From b772cd38e2202d34f308e79e08e208fd3f1e0e0c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 15:41:26 +0530 Subject: [PATCH 001/104] Phase the reviewed binary attestation contract --- ...6-10-13-attested-release-implementation.md | 1575 +++++++++++++++++ ...3-cli-packaging-attestation-integration.md | 112 ++ ...13-fastly-prebuilt-edgezero-integration.md | 125 ++ .../plans/binary-attestations-slice-map.json | 594 +++++++ .../specs/2026-01-15-attestation-design.md | 10 +- ...6-10-09-1235-release-attestation-design.md | 651 +++++++ ...12-1235-release-attestation-feasibility.md | 121 ++ ...26-10-14-release-tooling-rust-migration.md | 55 + .../schemas/release-index-v1.schema.json | 581 ++++++ scripts/release/js-composition.md | 269 +++ .../2026-10-15-consumer-real-fixtures.md | 150 ++ .../qualification/2026-10-15-js-two-builds.md | 170 ++ .../2026-10-16-confined-cargo-candidate.md | 102 ++ ...-fastly-canonical-reproduction-equality.md | 60 + .../2026-10-16-fastly-runtime-gate.md | 60 + .../2026-10-16-linux-cli-candidate.md | 153 ++ ...026-10-16-producer-normalized-manifests.md | 49 + .../2026-10-19-apple-sdk-inputs.md | 98 + .../2026-10-20-apple-cli-candidates.md | 157 ++ .../2026-10-20-linux-cli-gnu-follow-up.md | 153 ++ .../2026-10-21-candidate-release-index.md | 90 + .../2026-10-21-four-asset-candidate-join.md | 122 ++ ...2026-10-21-native-reproduction-schedule.md | 77 + 23 files changed, 5532 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-13-attested-release-implementation.md create mode 100644 docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md create mode 100644 docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md create mode 100644 docs/superpowers/plans/binary-attestations-slice-map.json create mode 100644 docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md create mode 100644 docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md create mode 100644 docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md create mode 100644 docs/superpowers/specs/schemas/release-index-v1.schema.json create mode 100644 scripts/release/js-composition.md create mode 100644 scripts/release/qualification/2026-10-15-consumer-real-fixtures.md create mode 100644 scripts/release/qualification/2026-10-15-js-two-builds.md create mode 100644 scripts/release/qualification/2026-10-16-confined-cargo-candidate.md create mode 100644 scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md create mode 100644 scripts/release/qualification/2026-10-16-fastly-runtime-gate.md create mode 100644 scripts/release/qualification/2026-10-16-linux-cli-candidate.md create mode 100644 scripts/release/qualification/2026-10-16-producer-normalized-manifests.md create mode 100644 scripts/release/qualification/2026-10-19-apple-sdk-inputs.md create mode 100644 scripts/release/qualification/2026-10-20-apple-cli-candidates.md create mode 100644 scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md create mode 100644 scripts/release/qualification/2026-10-21-candidate-release-index.md create mode 100644 scripts/release/qualification/2026-10-21-four-asset-candidate-join.md create mode 100644 scripts/release/qualification/2026-10-21-native-reproduction-schedule.md diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md new file mode 100644 index 0000000000..96e98f12ce --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -0,0 +1,1575 @@ +# Attested release binaries and verified consumption Implementation Plan + +## Phasing — 2026-10-22 + +This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. + +**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. + +**Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. + +For requirement headings below, R-CONFIG, R-INPUTS, R-SIGN and R-PUBLISH apply only to the Phase 1 subset described above; all additional clauses and every other R-ID belong to Phase 2+. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. + +### Phase 1 implementation checklist + +- [x] Copy reviewed spec, plan, feasibility and schemas from `122b78664b1235c717e9208fd200f9a9b0c335df`; preserve linked historical evidence. +- [ ] Add `.github/workflows/release.yml`: four tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. +- [ ] Generate CycloneDX 1.5 from the selected Cargo target after its locked release build and npm's installed tree from that same build; merge without reference collisions and bind the final binary hash. Record conservative build-dependency coverage, including npm build tools; do not claim exact retained-byte or native-source closure. +- [ ] Enforce fresh npm install, prohibit skip/prebuilt overrides, audit compile-time config inputs and reject planted operator values in every binary. +- [ ] Document exact GitHub CLI provenance/SBOM and immutable-asset verification, native baselines and deployment of verified Fastly bytes; update CLI guide, navigation and changelog. +- [ ] Run pure local tests, actionlint, format/domain checks, touched build gates, independent and security review; push this branch and open only the requested draft PR. + +| #1235 acceptance item | Phase 1 evidence | +| ----------------------------------------------------- | ---------------------------------------------------------------------- | +| Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | +| Tag publishes immutable binaries, SBOMs and checksums | release.yml build/sign/release jobs; human rehearsal pending | +| Provenance, SBOM predicate and asset verification | release-verification.md; human rehearsal pending | +| Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | +| Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | +| Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | +| Operator documentation | CLI guide, release-verification, CHANGELOG | + +### Complete remaining-work slice map + +The [machine-readable inventory](binary-attestations-slice-map.json) assigns every one of 434 source-diff paths exactly once, with full source commit citations. It is an ownership map, not automatic cherry-pick boundaries: shared Cargo manifests/locks, entry points and workflows require staged hunks reconciled with main. No source work is discarded. Source files later reused in Phase 1 are additionally identified in the PR; the remaining hunks stay with their assigned slice. + +| Phase / PR suffix | Scope | +| ----------------- | --------------------------------------------- | +| 2.1 | strict documents policy preflight source | +| 2.2 | process and native sandbox | +| 2.3 | immutable build facts | +| 2.4 | js inventory composition materials two builds | +| 2.5 | recipes inspection manual package | +| 2.6 | composition spdx | +| 2.7 | collection workflow jobs candidate index | +| 2.8 | publication owner | +| 2.9 | candidate ci same commit validation | +| 2.10 | consumer cli | +| 2.11 | docs and format misc | + +All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare, Spin and Axum distribution/qualification follow these slices. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection, a user-provided throwaway signing rehearsal, and explicit authorization of the first real tag. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish and verify the four canonical Fastly/`ts` artifacts required by #1235, with complete evidence and a validated manual Fastly production route. + +**Architecture:** A small domain-specific host-only Rust release tool builds and describes bytes; POSIX wrappers select its independently approved native bootstrap; a reusable workflow signs only validated outputs; one caller publishes the complete immutable distribution. A host-only Trusted Server verifier separately establishes build evidence, publication and consumer authorization before retaining verified bytes. Packaging and integrated EdgeZero deployment are separate milestones that consume these boundaries. + +**Tech Stack:** Rust 1.95.0/2024, existing workspace serde/serde_json/error-stack/derive_more/clap/sha2/log/tempfile/toml/gix/libc/flate2 as needed, qualified Rust schema/archive/binary parsers, Node 24.12.0/npm 11.6.2, Vite/Rollup, SPDX 2.3, GitHub CLI 2.97.0, actions/attest 4.2.2, Fastly CLI 15.1.0. CLI consumer tasks retain their existing tokio/reqwest architecture. + +--- + +## Authority, baseline and scope + +Planning baseline: `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52`, inspected 2026-10-13. The governing [contract](../specs/2026-10-09-1235-release-attestation-design.md), [feasibility register](../specs/2026-10-12-1235-release-attestation-feasibility.md) and [index schema](../specs/schemas/release-index-v1.schema.json) remain drafts awaiting maintainer approval. Delivery direction on 2026-10-13 is to develop/review the spec, plans and implementation together on `spec/1235-release-attestation` in PR #1262, with no new Trusted Server PR. The reviewed plan guides implementation here; maintainer contract/code approval remains required before merge, and the plan does not qualify a release or authorize live publication/deployment. Task 1 records this delivery contract and the unchanged qualification prerequisites. + +#1214 and #1213 are present at this baseline. Extend their merged private JS output/prebuilt interface and source-version mechanism; do not reimplement the historical audit snapshots. Source deployment currently delegates to EdgeZero and no release workflow/verifier exists. + +The initial cohort is exactly: + +| Canonical asset | Required assurance | Blocking qualification | +| ----------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `trusted-server-fastly.wasm` | `provenance-sbom-repeatable`; default, no reusable sandbox | Final complete-byte A/B equality, real SBOM/runtime and verified manual submission | +| `ts-x86_64-unknown-linux-gnu` | `provenance-sbom` | glibc ceiling 2.34; clean UBI 9, Ubuntu 22.04, Debian 12 startup | +| `ts-aarch64-apple-darwin` | `provenance-sbom` | Explicit loader floor 13.0; actual macOS 15 ARM execution | +| `ts-x86_64-apple-darwin` | `provenance-sbom` | Same loader floor; actual macOS 15 Intel execution | + +Every selected canonical Build A, SBOM, signature and publication check is mandatory. CLI Build B is a bounded measurement, not a shipping equality requirement. No Linux ARM64, installer packages, other adapter releases, retained-input archive, operator reconstruction or official offline deployment is added by this plan. + +Two linked plans keep independent work out of these closure criteria: + +- [CLI packaging attestation integration](2026-10-13-cli-packaging-attestation-integration.md): reconcile #1292, package exact canonical bytes and authenticate member identities. Later releases only after the required spec/schema amendments are approved. +- [Fastly prebuilt EdgeZero integration](2026-10-13-fastly-prebuilt-edgezero-integration.md): upstream prepared payload, integrated online production/staging and durable recovery; AC-8 is separate from initial #1235 closure. + +## Rust migration and current execution boundary + +The user's 2026-10-14 correction prohibits introduced Python release tooling. The [migration design addendum](../specs/2026-10-14-release-tooling-rust-migration.md) selects `crates/trusted-server-release`: a Rust 2024/1.95.0 host-only workspace member, excluded from default members and independent of core, CLI and JS crates. The existing host-only codegen member is the precedent. The tool is internal producer tooling, not a fifth canonical asset, a generic attestation framework or a publisher abstraction. `ts` remains the consumer in Tasks 17–20: CLI → core → JS makes the CLI unsuitable as the JS/bootstrap producer. + +Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrappers use an independently approved-source, OS-native Rust toolchain to build/select the release tool before executing it; a colocated SHA file or self-reported source label supplies no authority. Developer Cargo commands below establish local behavior only. Production needs the authenticated bootstrap, full qualified source/dependency/tool closure and reviewed native OS adapters. No language migration waives native SDK/tool acquisition or authenticating the original Fastly CLI archive through independently reviewed vendor-source evidence. + +- [x] **M0: Remove superseded tooling.** Introduced Python release code and validator requirements are removed. Deleted prototype behavior is not implemented or fixed by removal; pending Rust regressions below remain mandatory. +- [ ] **M1: Port primitives and preflight (Tasks 2–3).** Strict document/schema utilities, fixed policy/environment checks, local Git object/worktree authentication and the helper-closure fingerprint are implemented. Git commands use bounded process supervision and a shared command deadline. Independently authenticated bootstrap/tool acquisition and qualification remain incomplete; the closure fingerprint supplies no acquisition authority. Register each private test module before applying the nonzero-selection guard. +- [ ] **M2: Port material/process/OS boundaries (Tasks 7–9).** Establish original archive/Git identities, bounded child execution, kill/reap/cleanup, private staging, source/object read-only protections and real native denial controls. Preserve independent all-cohort remote receipts separately from rehashing tools actually installed in the current execution environment. +- [x] **M3: Restore real JS orchestration (Task 7).** Perform two fresh offline installations, actual lifecycle observations and denied missing-tool probes, all tracked bundle comparisons and both full registered JS/type-check suites. Only actual successful execution constructs a private typed completion bound to source, tools, settings and retained A bytes; parsed JSON, paths or booleans cannot construct it. The full Linux A/B execution test passed in ordinary PR CI on 2026-10-15 (1,275/1,275 tests per side, 13 byte-identical modules, six private roots removed); see the [two-build evidence report](../../../scripts/release/qualification/2026-10-15-js-two-builds.md). This is candidate CI execution, not hosted release qualification. +- [ ] **M4: Restore canonical recipes and inspection (Tasks 8–9).** Candidate recipes are implemented for all four assets and pass ordinary CI with `release_qualification: false`; qualification, the closed `build.sh` interface and the production reproduction profile remain pending. Compose actual Cargo preparation, compile, final acceptance and copying; collect Task 10 metadata/tree/native evidence through that same boundary before hosted qualification. Local tiny artifacts cannot qualify the full product or actual macOS 15/GNU cohort. +- [ ] **Then Tasks 10–22.** Implement the remaining Rust producer modules, workflow contracts, consumer verification and manual route in dependency order. Complete every selected qualification and full CI gate before release enablement/handoff. + +The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. + +Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Original archive authentication, bounded private extraction and lifecycle declaration review are implemented. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The experimental macOS profile now kills and rejects descendants that escape the process group, including with a scrubbed environment, by identifying its sandbox instance after each run; it remains unqualified and does not own descendant cleanup the way a container does. Native boundary controls pass ordinary Ubuntu 24.04 and macOS 15 CI; they do not qualify complete release recipes or authenticated tool selections. Original npm and Cargo Git material verification and two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI. Candidate recipes for all four assets are implemented and pass ordinary CI (unqualified, `release_qualification: false`). Qualification, the closed `build.sh` interface, the production reproduction profile, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. + +## Historical progress references + +Original task progress is preserved at source commit `122b78664b1235c717e9208fd200f9a9b0c335df` in this plan's original path. It is evidence for later slices, not Phase 1 completion. Last confirmed full green: `b6f5d92e4`, run `39639053390`; four-asset candidate join: run `39577910126`. Other cited runs: `39273777887`, `39290139247`, `39417433680`, `39420011152`, `39540057202`. + +## Work breakdown and ownership + +Use successive focused commit/review groups within PR #1262: (1) input/fact/JS guards, (2) qualified recipes and composition, (3) collection/signing/publication, (4) verifier/manual route/docs. Each depends on its predecessor; tag enablement waits for all four. No new Trusted Server implementation PR is created. Build scripts can be proved locally before production YAML exists. No intermediate PR may enable an incomplete release trigger. + +| File group | Responsibility | +| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `scripts/release/release-cohort.json`, `release-tools.json` | Reviewed fixed identities, profiles, approved RC branches and qualified environment pins | +| `crates/trusted-server-release/Cargo.toml`, `src/main.rs` | Host-only producer dispatch, with no product-crate dependencies | +| Release tool `src/documents.rs`, `policy.rs`, `preflight.rs`, `source.rs` | Strict bounded JSON, fixed contracts, authenticated source and explicit environment | +| Release tool `src/materials.rs`, `process.rs`, `boundary.rs`, `preparation.rs`, `javascript.rs`, `qualification.rs` | Original acquisitions, bounded execution, real OS isolation, fresh JS and native qualification | +| `scripts/release/bootstrap.sh`, `build-js.sh`, `build.sh`, `collect-js.mjs` | Approved native Rust bootstrap, fixed POSIX adapters and retained Node observer | +| Release tool `src/recipe.rs`, `inspect.rs`, `composition.rs`, `spdx.rs`, `collection.rs`, `distribution.rs`, `publication.rs` | Fixed recipes, actual composition, SPDX, transfers, distribution and sole-owner publication support | +| Release tool `src/package.rs` | Bounded trusted-source manual Fastly inspection; later approved packaging adapters | +| `.github/workflows/release-validation.yml`, `release-build.yml`, `release.yml` | Same-commit gates; isolated builds/signing; sole draft/publication owner | +| `crates/trusted-server-cli/src/release/` | Host-only consumer verifier: policy, strict documents, bounded gh, GitHub publication and evidence graph | +| `crates/trusted-server-cli/src/commands/release.rs` | Public read-only `ts release verify` adapter | +| Release tool private tests, `tests/commands.rs`, `tests/fixtures/`; CLI/core/JS tests | Producer negatives, public-command process tests, consumer fixtures and actual qualification | + +Execute each listed negative fixture as a separate red/green microcycle: write one case, run its focused filter, implement the smallest relevant behavior and rerun it, then move to the next case. A task groups related work for review/commit; its fixture list is not one giant implementation step. + +Task 2 utilities and the retained Rust/Node guard work are implemented. Remaining Rust module names below are proposed, not existing APIs. Create only modules needed by the current stage, without placeholder files. Private unit tests live alongside `src/.rs` under `#[cfg(test)]`, or in registered `src//tests.rs`; fixture data lives in `crates/trusted-server-release/tests/fixtures/`. `tests/commands.rs` drives the public binary. Do not make internal APIs public merely for integration tests. Use concrete error-stack reports, top-level imports, descriptive assertions, documentation on public APIs and project commit conventions. + +Keep direct dependency features minimal. Reuse workspace dependencies only where an actual stage needs them; the producer has no dependency on core, CLI or JS. Maintained TAR/ZIP, schema and binary-format parsers are justified by existing bounded archive, Draft 2020-12/Draft 7 and ELF/Mach-O/core-Wasm requirements. Research exact maintained versions from primary sources and qualify semantics/acquisition before pinning; this plan invents no replacement version or digest. Library parsing does not replace archive limits or source authentication. + +### Task 1: Freeze approved scope and reconcile the packaging owner + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Files:** Modify this plan and the governing spec only for approved corrections; reference PR #1292's `docs/superpowers/specs/2026-10-12-863-cli-release-packaging-design.md` and `docs/superpowers/plans/2026-10-12-cli-release-packaging.md` after their owning branch is available. + +- [ ] Read the current spec revision; record its commit and the same-branch/same-PR delivery direction. Work through reviewed tasks in PR #1262; obtain maintainer contract/code approval before merge. Do not require a separate spec merge or open another implementation PR. +- [ ] Record one publication owner, `.github/workflows/release.yml`, and one signing identity, same-revision `release-build.yml`. #1292 MUST NOT independently publish an overlapping immutable tag while #1235 assets are still pending. +- [ ] Confirm the current four-artifact cohort and three CLI baselines. Initial #1235 delivery may precede packaging on a different new tag. Adding package records or ARM64 requires the linked plan's approved delta, never a covert schema expansion here. +- [ ] Settle one strict producer control channel: `TRUSTED_SERVER__RELEASE_BUILD=1`, injected by trusted recipe after environment sanitation, recorded as an input, rejected if present with another value. This is a fact/input guard, not the prohibited parallel JS fallback mode. Name the checked prebuilt inventory input `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY`. +- [ ] Confirm permission policy remains runtime-variable; conflicting #1045/#1084 code blocks release enablement. Record the owner and dependency rather than designing a new permission transport. +- [ ] Review milestone exits with maintainers in PR #1262. Commit any approved documentation delta as a focused commit on this branch with `Clarify release implementation prerequisites`. + +**Exit:** An engineer can identify approved source, scope, producer controls and the single release authority; no unspecified cross-PR ownership decision remains. + +### Task 2: Establish Rust producer fixtures and strict document utilities + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** `crates/trusted-server-release/Cargo.toml`, `src/main.rs`, `src/error.rs`, `src/documents.rs`, private registered `documents::tests`, `tests/commands.rs`, `tests/fixtures/README.md`. +**Modify:** workspace `Cargo.toml` membership only; keep default members unchanged. Retain `scripts/release/tests/fixtures/README.md` as the migration pointer. + +- [x] Write private Rust tests for duplicate keys at every depth, nonfinite/out-of-range JSON, invalid UTF-8/surrogates/BOM/trailing controls, unsafe names, depth 65, oversized role inputs, negative/noninteger lengths and ambiguous duplicate identities. Include lexical integer overflow in opaque nested fields and top-level values (`18446744073709551616`, `-9223372036854775809`), decimal and exponent float overflow, exact integer boundary controls, and large number-like text inside strings with escaped quotes/backslashes that MUST NOT be rejected as a numeric token. +- [x] Register tests, derive the host target as below, and run `cargo test -p trusted-server-release --target "$HOST_TARGET" documents::tests` with the nonzero-selection guard. Expect compilation/assertion failure for absent behavior, not an empty successful selection. +- [x] Implement bounded byte-first loading, SHA-256 of original bytes, safe flat names, deterministic output and atomic writes. Use a recursive Serde visitor/seed that rejects duplicate object keys **before insertion**, rather than deserializing into `serde_json::Value` first. A representative parser core is: + +```rust +use std::fmt; + +use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; + +struct StrictSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictSeed { + type Value = Value; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + 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("bounded unambiguous JSON") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + if self.depth > 64 { + return Err(A::Error::custom("JSON container depth exceeds 64")); + } + let mut object = Map::new(); + while let Some(key) = access.next_key::()? { + if object.contains_key(&key) { + return Err(A::Error::custom("duplicate JSON key")); + } + let value = access.next_value_seed(StrictSeed { + depth: self.depth + 1, + })?; + object.insert(key, value); + } + Ok(Value::Object(object)) + } + + fn visit_seq(self, mut access: A) -> Result + where + A: SeqAccess<'de>, + { + if self.depth > 64 { + return Err(A::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_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + + 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 + where + E: serde::de::Error, + { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite JSON number")) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } +} +``` + +The implemented utility chooses the mandatory role byte limit before reading/allocating and reject BOM/invalid encoding. Before `deserialize_any`, validate numeric tokens across the **whole document**, including opaque fields and top-level values, with a string-aware lexical scanner that skips quoted text and handles escapes. Negative integer tokens MUST fit `i64`; nonnegative integer tokens MUST fit `u64`. Decimal/exponent tokens MUST parse as finite `f64`; overflow fails. `serde_json` otherwise converts some overflowing integer tokens to finite floats, so the visitor's finite-float check alone is insufficient. Use the existing scanner in `crates/trusted-server-js/build/inventory.rs` as a behavioral reference without adding a JS-crate dependency. Then start with `StrictSeed { depth: 1 }` and require `serde_json::Deserializer::end()` so grammar/trailing data fails. Closed role validators additionally enforce stricter safe-integer limits and reject negative/noninteger lengths. The representative visitor above is the ambiguity parser, not the complete strict front end; schema validation never substitutes for lexical prevalidation, byte bounds, exact-original hashing or semantic joins. + +- [x] Qualify a maintained Rust schema evaluator. The locked `jsonschema` 0.58.6 is a locally checked evaluator candidate, not an approved production tool pin: use minimal features, explicit dialect and offline construction, with only known retained schemas and no arbitrary schema-file CLI override. Governing schemas require Draft 2020-12 `prefixItems`/`items` positive/negative cases; the official SPDX 2.3 schema uses Draft 7. Disable remote/file retrieval and reject unregistered references. +- [x] Retain the official SPDX schema at source commit `aadf3b0b8dbbabdb4d880b0fc714255fea436ff7`, path `schemas/spdx-schema.json`, 45,312 bytes and SHA-256 `239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b`. Confirm raw-byte identity and dialect before use. Candidate compatibility or a schema-valid toy document does not establish complete real-artifact coverage. +- [x] Rerun documents/private atomic-write negatives and command-process tests; run host-target clippy/format. Commit only after authorized review with `Add strict Rust release document utilities`. + +### Task 3: Encode cohort, tools and fail-closed release preflight + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Retain:** `scripts/release/release-cohort.json`, `release-tools.json`, `crates/trusted-server-cli/release-policy.json`. +**Create as needed:** release tool `src/policy.rs`, `preflight.rs`, `source.rs` and private registered tests; wire the approved POSIX bootstrap adapter without weakening pending selections. + +- [ ] Port cases for missing/extra artifacts, profiles changed after failure, unknown target/feature, missing pins and every skip value (`""`, `"0"`, `"1"`). Include wrapper, Cargo config, NODE_OPTIONS, native selectors, unexpected ignored/nested validation files, modes/links and source mutation. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" preflight::tests` and `source::tests` separately with the nonzero-selection guard; expect the missing Rust behavior to fail. +- [ ] Preserve exact tracked producer/consumer policy bytes, concrete fixed identity/profile/action/bounds and the raw source-owned tools input. Runtime consumer enforcement remains Tasks 17–20. No downloaded policy override or silent schema/cohort/capability amendment is permitted. +- [ ] Map four asset IDs to fixed package/bin/target/features; never accept JSON commands. Preserve Rust/Node/npm, gh 2.97.0, attest 4.2.2/full SHA and Fastly 15.1.0. Populate binary/image/SDK digests only from authenticated acquisition and Task 8 measured review. The production preflight/selector requires all qualifications by default and MUST fail on pending/null pins; omitting a flag MUST NOT return success. A separately named audit diagnostic MAY describe incomplete inputs but MUST NOT construct authorization state. No JSON qualified boolean or dummy digest is accepted. +- [ ] Audit the actual inherited environment before clearing it; reject skips and unreviewed channels, then build an allowlisted child environment. Do not hide hosted/setup overrides under `env -i`. Accept vetted setup exports such as `RUSTFLAGS`/`VCPKG_INSTALLATION_ROOT` only with explicitly reviewed, qualified values and path identities; otherwise reject. Remove publisher prefixes and inject only approved commit/control facts. Derive epoch from approved Git committer time. Check tracked and unexpected source-only files, including the full JS validation library, modes/links before and after execution; preserve narrow reviewed coordination exceptions. +- [ ] Select generic and target-suffixed AWS-LC settings, bundled SQLite, Cargo config/home layers, sysroot/tool paths and Vite mode. Set `AWS_LC_SYS_USE_SYSTEM=0`, `LIBSQLITE3_SYS_USE_PKG_CONFIG=0`; reject alternate selectors rather than setting weaker defaults. +- [ ] Authenticate approved source and acquisitions into private typed states. Keep independent all-cohort remote qualification receipts separate from current-environment installation identity: rehash the actual selected native binaries/SDK inputs immediately before use. Serialized `authenticated`/`qualified`/`completed` booleans, source labels or checksum files cannot construct either state. +- [ ] Rerun negatives, including fictional operator-config independence and wrong-bootstrap source; run host lint/format. Commit `Define fixed Rust release inputs and preflight policy`. + +**Interface:** after independently approved native bootstrap, `trusted-server-release preflight --cohort scripts/release/release-cohort.json --source-commit --require-qualified`; exit 0 only for a complete selected contract, otherwise fail before preparation/signing. A developer may exercise it with `cargo run -p trusted-server-release --target "$HOST_TARGET" -- preflight --cohort scripts/release/release-cohort.json --source-commit "$APPROVED_SOURCE_COMMIT" --require-qualified`, which provides no official tool or release assurance. + +- [ ] Reject unlisted RC branches and a malicious candidate cohort that lists itself; require independently reviewed tracked branch/governance inclusion, actual remote-tip ancestry and active ruleset/bypass validation. Current eligibility is exactly `["main"]`; reject local remote-ref substitutions. +- [ ] Audit the true Git common directory, including linked worktrees, for alternates, grafts, shallow/replacement/config/environment channels. Explicitly reject unsupported linked indirections rather than partially checking them. Preserve bounded detailed Git errors. Reject an untracked extensionless `rust-toolchain` before bootstrap/rustup selection, including alongside the tracked TOML pin. Join approved source to the original JS inventory commit. + +### Task 4: Reconcile immutable build facts and CLI source resources + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Modify:** `crates/trusted-server-core/build.rs`, `build_support/git_version.rs`, `tests/git_version_resolve.rs`, `src/lib.rs`; `crates/trusted-server-cli/src/run.rs`, `src/prebid_bundle.rs`. +**Create:** core `src/build_info.rs`; CLI `build.rs`, `tests/release_build_facts.rs`. +**Modify when adding the owning facts surface:** `crates/trusted-server-adapter-fastly/src/main.rs`; retain existing version-header/service-version behavior. + +- [x] Add strict full-commit tests: missing/malformed/branch/tag values fail in release mode without local-git fallback; development behavior stays covered. Add two external tags/same source yielding identical facts, owning-target/variant checks and compiled-path fallback rejection. +- [x] Run `cargo test -p trusted-server-core --test git_version_resolve` and `cargo test -p trusted-server-cli --target "$HOST_TARGET" --test release_build_facts`; expect the new behavior to fail. +- [x] Extend the merged #1213 helper using the approved release control. Derive target from Cargo `TARGET` through `TRUSTED_SERVER__BUILD_TARGET`, package version from the owning product, and default/reuse variant at the Fastly owner. Expose typed immutable facts without importing verifier/publisher keys into core. Do not embed tag, run ID, time or final checksum. +- [x] Set clap's version text to `ts package= source= target= variant=default`. Read Fastly owning package/target/variant facts through existing startup diagnostics. Remove unread variant and source-owner re-emissions; core remains the source identity owner. Official releases compile out `find_js_lib_dir`'s literal developer checkout fallback; preserve explicit/current-checkout resource discovery. Keep the tracked example config as a fictional scaffold, never active settings. +- [x] Run core fact/template tests, CLI tests/clippy and Fastly version-header tests. Outside-checkout help/version/config-init MUST pass; Prebid source-dependent commands return documented prerequisites. Commit `Derive immutable release build facts`. + +### Task 5: Authenticate the existing prebuilt JS interface + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Modify:** `crates/trusted-server-js/build.rs`, `build/bundle_set.rs`, `src/lib.rs` test inclusion. +**Create:** `crates/trusted-server-js/tests/release_prebuilt.rs`. + +- [x] Write process/fixture tests for prebuilt mode plus any skip presence, absent inventory, wrong source/digest/size, duplicate IDs, unsafe filenames, missing/empty/extra modules and source changed during copy. +- [x] Run `cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt` with the nonzero-selection guard; expect current prebuilt behavior to fail these guards. +- [x] Extend the existing bundle-set validator; require `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` in release mode and validate exact input and private copied bytes. Track both paths/control values with Cargo rerun directives. Reject skip before selecting prebuilt/source path; no TSJS_RELEASE_BUILD parallel mode and no read from old `dist/`. +- [x] Preserve ordinary development's supported fresh source build and #1214's inventory-absent prebuilt path, including unrelated non-bundle extras and supported directory aliases. An inventory-present development build validates inventory source syntax/hash but MUST NOT compare it with the override/tag/branch display label. In release Rust jobs require prebuilt inputs and prohibit npm invocation; a fake failing npm proves it is never run. +- [x] Run JS crate tests, Fastly/CLI target checks and prebuilt negatives. Commit `Bind release JS inputs to a checked inventory`. + +### Task 6: Collect JS contributions without changing bundle bytes + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Modify:** `crates/trusted-server-js/lib/build-module-options.mjs`, `build-all.mjs`. +**Create:** `lib/test/release-composition.test.mjs`, `scripts/release/collect-js.mjs`. + +- [x] Write temporary IIFE fixtures for type-only/external Prebid import, eliminated module, transitive npm contributor and plugin/virtual helper. Build with/without recording and assert identical final JS bytes. +- [x] Run `cd crates/trusted-server-js/lib && npx vitest run test/release-composition.test.mjs`; expect missing recording behavior. +- [x] Add an opt-in Rollup contribution hook at `moduleBuildOptions()`. Join source/package origins to the lockfile, retain installed/lifecycle/build-tool materials separately, and hash final files after Vite/esbuild. Suppress ambient `.env` loading through the reviewed release invocation; explicit production mode alone is not sufficient. +- [x] Emit sorted version-1 inventory with source, module names/lengths/digests and origins; ambiguous contributing ownership fails. State conservative post-Rollup reachability limits. +- [x] Run byte-preservation and ownership negatives plus the full JS test/format checks. Commit `Record release JS composition evidence`. + +### Task 7: Prepare and compare two independent JS builds + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Retain/modify:** `scripts/release/build-js.sh`, `bootstrap.sh`, `collect-js.mjs`. **Create:** release tool `src/materials.rs`, `process.rs`, `boundary.rs`, `preparation.rs`, `javascript.rs` and private registered tests; add public process fixtures to `tests/commands.rs`. + +- [x] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. Prohibited download and missing-tool denial are actual runtime controls exercised in the full Linux fixture; that fixture also asserts each side exposes only its own private roots and shares only read-only originals and tools. +- [x] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" materials::tests`, `process::tests`, `boundary::tests`, `preparation::tests` and `javascript::tests` separately with the nonzero-selection guard; expect missing Rust orchestration. +- [x] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Rust migration MUST reprove this with actual full local execution and fresh fixtures; Task 8 MUST qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Read-only original sources/object stores and authenticated archive/tool inputs remain outside private installation outputs. Timeouts/cancellation/pipe limits kill and reap descendant process groups; bounded Docker stop/remove and all temporary-profile/resource cleanup run on success and failure. Bundle under qualified network denial; compilation-time downloads fail. The passing run used an ordinary-CI Docker denial boundary; qualifying that boundary remains Task 8. +- [x] Compare final core and all tracked integration files byte-for-byte. Retain only A plus its original inventory after equality. Construct a private typed `JsBuildCompleted` only from the actual successful two-install/test/build/control/comparison path, binding authenticated source/tools/settings, inventory raw digest and retained module bytes. Rehash at handoff; a checked inventory or JSON completion assertion cannot substitute. Unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` into BOTH actual A/B Vitest processes, independent of inherited developer flags; run every registered test/type check and reject a zero selected suite; verify source-only trees remained intact. +- [x] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Require both actual fresh full registered JS/type-check suites and all tracked final bundles before `Build and compare shared Rust release JS inputs`; fixture-only counts remain separate. Landed as `Build and compare two fresh offline JavaScript installations` plus follow-up fixes; Linux CI evidence is in the [two-build evidence report](../../../scripts/release/qualification/2026-10-15-js-two-builds.md). + +- [x] Port actual regressions for immutable macOS source/object regular files with `nlink == 1`; reject pre-existing hard links and prove write-through/restoration cannot bypass the boundary. Restrict signals to `same-sandbox`: own-child positive, outside same-user sibling and orchestrator denial negatives. Parent/child/grandchild install lifecycle, Cargo/compiler/proc-macro/native children MUST inherit the same OS-denied controls. `nlink == 1` scans, pre-existing hard-link rejection, run-time hard-link denial, run-time mutation detection, `same-sandbox` signal positives/negatives, grandchild socket denial and escaped-descendant socket denial pass on macOS 15 CI. A descendant that leaves its process group, even with a scrubbed environment, is identified by its per-run sandbox instance, killed and the run rejected; only processes started during the run are eligible. R-JS uses the single shared Linux JS job, so no macOS install-lifecycle path exists; Task 9 must reprove Cargo/compiler/proc-macro/native child inheritance through its actual recipes. +- [x] Port Docker boundary regressions: use Docker's default private PID namespace (no invalid `--pid=private`), and actually execute positive/denial/recipe controls instead of testing argv alone. Keep BOTH host seccomp profiles and an empty Docker config disjoint from every container-writable root; scan before creating them and rehash BOTH before EVERY host command. Select fixed authenticated Docker client/config/Unix daemon, no TTY/plugins/context or environment overrides, and read-only anchors. Reject escaping and dangling aliases in the CONTAINER namespace. Linux container tests execute actual positive, denial and confined commands in ordinary CI. Cleanup `stop`/`rm` still rehashes every input first: a changed client or config blocks the command, while a changed seccomp profile (which those commands do not read) is recorded as a failure and cleanup proceeds. +- [x] Bound child pipes/deadlines; timeout/cancellation/group kill MUST reap descendants and clear token/provider/injection environment. Record real errno without inventing `ETIMEDOUT`. Use host-independent tool/path/environment fixtures; no ambient shim or interpreter supplies trust. + +### Task 8: Qualify real platform environments before fixing recipes + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Retain:** `scripts/release/qualification/results.schema.json` and reviewed seccomp baseline/license. **Create:** release tool `src/qualification.rs`, native probe dispatch in `src/boundary.rs`, private registered tests; update `release-tools.json` only with authenticated measured pins. + +- [ ] Write schema/preflight fixtures rejecting wrong architecture, missing SDK/tool digest, failed startup, permitted confined sockets and invented host compatibility. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" qualification::tests` with the nonzero-selection guard; expect unimplemented Rust qualification checks. +- [ ] Execute throwaway full recipes on the actual proposed GitHub-hosted environments: digest-pinned GNU builder <=2.34, matching macOS 15 ARM/Intel, explicit loader floor 13.0. Record host, rustup paths, compiler/linker/SDK/native identities. Docker absence on a developer Mac is not replaced by an inferred Linux result. +- [ ] On each recipe run an unconfined socket success control and a confined denied socket probe, then full compilation under the same qualified boundary. Linux container uses process network isolation; macOS profile MUST permit required filesystem/process/tool activity while denying sockets. Archive/tool acquisition is separate and authenticated; actual offline install lifecycle hooks execute under denial. +- [ ] Record actual startup/link evidence and complete Fastly runtime request, not only host health. Missing Intel execution or GNU baseline stops qualification. A different platform/cohort requires reviewed spec changes, not a silent downgrade. +- [ ] Pin authenticated observed digests/bootstrap data and add result validation; commit `Qualify controlled release build environments`. + +**Gate:** This task produces measured pins, not placeholder configuration. It can run only with the separately authorized disposable hosted resources. It does not publish official tags or deploy a real publisher service. + +Implementation sequencing: review the local validation framework and candidate boundaries first, then implement the shared Task 9 recipes and Task 10 composition captures. Complete the authenticated candidate/production adapters and shared-JS producer binding before measuring the executable closure. Hosted qualification MUST invoke those same recipes with independently authenticated candidate selections; it cannot require its own future qualification result. Production selection remains closed until the hosted evidence and measured pins are reviewed. Local framework completion does not satisfy the hosted checkboxes above. + +- [ ] Join observations to the reviewed catalog at the approved source: exact selection digest, tool versions/authentication methods, complete helper compiled closure and every path-dependency manifest, including `crates/trusted-server-openrtb/Cargo.toml`, tool-version files, authenticated executable digests, SDK identities and raw receipts. A code change invalidates the closure; labels/checksums cannot self-qualify. Hash the actual selected shell; do not assume `/bin/sh --version` works on dash. Unsupported pending selections keep production closed. +- [ ] Derive container resource limits no higher than actual host capacity (a private Ubuntu runner may have two vCPUs). A Cargo jobs A=4/B=2 variation does not authorize `--cpus 4` on that host. Use actual controls and whole-product builds, not deleted prototype claims. + +### Task 9: Implement fixed fresh Rust recipes and native inspection + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +Fastly host-tool selection increment (2026-10-22): the Linux x86_64 candidate +plan explicitly selects `/usr/bin/cc` as its host Rust linker driver and +`HOST_CC`, `/usr/bin/ar` as `HOST_AR`, and the measured Rust tree's +`lib/rustlib/x86_64-unknown-linux-gnu/bin/rust-lld` as the Wasm linker. +The host-triple Cargo linker setting applies to host artifacts during an +explicit-target build; no unstable host configuration or compiler wrapper is +introduced. Host C selectors do not establish target C compilation or prove +that the selected drivers executed. Existing GNU and Apple selections stay +separate. + +The existing staged measurement helper gains a fixed private Fastly mode, +without a new command in `main.rs`. It runs before and after Cargo in the +existing denied sibling sandbox, excluding the writable target root. It checks +the actual Rust host, resolves and hashes selected files inside the image, +and independently hashes the compiler's reported assembler candidate. The +receipt binds the helper, exact plan and build/measurement boundary identities; +changed before/after observations fail. This changes only the opaque +`platform.native_tools` receipt payload, not the checked composition fields or +candidate-index companion formats. Production selection remains closed. + +**Retain/modify:** `scripts/release/build.sh` and approved native bootstrap. **Create:** release tool `src/recipe.rs`, `inspect.rs`, private registered recipe/native/final-Fastly tests and public-command process fixtures. + +- [ ] Test unknown asset/variant, reused target output, source/material mutation, unselected native library, GLIBC symbol ceiling violations, runner-only paths and Mach-O deployment-target mismatch. Add role-specific variation fixtures: missing selected B variation, normalized values misreported as varied, unsupported user variation claimed as passed and incorrect observed concurrency/umask. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" recipe::tests` and `inspect::tests` separately with the nonzero-selection guard; expect missing Rust recipe/inspection behavior. +- [ ] Implement a closed interface: `build.sh --asset --role A|B --source-commit --js-dir --js-inventory --materials --output `. Select package/target/features from reviewed source; JSON never supplies executable commands. +- [ ] Implement checked A/B ambient profiles in the recipe and record them in `release-tools.json`: different external checkout roots, A umask 022/B umask 027, UTC versus a qualified alternate timezone, distinct qualified locale settings, different observed start times and A jobs 4/B jobs 2 within the qualified insensitive envelope. Vary execution user only where Task 8 proves support; otherwise record it unsupported without claiming it changed. Capture both ambient inputs and effective compiler values after sanitation/remapping; fixed internal locale/path/epoch values are normalized, not varied. No unreviewed caller overrides can choose profiles. Tests assert genuinely observed supported differences and reject dishonest/omitted records. +- [ ] Apply Task 8's qualified OS/container denial profile in the actual production `build.sh`, not just the qualification prototype. Every production invocation MUST record a successful unconfined socket control and denied confined socket probe, then execute Cargo, compiler, proc-macro, build-script and native-tool children inside that same denied boundary. Authenticated archive/tool acquisition alone may fetch materials; offline installation lifecycle hooks and all compilation run under denial. A build-script/lifecycle download-attempt fixture MUST fail, and a missing or ineffective confinement proof rejects the output; `--frozen` is not the boundary. +- [ ] Build explicit packages with `--release --frozen --message-format=json-render-diagnostics` after preparation. Keep `debug = 1`. Capture compiler/build-script output separately from diagnostic stderr; copy only final output after any reviewed transformations. Do not invoke Fastly source build/annotation. Never strip after evidence/signing. +- [x] Enforce final-byte path checks inside recipe completion using its own bound host/execution roots, not only candidate fixtures. Require bounded nofollow size/hash verification, reject `/boundary/` and actual build roots, and require a remapped prefix. For GNU builds, close unbound writable `/tmp` and shared-memory storage and reject image-declared volumes. Keep macOS's temporary-path inspection unchanged. Docker execution starts only an acknowledged full container ID and confirms cleanup by that ID. +- [x] Prove the refined GNU boundary with actual denied writes, successful bound-`TMPDIR` write/read/removal, the real timeout-cleanup regression, full fresh JS and Cargo builds, final CLI inspection and all three clean startup images. The observed Chromiumoxide `/tmp/aws/lib` lookup constant is runtime data, not evidence of build identity; its actual ELF context was measured before refining the GNU-only path policy. Passed in ordinary PR CI with identical Linux CLI canonical/reproduction bytes and all three startups; see the [Linux CLI candidate report](../../../scripts/release/qualification/2026-10-16-linux-cli-candidate.md). These ordinary-CI results do not qualify production pins or authenticated acquisition. +- [ ] Inspect ELF/Mach-O libraries and paths, run clean Linux startup fixtures and real ARM/Intel macOS startup outside checkout. With differing fictional operator config removed by preflight, require identical sanitized effective source/tool/build inputs for every artifact and reject embedded operator sentinels/config fallback. Require complete executable equality for Fastly; preserve native nonmatching outcomes as measurements rather than adding a native repeatability shipping gate. Separately show runtime configuration can change without rebuilding. +- [x] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture MUST NOT invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. Implemented and accepted for both canonical and reproduction outputs in ordinary PR CI, with a closed-origin control rejected; see the [runtime gate report](../../../scripts/release/qualification/2026-10-16-fastly-runtime-gate.md). The Viceroy digest is recorded, not yet independently pinned. +- [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. + +Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That first build used unauthenticated acquisition and `cargo vendor` manifests. The producer now normalizes Git package manifests itself from the verified Git tree; later builds record eight producer-normalized and zero caller-derived manifests, and an evidence-only differential against `cargo vendor` found no differences (see the [producer-normalized manifests report](../../../scripts/release/qualification/2026-10-16-producer-normalized-manifests.md)). Acquisition remains unauthenticated. The plan now remaps the checkout, vendor, Cargo home, Rust tool (sysroot) and target roots to fixed prefixes through one `--config target..rustflags` entry, which merges after reviewed target rustflags (environment rustflags stay rejected, a tracked `build.rustflags`, which target rustflags would supersede, is refused, as is any extensionless `.cargo/config`). The remapped roots must be pairwise disjoint, because rustc applies the last matching prefix, and the remap is bound by the plan digest. On Linux both roles see identical container paths (`/boundary/...`), so the checkout-root variation is host-only; the remap is proven on the final Fastly bytes by a scan that rejects `/boundary/` and every host build root and requires a fixed remapped prefix. Literals built from `env!("OUT_DIR")` or `env!("CARGO_MANIFEST_DIR")` are not remapped by rustc and are caught by the same scan. A pure comparison requires one role A and one role B Fastly build over identical source, lock, materials, JS handoff and tools, an honest ambient pairing and complete final-byte equality, reporting sizes, digests, the first differing offset and its Wasm section otherwise. The ordinary CI job now builds roles A (umask 022, four jobs) and B (test-only alternate profile: umask 027, two jobs, distinct checkout root and start time) from one JS handoff and compares them. Both builds were byte-identical in ordinary PR CI, and the scan found the remapped prefixes and no host or container paths; see the [canonical and reproduction equality report](../../../scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md). The Linux `ts` candidate now builds through the GNU baseline, passes final-path/ELF inspection and all three clean startup images, and has a byte-identical reproduction; see the [Linux CLI candidate report](../../../scripts/release/qualification/2026-10-16-linux-cli-candidate.md). Viceroy final-byte acceptance is also proven in ordinary CI as recorded above. The two Apple `ts` candidate recipes and native startups have since passed in ordinary CI (runs 39273777887 and 39290139247, recorded below); qualified pins remain open, and production role B stays unqualified. Qualified and production selections stay rejected by the execute gate. Only the `cfg(test)` ordinary-CI candidate selection accepts the macOS boundary, which uses post-run-reaped descendant cleanup (approved 2026-10-19); that is candidate evidence, not Task 8 qualification, and production code cannot name that selection. In code, role A is `Role::Canonical` (Build A) and role B is `Role::Reproduction` (Build B); only `Role::parse` and `Role::spec_label` keep the external `A`/`B` spelling used by `--role` and the tracked `ambient_profiles` keys. + +### Task 10: Record selected Cargo units and native materials + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +Fastly selected-file evidence (2026-10-22, local implementation; hosted proof +pending): the receipt retains selected and resolved paths, SHA-256 and size for +the host C compiler driver, host archiver, configured host assembler candidate +and selected Wasm Rust linker. The host Rust linker selects the same measured +C driver. SPDX projects these four raw files only as build tools, with no +invented acquisition, download, license or executed-helper claims. The bound +Rust installed-tree measurement remains separate. + +After successful denied measurement, the compiler, archiver and linker +host-path gaps can be removed for this explicitly selected Linux profile. +The assembler is merely compiler-configured, so its role gap remains. Expected +candidate SPDX gaps are exactly `fastly-original-acquisition-unverified`, +`fastly-tool-closure-unmeasured`, `native-material-coverage-incomplete`, +`native-tool-host-path-unresolved:assembler` and +`native-tool-host-path-unresolved:build_tools`. Missing or inconsistent required +measurements fail rather than manufacturing evidence. Other profiles retain +their existing gaps. This does not complete Task 10, original acquisition, +qualification or qualified SPDX generation. The next hosted Fastly candidate +must confirm these gaps and the unchanged real producer-receipt check before +this increment is described as integrated. + +Local verification passed 216 pure tests: 70 SPDX, 68 composition, seven Fastly +tool tests, 18 GNU measurements, 14 recipe-plan, 13 tool-selection, four +source-replacement, 20 completion and two unchanged candidate-index golden +tests. The explicit-selection assertion failed against the previous bare +driver selection and passed after restoring the implementation. Filesystem +tests independently check raw SHA-256/size, changed bytes and escaping aliases; +parser tests reject malformed compiler queries. Strict host-target +all-target/all-feature Clippy, all-feature documentation and formatting passed. +Independent specification and quality reviews found no remaining blockers. +The minimal Fastly expectations in `recipe/tests/plan.rs` were explicitly +authorized; their Linux branch and real boundary execution remain hosted checks. +No process-spawning or ignored tests were run locally. + +Remaining dependency audit (2026-10-22, `41560cfd9`): the open capture exit +below requires conservative native/tool coverage. The specification does not +require an exact compiler DAG or mandatory execution tracing: an inventory of +all permitted relevant inputs, coupled with enforced access containment, can +also establish closure. The current recipes do not establish that containment. +The next implementation must address these dependencies rather than treating +another successful snapshot hash as closure: + +| Remaining evidence | Dependency and current limitation | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly host tools and supporting inputs | At audit head `41560cfd9`, five host-tool roles were unresolved. The increment above measures selected Linux host compiler/archiver/linkers while retaining assembler, build-tools and full-closure gaps. Other Fastly profiles keep all five role gaps. Supporting inputs still require Task 9/10 closure work; GNU CLI receipts do not cover Fastly automatically. | +| GNU tool/input closure | Task 9 must constrain or account for relevant inputs beyond the inventory roots; Task 10 must inventory and join that closure. `recipe/gnu/image.rs` explicitly leaves other files available. Configured helper paths are observations, not exhaustive dependency coverage. | +| Apple tool/library closure | Task 9 must constrain or account for relevant inputs outside measured directories; Task 10 must measure and join them. `recipe/apple.rs` permits broad developer-directory access. Measuring the selected toolchain's entire `usr/lib` does not resolve outside-directory or transitive dependency inputs. | +| Unreadable GNU privileged helpers | Tasks 9/10 must obtain measured content or establish reviewed, enforced noncontribution/exclusion treatment for the three helpers. Acquisition evidence alone cannot close their content gap. Retain metadata-only observations and the gap until proven; do not infer hashes or expand privileges. | +| Original acquisition | Tasks 3/8 must bind authenticated Rust/tool/SDK/image distributions to the selected inputs. Installed-tree hashes do not authenticate original acquisition; pending pins in `scripts/release/release-tools.json` remain pending. | +| Platform qualification | Task 8 must qualify the actual platform, boundary and recipes against the selected closure. Passing ordinary-CI candidates does not construct the closed qualified tool or sandbox types. | +| Complete owning composition | Task 10 must resolve the first four material-coverage rows before supplying complete composition evidence. Acquisition and production qualification are independent additional requirements; complete measured coverage alone would not open `spdx::generate_from_completed`, which deliberately rejects current candidates. | + +Paths in the table are relative to `crates/trusted-server-release/src/` unless +shown otherwise. Content measurement, dependency closure, authenticated original +acquisition and qualification are distinct proofs. Acquisition and qualification +rows describe production dependencies, not prerequisites for measuring complete +material coverage. None is substituted for another, and this audit changes no +completeness or qualification state. + +**Create:** release tool `src/composition.rs`, private `composition::tests`, `crates/trusted-server-release/tests/fixtures/composition/`. + +- [x] Add fixtures for host/target dual-role packages with different unit features, ambiguous source IDs, dev-only graph entries, unknown native origin, system substitution and prepared-but-discarded CLI JS. +- [x] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" --bin trusted-server-release composition::` with the nonzero-selection guard; the scoped 2026-10-21 run selected and passed 63 pure tests. +- [x] Join explicit-target compiler/build-script messages with metadata/tree and selected material identities; preserve each observed unit's features/context. Package union features cannot stand in for effective per-unit features. Retain raw observations for audit; do not claim an exact compiler dependency DAG. +- [ ] Capture AWS-LC/ring/SQLite native source and output archive hashes/link directives, Rust sysroot and external platform libraries. Validate generic/target-specific selection against the recipe. Distinguish embedded components, external requirements and build tools. +- [x] Inspect final files conservatively for complete JS module strings. Record absence explicitly; Fastly and CLI do not need the same retained set. Do not force unused JS into CLI. + +Scoped audit and hardening (2026-10-21): the checked items above describe existing +implemented collectors, not newly established full coverage. Named fixtures in +`composition/tests.rs`, `graph/tests.rs`, `native/tests.rs` and +`javascript/tests.rs` already exercise the listed cases. The dual-role feature +cases are synthetic; retained real cross-build fixtures establish host-only +membership, not a captured dual-role build or exact compiler dependency DAG. +AWS-LC/ring/SQLite observations already bind verified original distributions, +prepared source/subtrees, consuming contexts/features, static output archives +and link directives. Rust installed-tree measurements and logical external +runtime requirements remain distinct from original acquisition and complete +native/tool coverage; that capture exit stays unchecked. + +A new negative fixture replaces a native archive between checking its magic and +hashing its path. The previous implementation accepted a valid header from one +inode and a digest from a different non-archive file. The collector now hashes +the checked header and remaining bytes through the same held descriptor, with +bounded reads, owning-attempt checks and matching final descriptor/path metadata. +No reported path or digest becomes trusted without local measurement. + +GNU receipts already record incomplete closure and unqualified acquisition. +Candidate SPDX now names both limitations as `gnu-tool-closure-unmeasured` and +`gnu-original-acquisition-unverified`, independently of the existing +`gnu-privileged-helper-content-unmeasured` gap. These names are absent for +non-GNU receipts and remain sorted/unique. No serialized receipt fields, schemas, +workflow assertions, completeness flags or production constructors changed. +Hosted Fastly/Linux/Apple candidate validation and emitted-gap inspection are +recorded below for `e91d75b74`. Complete coverage and qualification stay +false; this does not complete Task 10 or touch the parallel Task 12 adapter. + +Local verification for this increment passed 63 composition tests with the +nonzero-selection guard and 62 SPDX tests, strict native all-target/all-feature +Clippy, documentation generation and formatting. The inode-swap regression and +each new GNU gap assertion failed before its corresponding fix and passed after. +Independent spec and quality review approved the scoped changes; plan wording +was corrected to distinguish the implemented collector from the historical +missing-collector test step. No child-process or ignored tests were run locally. + +Hosted gap follow-up (2026-10-21): [run 39565110219](https://github.com/IABTechLab/trusted-server/actions/runs/39565110219) +has head `7ab7f96e71c896b214e6d09e760b7da7ea22d1b4`. Completed candidate logs +for Fastly [119105403584](https://github.com/IABTechLab/trusted-server/actions/runs/39565110219/job/119105403584), +Linux CLI [119105404005](https://github.com/IABTechLab/trusted-server/actions/runs/39565110219/job/119105404005) +and Apple ARM [119111703005](https://github.com/IABTechLab/trusted-server/actions/runs/39565110219/job/119111703005) +record built PR merge source `f5bac2786585169cf597139069d05dd47513d9f1` and +`material_native_coverage_complete: false`. Linux alone reports both +`gnu-tool-closure-unmeasured` and `gnu-original-acquisition-unverified`, alongside +its existing privileged-helper and incomplete-native-material gaps. Fastly and +ARM report neither new GNU gap; their existing host-tool/Apple exclusions remain. +At this evidence check Intel [119111702865](https://github.com/IABTechLab/trusted-server/actions/runs/39565110219/job/119111702865) +was still running its denial fixture. Its final candidate gap list and the overall +run result remain pending; no all-four confirmation is inferred. + +Intel follow-up (2026-10-21): successful [run 39577910126](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126), +head `d774ee47c274257fa0a088280f7df37ed530020c` (including `04be89638`), +completed Intel candidate [119154065062](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119154065062). +Its built PR merge source is `fb42d8268af93c011959d032c81bfa581306c7c2`. +The final candidate SPDX reports only `apple-coverage-excluded:toolchain_libraries`, +`apple-tool-closure-unmeasured` and `native-material-coverage-incomplete`; +neither GNU closure/acquisition gap appears, and +`material_native_coverage_complete` and `release_qualification` remain false. +Together with the three completed logs above, this closes the four-platform +gap-placement follow-up across the recorded runs, not complete native coverage. + +Progress (2026-10-19, partial Task 10 only): candidate recipes retain an unfiltered Cargo metadata identity catalog and run an explicit-target tree query under the same denied boundary and selected tool/environment/source replacements as the build. Completion joins actual compiler messages to exact metadata package/target identities, preserves observed host/target unit features and build-script outputs, and requires the selected final executable to appear exactly once. Raw metadata/tree and normalized observations, including query plan digests, are retained privately and rehashed at handoff; candidate measurements keep `material_native_coverage_complete: false`. Ordinary CI at `456d15005` passed Fastly A/B equality and final-byte Viceroy acceptance with 255 observed packages, 377 units and 39 build-script executions per side, and Linux CLI capture/startup with 486 packages, 649 units and 62 build-script executions. Those runs precede the new component receipts below; they do not validate their full-product integration (later runs 39273777887, 39290139247 and 39417433680 do, as an ordinary-CI candidate). Apple full-product acceptance has since passed in ordinary CI (runs 39273777887 and 39290139247, recorded under Task 9 and below). + +The candidate completion path now also reconciles normal/build metadata and tree edges against observed compiler membership, rejects ambiguous source joins and dev-only paths, and keeps potential graph membership separate from actual compiled units. It does not claim an exact compiler unit dependency DAG. Verified package origins join opaque compiled IDs to approved workspace manifests or original registry/Git materials. Reviewed AWS-LC, ring and bundled SQLite collectors bind original source, prepared source trees, static output archives, observed features and link directives; unknown native contributors and system substitutions fail. Final-byte JS checks classify complete retained modules conservatively: Fastly requires every prepared module, while CLI absence remains build-material evidence. Bound source checks cover every tracked input before/after execution and immediately before sealing. Native working inputs are rehashed at sealing; retained immutable receipt bytes are verified before cleanup and at subsequent handoffs. No retained-input archive is implied. Tool/sysroot/SDK/external-requirement coverage, all-cohort real component validation and qualification remain open, so these components do not complete Task 10. + +The verified vendoring path records a read-only package index on `PreparedCargoMaterials`, keyed by vendor directory. Registry entries retain the locked source, original archive SHA-256 and prepared manifest SHA-256; Git entries additionally retain the complete selector/revision, repository/package trees, package path, committed manifest blob/SHA-256 and any producer-normalized manifest SHA-256. Entries are constructed only after package writing succeeds. The origin join selects only observed packages from this larger prepared-material index; preparation alone never establishes contributor membership. + +A read-only full-workspace graph check caught and fixed a real input-size mismatch: the CLI's `--no-dedupe` tree contains 163,493 occurrences, exceeding the separate 65,536 compiler-message limit. Trees now have their own 1,048,576-occurrence bound within the unchanged 16 MiB byte bound. An indexed name/version lookup preserves conservative ambiguity rejection. The actual Fastly and Linux CLI potential trees parse successfully; this parser check uses synthetic selected-root membership and does not establish actual full-product compiled membership for the new receipts. + +Candidate platform receipts additionally retain the measured installed Rust tree digest and explicit Cargo/compiler/selected target standard-library roles. Typed Apple exports retain the measured clang, archiver, linker and SDK-settings identities and versions, with explicit exclusions for SDK headers, stubs, frameworks, helper tools, libLTO and compiler resources (narrowed for measured selections; see the 2026-10-21 Apple toolchain note below). Accepted ELF/Mach-O/Wasm facts supply artifact-bound observed runtime requirements; these are logical dependencies, not installed runtime-library hashes or provenance. The collector independently checks artifact, target and Apple SDK agreement, compares the retained Rust digest with its bound identity, and rechecks inputs before retaining evidence. Linux host-PATH native tools remain unresolved, original acquisition is not inferred, and coverage remains incomplete. The component/integration verification batch passes 252 focused tests, native and Linux cross-target Clippy, formatting and documentation generation; independent review passed. All four candidate cohorts have since executed these receipts in ordinary CI (runs 39273777887 and 39290139247, recorded below); qualification remains pending. + +Ordinary CI at `3fef2c5f0` subsequently passed the complete Fastly composition/platform receipt path, A/B byte equality and Viceroy acceptance. Its Linux CLI candidate stopped at a generic native-feature rejection. Investigation found a concrete mismatch with the locked AWS sources and resolved CLI feature graph: `all-bindings` is optional, and the selected `prebuilt-nasm` feature cannot activate prebuilt objects on the selected Linux/macOS platforms. The narrowly corrected policy accepts these reviewed profiles while retaining original-source, archive, environment and static-link checks. New regressions cover the profiles and bounded package/context/feature diagnostics. Full native candidate reruns remain required; the original generic error did not identify the exact failing unit. + +The corrected receipt path subsequently passed all four ordinary-CI candidate recipes in [run 39273777887](https://github.com/IABTechLab/trusted-server/actions/runs/39273777887), over merge source `cace4c508159bc7502d359e0cce49fef47d61f8b`. Fastly, Linux CLI and Apple ARM completed byte-identical A/B builds and their required runtime checks. Intel completed its mandatory canonical build, inspections and native startup; its optional reproduction exhausted the confined 25-minute wall budget while compiling and was recorded as a measurement-only build failure. No Intel equality result is inferred. These are candidate integration results for that source, not authenticated acquisition, complete composition coverage or qualification of subsequent changes. + +Typed owning access now rechecks retained observations and requires sealed component receipts before exposing observed units, verified package origins and potential graph facts. The typed view borrows its owner and exact normalized-receipt digest; it changes no raw receipt bytes and grants no completeness capability. Its real recipe checks run after source/target cleanup. Selected SDK-content enforcement now stages a separately owned copy with independent regular files, preserves safe internal aliases and modes, binds a structured-tree digest into Apple identity, and selects that copy through explicit C/Rust sysroot arguments, immutable boundary roots, remaps and final-byte path rejection. The [SDK input audit and smoke](../../../scripts/release/qualification/2026-10-19-apple-sdk-inputs.md) records the local installed-SDK pass and subsequent hosted ARM/Intel candidate acceptance. Compiler/helper/sysroot and original-acquisition closure remain separate work. + +Hosted follow-up (2026-10-20): [run 39290139247](https://github.com/IABTechLab/trusted-server/actions/runs/39290139247) finalized success for head `df4a6e5c30fe8c93df68748ead77a3bc559d16b2`, building merge source `506d8ead69757556e1bd8ed29b578d0257d05169`. All four actual candidate fixtures accepted sealed typed composition, verified Rust-package SPDX projection and retained candidate exports that survive build-root cleanup. Fastly, Linux CLI and Apple ARM reproduced identically; Fastly also passed final-byte Viceroy acceptance. Intel canonical inspection/startup/export passed, while optional reproduction failed on its time bound, so no Intel equality evidence exists. Observed package/unit/build-script counts were Fastly `255/377/39`, Linux and ARM `486/649/62`, and Intel canonical `487/650/62`. Both Apple recipes used the detached SDK. This closes the previously pending hosted integration checks for these specific increments; full native/tool closure, complete SPDX generation, authenticated acquisition and release qualification remain open. The receipts retain both completeness and qualification as false. + +Apple and Intel candidate details (2026-10-20, verified from job logs; see the [Apple CLI candidates report](../../../scripts/release/qualification/2026-10-20-apple-cli-candidates.md)): in both runs the ARM recipe built byte-identical canonical and reproduction executables and every canonical Apple build, inspection and native startup passed. The Intel optional reproduction failed in both runs, for different recorded reasons: run 39273777887 exceeded the confined build wall-clock deadline while compiling, and run 39290139247 recorded "source preparation cancelled or timed out". The later runs 39417433680 (`bfab8ef20`) and 39420011152 (`1719cf15f`) recorded Intel reproduction `identical`, with the Intel candidate test taking 3,552 and 3,305 seconds against its 60-minute test deadline. Because the timing margin is thin and no code change addressed it, this is a measurement, not a resolved Intel equality gate; native B stays optional. All of this is ordinary-CI candidate evidence with `release_qualification: false`. The macOS boundary reports `descendant_cleanup: "post-run-reaped"`, which only the `cfg(test)` ordinary-CI selection accepts. + +The subsequent GNU increment replaces ambient compiler selection for the Linux CLI candidate with source-owned `/usr/bin/gcc` and `/usr/bin/ar` selectors for both host and target C builds, an explicit Rust linker driver, and exact AWS-LC cc-backend controls that prevent fallback to CMake. A staged Rust helper runs inside the same denied Docker boundary before and after Cargo. It records selected/configured compiler, assembler, archiver, linker and compiler-resource identities plus a bounded conservative installed-image file inventory, including headers/libraries and reviewed loader/alternatives configuration. Image hardlinks use a separate held-descriptor policy; strict owned-tree and host-document policies remain unchanged. The receipt binds the helper, boundary, profile and invocation, rejects drift, and remains retained through composition sealing. Installed-file coverage does not establish which helpers executed, authenticated acquisition, or complete native closure. Those flags remain false, and the actual GNU helper/profile integration has since run in hosted CI, passing at `bfab8ef20` after two fixes (see the GNU snapshot and measurement notes below). + +GNU snapshot of reviewed root-only helpers (2026-10-20, `e5a780d5c`): the hosted Linux CLI candidate at `7be86d935` failed with "cannot open image file: Permission denied" (job 118460869596, run 39371724671), because the unprivileged helper cannot open three root-only privileged helpers in the UBI 9 builder. The snapshot now accepts a permission-denied file only at the exact reviewed paths `/usr/libexec/utempter/utempter` (setgid), `/usr/sbin/unix_update` and `/usr/sbin/userhelper` (setuid), owned by root with no group/other write and no other-read bit and unchanged since first inspection. Each is recorded by mode, owner and size rather than content and listed as `content_unmeasured_files`, which must be a sorted subset of the reviewed paths. Every other unreadable file, including an execute-only compiler helper, still fails closed, and selected build tools must stay readable and hashed. This records a coverage gap; it measures no helper content and qualifies nothing. + +GNU measurement in a sibling sandbox (2026-10-20, `bfab8ef20`): the next hosted run, at `e5a780d5c`, failed because the post-build image measurement ran in the build boundary, whose unchanged pre-run writable scan rejects Cargo hard links inside the target directory ("has 2 hard links; a link outside the root would bypass write denial", job 118494982595, run 39382624785). Both GNU measurements now run in a sibling Linux container boundary derived from the build boundary with the same image, settings, read-only and tool roots and every writable root except the target directory. The sibling gets a fresh fingerprint, records the boundary it derived from and proves denial with the staged probe. Cargo queries and the build keep the build boundary and proof; receipts record both fingerprints. Ordinary-CI run 39417433680 (head `bfab8ef20`) passed the Linux CLI candidate (job 118609761876): byte-identical reproduction and three clean startups, recorded in the [GNU follow-up report](../../../scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md). This is candidate evidence only. + +Apple toolchain identity (2026-10-21, uncommitted, Tasks 9/10 candidate only): the ordinary-CI Apple selection now also measures, in place below the selected `clang`'s toolchain `usr` directory, a structured-tree digest of the helper directory beside `clang` (files, modes and relative alias targets), the single versioned clang resource directory (`lib/clang/`; any second real version directory, foreign alias or other entry fails closed) and the SHA-256 of `lib/libLTO.dylib` (missing, aliased or non-regular fails closed). The trees are bounded by entry, file and total byte limits and reject special files and absolute, dangling or escaping aliases. Vendor hard links are accepted because the tree is not owned; every file is rehashed instead. Nothing is copied, executed or resolved through `xcrun`. The receipt is part of the Apple identity record and therefore the plan digest, and it is rechecked at every Apple verification point and at evidence sealing. Candidate exports drop the `tool_helpers`, `liblto` and `clang_resource_directory` exclusions only when the receipt is present, and add a `toolchain_libraries` exclusion for other toolchain libraries such as `libtapi`. The `apple-tool-closure-unmeasured` gap stays: which helpers clang actually ran is unmeasured, and the developer directory remains a broad read/execute system root. The platform SPDX projection adds `libLTO` and the two trees as components. Pure fictional fixtures cover this locally; the real Xcode layout is exercised only by the ordinary-CI macOS jobs, which have not yet run it. It grants no qualification. + +Apple measurement follow-up (2026-10-21): the earlier uncommitted/unexecuted +status above is superseded by committed `7d045ac83` and the successful ARM/Intel +candidate jobs in run 39540057202. Their candidate entries retain +`apple-coverage-excluded:toolchain_libraries` and `apple-tool-closure-unmeasured`; +passing the measurement path does not establish complete closure or acquisition +authenticity. See the [candidate index report](../../../scripts/release/qualification/2026-10-21-candidate-release-index.md). + +Timing follow-up (2026-10-21): the Intel optional reproduction timeout recorded +in run 39273777887 recurred in run 39540057202, after historical successful +Intel comparisons. The ordinary-CI selection now supplies a 45-minute confined +Cargo wall through `ToolSelection::build_wall` (`b4c8e0c0f`), rather than the fixed +25 minutes. See the [schedule/cache report](../../../scripts/release/qualification/2026-10-21-native-reproduction-schedule.md). +The running speedup CI is not yet a completed acceptance result; neither deferral +nor the changed budget establishes repeatability, complete coverage or qualification. + +GNU receipt follow-up (2026-10-21): capture now validates the exact nine selected +file roles and eight compiler-query identities, not only their counts. Selected +drivers match the fixed profile; configured cc1, collect2, assembler, linker and +compiler-runtime files join their recorded query paths. The raw sysroot response +is rechecked against the supported image-root forms. Bare assembler/linker names +must match the fixed image candidate's resolved path, digest and size; `/bin` +aliases of the same file and explicit alternative image-helper paths remain +supported. These are installed/configured observations, not execution tracing or +general PATH-resolution proof; tools-root helpers remain outside this profile. + +The audit found existing AWS-LC/ring/SQLite collection already retains original +registry archive identities, prepared source/subtree hashes, static-link +directives and rehashed output archives. Rust tool trees, measured Apple tools/ +SDK inputs and GNU installed/configured files already project into SPDX. No new +native source fact, external-library identity or authenticated acquisition is +invented by strengthening the receipt checks. Native material coverage stays +incomplete. The existing Fastly unresolved-host-tool, GNU closure/acquisition/ +privileged-helper-content and Apple closure/toolchain-library gaps remain open; +complete sysroot/tool execution closure and external platform inputs still need +additional owner measurements and qualification. Hosted acceptance of this +guard is recorded below; none of these changes completes Task 10. + +Scoped verification: nonzero guards selected 18 pure GNU tests, 67 pure SPDX +tests and two unchanged producer golden checks; all passed. Strict host clippy, +documentation and owned-file formatting pass. Independent spec and quality +reviews approve this increment without blockers, including preservation of +explicit GCC paths and same-identity aliases. No child-process or ignored tests +were executed locally. The earlier Task 14 run is not evidence for this guard. + +Hosted guard acceptance (2026-10-21): [Run Tests 39616416485](https://github.com/IABTechLab/trusted-server/actions/runs/39616416485) +and [Run Format 39616416604](https://github.com/IABTechLab/trusted-server/actions/runs/39616416604) +succeeded at head `e91d75b7447c9611d6e1ccb5dc7c2dce07377ff6`. Candidate +logs record built PR merge source `624cef618ffadbfad72c41c9072d8c5f5c1f4f64`. +Actual SPDX gaps were inspected for all four assets: + +- Fastly [119278571153](https://github.com/IABTechLab/trusted-server/actions/runs/39616416485/job/119278571153): + `native-material-coverage-incomplete` and `native-tool-host-path-unresolved:` + for `archiver`, `assembler`, `build_tools`, `compiler` and `linker`. + Canonical and reproduction SPDX report the same gaps and identical document + bytes; document equality remains a measurement. +- Linux CLI [119278571303](https://github.com/IABTechLab/trusted-server/actions/runs/39616416485/job/119278571303): + `gnu-original-acquisition-unverified`, `gnu-privileged-helper-content-unmeasured`, + `gnu-tool-closure-unmeasured` and `native-material-coverage-incomplete`. +- Apple ARM [119287733613](https://github.com/IABTechLab/trusted-server/actions/runs/39616416485/job/119287733613) + and Intel [119287733492](https://github.com/IABTechLab/trusted-server/actions/runs/39616416485/job/119287733492): + `apple-coverage-excluded:toolchain_libraries`, `apple-tool-closure-unmeasured` + and `native-material-coverage-incomplete` on each asset. + +Every candidate keeps `material_native_coverage_complete: false` and SPDX +`release_qualification: false`; optional native reproductions are absent, not +invented successes. No unexpected gap difference was found. This closes hosted +acceptance of `e91d75b74`, not Task 10/11 completeness. That run predates +`5a98c18ad`'s strict real-receipt parser; parser-inclusive acceptance is recorded +in the 2026-10-22 note below. + +Cargo script presence hardening (2026-10-21): graph sealing now requires every +observed custom-build package to have execution evidence, and the execution +package/context set to match the observed non-custom consumers of those +packages. A compiled host script cannot substitute for its target execution; +dual-role packages need evidence in both consuming contexts. Multiple executions +within one context remain permitted, and effective unit features remain separate. +This is package/context presence consistency, not proof that every feature-specific +execution within a context was retained or an exact compiler dependency DAG. +No serialized receipt field, SPDX rendering, completeness flag or collector +fixture changes. Coverage remains incomplete. + +Two negative fixtures were observed failing for their intended reasons before +the production checks: missing execution evidence and a host execution with only +a target consumer. A dual-role regression also covers a missing second context, +matching host/target executions and multiple executions in one context. Scoped +nonzero guards passed 66 composition tests, 67 SPDX tests, 18 GNU tests, 46 +collection tests and two unchanged producer golden checks. The collection +transfer module was excluded because it includes subprocess/network fixtures; +one other ignored collection test was not executed. Strict host clippy, +documentation and owned-file formatting passed. Independent review found no +blocker and confirmed the package/context limitation stated above. Real-build +acceptance of this graph guard is recorded in the 2026-10-22 note below. + +Selected Apple library measurement (2026-10-21): the in-place toolchain receipt +now records the SHA-256 of the exact `usr/lib/libtapi.dylib` when present, +alongside the existing `libLTO`, helper and resource measurements. A missing +leaf is recorded explicitly as `libtapi_sha256: null`; unreadable files, aliases, +special files and exhausted bounds are errors. Present files use the existing +no-follow descriptor/stamp checks, a 128 MiB limit and the attempt's cancellation +and deadline. Remeasurement rejects content changes, appearance and disappearance. +The new field is inside the collector's opaque `platform.apple.toolchain` +subtree; all strictly checked receipt fields remain unchanged. + +This measures one selected toolchain library, not its execution or loading. +Other toolchain libraries, runtime inputs, original acquisition and the full +tool closure remain unresolved. The existing `toolchain_libraries` exclusion, +`apple-tool-closure-unmeasured` gap and incomplete coverage flags are retained. +Hosted acceptance of this additional measurement is recorded below for +`39639053390`; run `39637275502` contains the preceding graph checks only. + +Verification of this increment selected and passed 66 composition, 68 SPDX, +19 Apple toolchain, 18 GNU, 46 collection and two unchanged producer golden +tests with nonzero guards. The collection transfer module and ignored tests +were excluded under the local execution restrictions. Strict host clippy, +documentation and owned-file formatting passed. Independent spec and code +quality reviews approved the increment. Review also strengthened the absence +check: a missing or aliased parent cannot be recorded as a missing library. +The existing vendor-tree intermediate-directory swap limitation still applies; +no stronger isolation guarantee is claimed. + +Hosted receipt acceptance (verified 2026-10-22): [Run Tests 39637275502](https://github.com/IABTechLab/trusted-server/actions/runs/39637275502) +succeeded at head `416b43f43c5d7e69c08bca32753240abded8523c`, building PR +merge source `17251a872e295e4f13047efd14b10077944620fb`. [Run Tests 39639053390](https://github.com/IABTechLab/trusted-server/actions/runs/39639053390) +succeeded at head `b6f5d92e4bc4483f81b1e0d8a376e7d67c9da8fd`, building PR +merge source `c7b1a9cb04ab8abf8e4fa2d5198c4c1337a96b1f`. Format, +Integration Tests and CodeQL also succeeded at both heads. All four actual +candidate fixtures selected and passed one test each in both runs. Each invokes +the unconditional real-receipt check in `check_owned_export_after_cleanup`; +none of the eight job logs contains the receipt-check panic. This establishes +producer/collector receipt acceptance for these builds, not complete coverage. + +The full job logs were inspected for exact candidate SPDX gap lists: + +- Fastly: [416 job 119351108924](https://github.com/IABTechLab/trusted-server/actions/runs/39637275502/job/119351108924) + and [b6f job 119357467783](https://github.com/IABTechLab/trusted-server/actions/runs/39639053390/job/119357467783). + Both roles report `native-material-coverage-incomplete` and + `native-tool-host-path-unresolved:` for `archiver`, `assembler`, `build_tools`, + `compiler` and `linker`. +- Linux CLI: [416 job 119351108938](https://github.com/IABTechLab/trusted-server/actions/runs/39637275502/job/119351108938) + and [b6f job 119357467960](https://github.com/IABTechLab/trusted-server/actions/runs/39639053390/job/119357467960). + Exactly `gnu-original-acquisition-unverified`, + `gnu-privileged-helper-content-unmeasured`, `gnu-tool-closure-unmeasured` + and `native-material-coverage-incomplete`. +- Apple ARM: [416 job 119360930044](https://github.com/IABTechLab/trusted-server/actions/runs/39637275502/job/119360930044) + and [b6f job 119367262345](https://github.com/IABTechLab/trusted-server/actions/runs/39639053390/job/119367262345). + Apple Intel: [416 job 119360930038](https://github.com/IABTechLab/trusted-server/actions/runs/39637275502/job/119360930038) + and [b6f job 119367262261](https://github.com/IABTechLab/trusted-server/actions/runs/39639053390/job/119367262261). + Each reports exactly `apple-coverage-excluded:toolchain_libraries`, + `apple-tool-closure-unmeasured` and `native-material-coverage-incomplete`. + +No GNU gaps leak onto Fastly or Apple. Every candidate retains +`material_native_coverage_complete: false` and SPDX `release_qualification: false`. +Fastly canonical/reproduction SPDX documents are byte-identical in each run; +SPDX equality remains measurement-only. Native reproductions are deferred and +their SPDX comparisons remain null. Both Apple jobs in the `b6f` run record a +non-null `apple_developer_tools.toolchain.libtapi_sha256`, confirming the new +measurement ran on both architectures. The unqualified four-asset candidate +index job also succeeded in the `b6f` run. No collector edits or field-mismatch +workaround was needed. Tasks 10/11 remain partial. + +Selected Apple library-directory inventory (2026-10-22): extend the in-place +toolchain receipt to the entire selected `usr/lib` directory. A read-only +metadata inventory of local Xcode 16.4 (the version recorded by both hosted +Apple candidates) found 6,205 entries, 2,601,020,072 regular-file bytes, a largest +file of 354,743,440 bytes and 41 contained relative aliases. This establishes +local sizing only, not hosted performance or qualification. + +The existing vendor-tree collector measures every file, directory, mode and +alias target under that root with its own `toolchain-library-directory` digest +domain. Limits are 512 MiB per file, 4 GiB per tree and 20,000 entries. Reads +retain the existing cancellation/deadline, no-follow, stable-descriptor and +alias-containment checks. The receipt's new `library_tree` stays inside opaque +`platform.apple.toolchain`; strictly checked collector fields are unchanged. +Every verification rehashes the tree: prior metadata equality cannot substitute +for fresh content measurement. No digest cache or extra attempt time is added. + +The `toolchain_libraries` exclusion now denotes unresolved dependency/loading +coverage and libraries outside the measured selected directories. It does not +mean the selected `usr/lib` files are unhashed. Host runtime, dynamic loading, +original acquisition and full closure remain unresolved, so no completeness +or qualification flag changes. This inventory deliberately overlaps the +separate LTO/TAPI/resource identities and proves neither which files the tools +read nor which bytes contributed to the final artifact. Its extra hashing cost +must be accepted by a later hosted run under the existing attempt budgets; +the earlier green runs do not validate this increment. + +Local verification passed 66 composition, 68 SPDX, 25 Apple toolchain, 18 GNU, +46 collection and two unchanged producer golden tests, each with a nonzero +selection guard. The collection transfer module and ignored tests were excluded +under the local execution restrictions. The added-library regression first +failed against the previous collector and then passed with the full inventory. +Fixtures also cover nested entries, edits/removals, alias targets, unsafe files, +limits, cancellation, expiry and structured SPDX rendering. Strict host clippy, +documentation and owned-file formatting passed. Spec review corrected two stale +comments so exclusions describe unresolved coverage rather than claiming the +newly inventoried files are unhashed. Independent code quality review found no +blocking findings and confirmed that hosted read-cost acceptance is still needed. + +Main-merge candidate compatibility (2026-10-22): main `43a25619a` upgrades Rust +to 1.98.1, Node to 24.20.0, Viceroy to 0.21.0 and `libsqlite3-sys` to 0.38.2. +Run `39716862538` failed before the real candidates could complete: three +preparation jobs rejected the old Node pin, nine npm-observer tests rejected +the old Node/npm pins, and both native release-tool suites detected the stale +Viceroy pin. These failures provide no acceptance of the library-directory +inventory above. + +Align the pending compiled/JSON tool and recipe pins with main, including npm +11.19.0 observed in the source-selected Node distribution. Original acquisition, +digests and qualification remain pending/null. The JS execution/settings checks, +fixture preparation and paired producer/collector Viceroy checks remain exact +version checks; no range, bypass or fallback is introduced. The user authorized +the minimal collector version check and fixture update. Receipt fields, nullable +members and enum tags are unchanged, and candidate index goldens are untouched. + +Review of the new SQLite wrapper confirms the bundled `sqlite3` archive and +source subtree remain the same selection, with default feature +`min_sqlite_version_3_34_1` replacing `min_sqlite_version_3_14_0`. Update only its +reviewed wrapper version/features; AWS-LC 0.42.0 and ring 0.17.14 stay fixed. +The native implementation version and native license are not inferred from the +wrapper version. Source-lock and source-tool-pin regressions first fail against +the stale rules, then guard the aligned cohort. + +Source review of npm 11.19.0 found the intercepted lifecycle entry points and +spawn adapter byte-identical to npm 11.6.2; the new script-policy defaults leave +the current reviewed hooks enabled. Do not add an `--allow-scripts` override. +Actual lifecycle, runtime, native-source and receipt acceptance require a new +hosted run; no local process-spawning tests are authorized by this migration. + +Migration verification: 370 selected pure tests pass, including unchanged +candidate-index goldens and rejection of the previous Viceroy version. Strict +host-target clippy, documentation, read-only workspace rustfmt, changed-file +Prettier, JavaScript syntax and diff checks pass. Independent specification/security +and code-quality reviews found no blocking findings. Hosted validation remains +pending for the actual npm lifecycle, four candidate builds and their receipts. + +Git dependency compatibility (2026-10-22): run `39720199554` at `0bf452370` +passes ordinary/native suites and the Linux JS handoff, but candidate jobs +`119631656292`, `119631656310` and `119635856319` reject the Git-sourced `esi` +dependency introduced by main. Its locked commit contains package-local LICENSE +and README.md links to regular files in the repository parent. Both the original +Git closure parser and the package vendorer previously rejected mode `120000`. + +Retain canonical link blobs as inert authenticated objects in the complete +locked commit closure. During package vendoring, resolve only direct relative +file links through the same repository tree, rehashing every traversed object +and the target under existing budgets. Copy target bytes and executable mode +into regular files through the existing checksum writer; never create or follow +filesystem links. Reject absolute/escaping targets, malformed or oversize text, +missing targets, directory or chained aliases, aliased manifests and submodules. +Reject parent traversal after descending into a target component rather than +collapsing paths with potentially different link semantics. This changes no +receipt fields, origin identities, acquisition authority or qualification claims. + +The positive pure vendoring regression first fails at the original unsupported +mode check, then passes with authenticated parent-file bytes. Pure regressions +also cover unsafe targets, manifest aliases, target executable mode and object +tampering. Full original-object collection and actual four-candidate builds +remain hosted checks; local process-spawning tests are not run. + +Local verification selects and passes 186 pure tests: 42 vendoring, 67 +composition, 68 SPDX, two candidate-index goldens, four authentication and three +entropy-reader tests. Strict host-target clippy, documentation, read-only +workspace formatting and changed-document Prettier pass. Independent +specification/security and code-quality reviews report no blocking findings. + +Hosted follow-up: run `39724280588` at `0c67be66e` exposes an outdated +whole-tree rejection fixture in macOS job `119644526058`: it still labels mode +`120000` as unsupported. Keep its unrelated-subtree rejection assertion and +replace that entry with a still-forbidden `160000` submodule. The separate new +collection fixture requires retention of authenticated inert link blobs. This +process-spawning collection regression remains a hosted check. + +Security verification: CodeQL run `39723446088` scans merge `eca28ba934`, +containing entropy correction `d4408d2e0`. All analyses pass and alerts 224–230 +are fixed, with no relocated entropy warning or open release-tool finding. +Existing core/adapter/JS baseline findings are separate. This is security +acceptance of the entropy correction, not candidate-build qualification. + +Cargo 1.98 compatibility (2026-10-22): run `39725113066` rejects canonical +Linux (`119647122855`) and Fastly (`119647122894`) candidates solely because a +`registry` directory exists in their private Cargo homes. Source review of +Cargo's 1.98 registry constructor and source-replacement loader shows that the +original registry is initialized before replacement with the vendor source. +Its backup-exclusion helper may create an empty directory or the exact +177-byte `CACHEDIR.TAG`, without fetching registry data. Directory presence +alone therefore cannot establish a source bypass. + +The recipe now admits only an absent registry, a direct empty directory or a +direct directory holding only that exact tag. A held directory and matching +before/after inode and metadata checks bind inspection; the existing bounded +no-follow hasher requires a stable, single-link regular tag with the exact size +and SHA-256. Registry `index`, `cache`, `src`, unexpected children, aliases, +changed tags and all Git state remain rejected. No state is deleted or repaired; +offline execution, source replacement and verified package-origin joins remain +unchanged. The tag's upstream URL is runtime compatibility bytes, with a narrowly +reviewed domain suppression; it is never fetched. + +The empty-directory regression first fails at the former presence-only gate, +then passes. Verification selects 177 pure tests, including eight dedicated +bookkeeping cases and 13 recipe tool tests, with nonzero-selection guards. +Strict host-target clippy, documentation and read-only workspace formatting +pass. Independent specification/security and code-quality reviews find no +blockers. Actual candidate builds and their receipts still require hosted +validation after this correction. + +Hosted native acceptance (2026-10-22): run `39729655151` at `5d194d00f` +builds merge `a8c33bcc9990a4eb6520a8afd0f7dd2641c25111`. Linux CLI job +`119661528635`, Apple ARM job `119667144194` and Apple Intel job `119667144562` +all pass their actual producer fixtures, retaining composition receipts with +native completeness and release qualification false. Linux/ARM record +495 packages, 659 units and 61 build-script executions; Intel records +496 packages, 660 units and 61 executions. The real-receipt assertions pass. + +Both Apple legs measure 6,205 selected toolchain library entries and +2,601,020,072 regular bytes, with digest +`da141ea11bf363ffcf61e620ccf28cf72224a7439d3e08e5cc945df01eb2333e`. +Their fixtures complete in 1,277.15 and 2,279.81 seconds respectively. This +accepts the inventory increment under the existing cohort budgets; no separate +inventory timing is logged, so its isolated cost is not established. Both +retain exactly `apple-coverage-excluded:toolchain_libraries`, +`apple-tool-closure-unmeasured` and `native-material-coverage-incomplete`. +Linux retains exactly `gnu-original-acquisition-unverified`, +`gnu-privileged-helper-content-unmeasured`, `gnu-tool-closure-unmeasured` and +`native-material-coverage-incomplete`. These results do not complete Tasks 10/11. + +The same run's only failing job is Fastly `119661528580`: canonical Wasm +inspection rejects `fastly_async_io`. The Cargo bookkeeping barrier is passed; +no Fastly runtime, reproduction, SPDX or four-asset index acceptance is inferred. +Review of locked `fastly-sys` 0.13.1 and project async request call paths binds +this module to `select`/`is_ready`; pinned Viceroy 0.21.0 registers it. Admit only +that exact module. Other unreviewed SDK modules and component-style imports stay +rejected, along with non-function imports, invalid Wasm, wrong entry ABI and +missing debug information. A pure two-function regression first reproduces the +failure and passes after the policy update. Actual Fastly acceptance remains +a hosted check after the fix. + +The scoped ABI verification passes 223 pure tests, including all 68 artifact +inspection tests, plus strict host-target clippy, documentation, read-only +workspace formatting and plan Prettier. Independent specification/security and +code-quality reviews find no blockers. No runtime gate, receipt field, +qualification flag or companion golden changes. + +### Task 11: Generate complete artifact-specific SPDX + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +JavaScript declaration guard (2026-10-22): projected JavaScript facts contain no +measured declared-license evidence. Reject any supplied declaration at the +JavaScript origin boundary, matching the existing native-origin restriction, +instead of allowing a renderer input to manufacture a declaration. A pure +red/green regression injects MIT into all six supported original/role pairs; +the existing valid-document byte digest remains unchanged. Unknown JS licenses +continue to render as `NOASSERTION`; no new license measurement, receipt field, +companion format or completeness claim is introduced. + +Scoped verification passes 187 pure tests, including all 69 SPDX tests and +unchanged candidate-index goldens, plus strict host-target clippy, documentation, +read-only workspace formatting and plan Prettier. Independent specification and +code-quality reviews find no blockers. The corrected Git collection fixture is +compiled locally but is run only in hosted CI under the process-test restriction. + +**Create:** release tool `src/spdx.rs`, private `spdx::tests`, `crates/trusted-server-release/tests/fixtures/spdx/`. + +- [x] Write cases for incorrect root digest, missing native coverage, ambiguous package identity, unknown license, duplicate SPDX IDs, dangling relationships, uncompiled dev dependencies, type-only/virtual JS and oversize predicates. +- [x] Run the pure `spdx::` suite with the nonzero-selection guard. The scoped 2026-10-21 run selected and passed 66 tests; this validates renderer/candidate behavior, not the closed qualified generator. +- [ ] Emit SPDX 2.3 JSON from Task 10 normalized evidence. Root checksum is final executable SHA-256; contributors/materials/tools/external requirements have distinct relationships. Unknown licenses use NOASSERTION; missing contributing origin/coverage fails. Validator uses a pinned authoritative SPDX schema. +- [ ] Generate full real Fastly and all three CLI SBOMs; schema-valid toy fixtures are not sufficient. Validate size <=16 MiB and document equality semantics: reject duplicate keys, ignore object order, preserve array order. +- [ ] Re-run fixtures plus actual coverage inspection. Commit `Generate validated per-artifact SPDX evidence`. + +Fixture exit audit (2026-10-22, `41560cfd9`): the first checkbox records existing +written regression cases, not complete qualified generation. The cases span +owner validation, projections, rendering and the JS collector: + +| Required case | Existing regression | +| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Incorrect root digest, duplicate SPDX IDs and dangling relationship | `spdx/tests.rs::rejects_root_digest_changes_duplicate_identities_missing_origins_and_dangling_edges` rejects each input mutation. `stable_ids_and_document_bytes_do_not_depend_on_input_component_order` checks unique emitted package SPDX IDs. Candidate merge tests additionally reject cross-part duplicates and dangling/cross-part edges. | +| Missing measured native coverage | `composition/native/tests.rs::native_spdx_requires_each_reviewed_consuming_context_and_unambiguous_features` clears measured contributors and requires projection rejection. `rejects_unknown_native_scripts_and_missing_native_execution` rejects missing captured build-script output. These tests do not establish complete platform coverage. | +| Ambiguous package identity | `composition/tests.rs::ambiguous_or_missing_package_and_target_joins_are_rejected` rejects duplicate metadata. `identical_names_and_versions_from_distinct_sources_remain_separate` and `spdx/cargo/tests.rs::original_identity_distinguishes_complete_registry_and_git_facts` preserve distinct source identities. Native tests reject original/context aliases. | +| Unknown license | `spdx/tests.rs::unknown_licenses_use_noassertion_and_never_become_unsupported_expressions` verifies `NOASSERTION` for absent and unknown declarations. Native and JS origin tests reject manufactured declarations. | +| Uncompiled dev dependencies | `composition/graph/tests.rs::catalog_dev_only_entries_never_create_contributors` excludes potential-only packages; `spdx/cargo/tests.rs::package_edges_require_both_observed_endpoints_and_skip_dev_kinds` excludes their projected dependency edges. | +| Type-only JS | `crates/trusted-server-js/lib/test/release-composition.test.mjs` checks that a type-only import and a zero-length rendered namespace are omitted. `javascript/contributions/tests.rs` rejects a zero rendered-length contribution. | +| Virtual JS | The same JS collector file rejects an unknown virtual producer and checks recognized CommonJS helper ownership. `spdx/tests.rs::generated_js_requires_a_known_producer_and_retains_generated_bytes_identity` rejects a missing producer while preserving generated-byte identity. | +| Oversize predicates | `spdx/tests.rs::rejects_an_spdx_document_exceeding_the_attestation_byte_limit` and `spdx/candidate/tests.rs::candidate_bounds_reject_documents_over_sixteen_mebibytes` enforce the output bound. | + +Rust paths above are relative to `crates/trusted-server-release/src/`. Running +only the SPDX and composition selections does not execute the JS collector +cases. No new test API, duplicate fixture, schema change or candidate-index +golden change is needed to close the written-cases checkbox. Complete native/tool +coverage, qualified generator integration, the producer/consumer contract and +full real four-asset SBOM exits remain open. + +Audit verification: fresh pure selections passed 69 SPDX and 67 composition +tests, each with a nonzero selected count. Strict host-target all-target/all-feature +Clippy, all-feature documentation, read-only workspace formatting and this plan's +Prettier check passed. JS collector cases were inspected, not rerun locally. +Independent specification and quality reviews supported the fixture checkbox; +their corrections explicitly separate acquisition/qualification from material +coverage and identify the emitted-ID uniqueness assertion. + +Progress (2026-10-19, partial renderer foundation): a private pure renderer validates executable root identity, component origins and roles, stable package/relationship identities, license declarations and bounded output against the existing pinned SPDX 2.3 schema. Structured-tree evidence stays in explanatory source information rather than raw-file checksums. Fictional fixtures exercise structural/semantic acceptance; schema validity supplies no signing or qualification authority. The closed (qualified) generator entry point still rejects every current candidate; a separate private candidate-only assembly (see the 2026-10-20 notes below) renders an unqualified document that cannot become the closed type. There is no functioning generation command, no caller-controlled completeness flag and no JSON constructor for complete inputs. Typed Cargo/JS contributor export, complete native/tool coverage, producer/consumer contract fixtures and full real Fastly/three-platform CLI SBOMs remain required; the renderer does not satisfy those exits. + +The owning JS handoff now exports typed conservative source/npm/generated contributions, material observations and tool joins after rehashing all retained files before and after export. The export preserves the original inventory digest, distinguishes npm archive integrity from installed-tree evidence, checks generated producer identities and rejects conflicting source hashes. It supplies no license inference, final-function attribution or complete-coverage capability. The real two-installation JS fixture exercises this export after its private working roots are removed; the full two-installation test (`preparation::tests::full::real_two_fresh_js_builds`) that carries the new export assertion passed in ordinary CI, run 39290139247 (job 118188353041, 680.82 seconds; head `df4a6e5c3` descends from `b99a17695`). Complete native/tool closure and qualified SPDX generation remain open; typed Cargo, native and platform projections and the candidate-only whole-document assembly are recorded below. + +The Cargo adapter now derives renderer component identities from approved workspace paths or verified original registry/Git identities, excluding opaque Cargo IDs and temporary roots. Host proc macros and build scripts have build-tool roles; supporting host libraries remain build materials, while target units have conservative Rust-contributor roles. Only package roles are combined; effective unit features remain separate borrowed observations. Potential graph edges are emitted only between observed endpoints and retain their potential-package meaning. Registry archive SHA-256 values identify actual original archives; registry index locations are not invented download URLs, and Git object/tree identities remain provenance information rather than raw SHA-256 checksums. The complete generator stays closed while the remaining native/tool and whole-artifact integrations are unavailable. The shared real-candidate helper exercises the owner adapter; all four canonical cohort checks passed in run 39290139247, as recorded under Task 10. + +The next JS adapter joins that sealed final-byte membership to checked original source/npm/generated facts. Complete bundles receive a containment relationship; positive Rollup-rendered inputs remain conservative observations before esbuild minification, and absent CLI bundles remain build materials. npm archive SHA-512 integrity is separate from installed-tree SHA-256 provenance. Generated wrappers retain their checked Vite producer, and unknown licenses remain `NOASSERTION`. The adapter consumes the completed recipe's frozen original inventory, without depending on the preparation attempt's later lifetime. The JS projection ran inside the shared real-candidate checks of ordinary-CI run 39417433680 (head `bfab8ef20`, every candidate job succeeded; verified from job conclusions, as per-assertion output is not logged). The complete generator stays closed. + +Native and platform adapters now also borrow the sealed completed owner. Native projection requires exact observed wrapper/context/features and verified original-source joins, rejects missing contexts and duplicate aliases of the same original, and keeps original registry archive checksums separate from structured source trees and measured static output archives. Wrapper versions do not assert native implementation versions. Platform projection preserves measured Rust trees, Apple tool files/SDK materials and GNU installed/configured identities with their acquisition and coverage limitations. Logical runtime requirements retain accepted artifact/target/format facts without invented installed-runtime checksums. Source, artifact, target, tool-tree, GNU boundary/plan/profile and Apple SDK joins are checked. Private working paths and opaque Cargo IDs stay out of projected identities. The shared real-candidate helper exercises both adapters, with hosted validation of these assertions in ordinary-CI run 39417433680 (all candidate jobs succeeded; verified from job conclusions, not per-assertion output). Complete generation remains closed until full owning composition coverage and whole-document integration are available. + +Candidate whole-document SPDX (2026-10-20, `1719cf15f` and `073704cc7`): a private `candidate_from_completed` joins the Cargo, JS, native and platform projections of one completed recipe into a single validated SPDX 2.3 document, returned as a separate `CandidateSpdx` with no conversion into the closed `GeneratedSpdx` and no generation command. The source commit must match the build, the platform receipt must equal the composition view's, and the root package must be the policy asset's verified workspace package at the same commit and version. Parts merge only with unique identities and edges inside their own part. The creation comment marks the document as an unqualified candidate and lists the platform coverage gaps, which must not be empty. The root checksum equals the rehashed artifact, the creation time derives from the source epoch and documents stay within the SPDX size bound. `073704cc7` runs it on every real completed artifact in the Fastly, Linux CLI and Apple candidate tests, checks the gaps each platform must report (unresolved Fastly host tools, the reviewed GNU privileged helpers, the Apple exclusions and unmeasured tool closure) and retains document digests and sizes as measurements; Fastly canonical-versus-reproduction document identity is a measurement only. Hosted status: ordinary-CI run 39420011152 passed for `1719cf15f`, which contains the assembly but not the real-build checks. The run for `073704cc7` (Run Tests 39452090186) was still in progress when this note was written, so no hosted result exists for the real-artifact SPDX checks yet. Task 11 remains open: complete native/tool coverage, qualified generation, the producer/consumer contract and full real SBOMs for all four assets are still required. + +Renderer validation follow-up (2026-10-21): JavaScript originals now require +exactly one compatible role before rendering relationships. A complete bundle +may be retained or build material; a rendered contribution may be conservative +input or build material; preparation/tool material may be a build tool or build +material. Crossed, empty, duplicate and mixed roles fail. This prevents a material +record or pre-minification contribution from manufacturing `CONTAINS` evidence. +The normal completed-owner projection already selects these roles correctly; +this is validation hardening, not evidence of a real recipe misclassification. + +An integrated negative fixture rendered unsupported containment before the guard +and rejects it afterward. All six accepted pairs are covered. A fixed valid +document retains its pre-change SHA-256 +`2a79890ec323d58b8c6b8f304b06977c8355c8a5c341301b9782a82ddc489ee8`. +Both pure producer golden checks pass against the unchanged candidate index and +companions; no golden, schema or companion format was rewritten. + +License regression tests confirm the existing conservative behavior: the four +explicitly reviewed exact declarations retain their original spelling/order; +missing or unreviewed declarations, unsupported expressions and an observed +`NONE` use `NOASSERTION`, preserving bounded observed text in license comments. +No license conclusion, file-license analysis, wrapper-to-native license inference +or complete SPDX license-catalog support is asserted. This change does not add +license facts or infer missing origins. Existing named native/tool/acquisition +gaps remain present, and complete qualified generation remains closed. + +The local suite passes 66 pure SPDX tests plus the two pure golden checks. +Real-artifact acceptance is recorded under Task 10 for `e91d75b74`. Complete +native/tool coverage, complete qualified per-artifact SBOMs and the remaining +Task 11 exits stay unchecked. Parallel Task 12 code and its fixtures are untouched. +Task 11 resumes after recording Task 14's hosted preservation result and open +maintainer decision below. Task 14 remains open; this ordering does not waive +either task's coverage, authority or qualified-generation requirements. + +Native license boundary follow-up (2026-10-21): the renderer rejects a native +implementation license declaration when the measured native original has no +such license fact. The owning adapter already supplies no declaration; this +closes an invalid-input path rather than correcting a demonstrated real-build +misclassification. The integrated regression covers all three reviewed wrappers +in host and target contexts. Native license declarations and conclusions remain +`NOASSERTION`; wrapper declarations cannot substitute for native license evidence. +No license catalog is expanded and no existing measured package or relationship +was found missing from its projection. Complete artifact-specific generation +still requires the unmeasured closure/input facts and qualified owning adapters. +Valid rendering, companion formats and candidate golden fixtures are unchanged; +all four candidate jobs accepted the strengthened guard in the `e91d75b74` +run recorded under Task 10. Complete generation remains closed. + +Selected Apple library projection (2026-10-21): a present measured `libtapi` +digest projects as a build tool with a raw-file SHA-256. Recorded absence +creates no package. The projection asserts no original download, license, +runtime bytes or execution, and retains `NOASSERTION` through the existing +renderer. Other package projections, relationship policies and candidate golden +fixtures are unchanged. This adds measured bytes to candidate SPDX without +opening complete qualified generation or closing Task 11. Hosted receipt and +candidate SPDX acceptance for both increments is recorded under Task 10 in the +2026-10-22 note; all named coverage gaps remain present. + +Selected Apple library-directory projection (2026-10-22): retain the new +structured inventory as a build material with no raw-file checksum, original +download or license claim. Candidate SPDX retains the same named coverage gaps. +The measured directory is an input inventory, not an asserted executable +dependency set; complete qualified SPDX generation remains closed. + +### Task 12: Bind transfers and enforce selected comparisons + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** release tool `src/collection.rs`, private `collection::tests` and exact transfer fixture data. + +- [ ] Test stale/wrong-run/source transfers, reused A/B artifact IDs, transfer hash warning, traversal/duplicate ZIP members, missing A, changed inventory, missing supported B variation or dishonest normalized/unsupported claims, absent final-byte runtime proof, missing/failed production socket controls or an unconfined build invocation, and a one-byte final wasm difference. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" collection::tests` with the nonzero-selection guard; expect missing validator. +- [ ] Accept only expected upload-step outputs: IDs/archive digests mapped by trusted workflow dependencies to job/attempt/role/recipe. Re-download exact current-run artifacts and validate REST repository/run/source plus transfer bytes. Artifact API does not independently identify the producing job/attempt. Verify bounded declared ZIP members before extraction. +- [ ] Match expected role-specific ambient profiles from the approved recipe against observed A/B records before signing. For mandatory Fastly A/B, all qualified supported dimensions MUST differ as selected; normalized values remain explicitly identified and omitted supported dimensions fail. Successful native comparisons use their selected observed profile and result binding, but a bounded native B failed/unavailable before environment setup records unavailable fields honestly and cannot be rejected for lacking observations it never produced. Add that exact nonblocking fixture; all native A qualification/composition remains mandatory. Verify final-byte core-format/publisher-request results for both Fastly outputs. Fresh-job identity alone cannot satisfy the variation or runtime contract. +- [ ] Compare complete final Fastly files with no normalized/excluded regions. For native B produce exactly matched/mismatched/failed/unavailable within its deadline; native A failures always block. Record ambient variations and normalized internal values honestly. +- [ ] Preserve A as canonical only after all mandatory requirements pass. Add explicit tests that optional B failure cannot hide any A/composition/JS failure. Commit `Validate release transfers and comparison outcomes`. + +Progress (2026-10-19, partial candidate implementation): 18 focused tests cover trusted exact-ID transfer tuples, raw artifact REST corroboration, complete mandatory A/Fastly B records, bounded optional native B outcomes, ambient/runtime/composition bindings and adversarial ZIP32 inputs. The in-memory ZIP reader checks declared flat members, exact lengths/digests, CRCs, central/local agreement and complete DEFLATE stream termination. Independent spec and quality review passed after fixing a truncated-stream acceptance finding. Imported records remain diagnostic data and cannot construct local recipe, confinement, runtime or signing capabilities. A qualified dependency constructor is deliberately uninhabited. Superseded in part (2026-10-20): the bounded HTTP transport and a candidate producer export now exist (see below), and upload-action ZIP interoperability passed in ordinary CI. The trusted workflow dependency adapter and hosted qualification remain required before this task can close. + +A subsequent exact-ID, read-only audit checked one actual ordinary-CI upload archive (242,907 bytes; SHA-256 `ef2714df5e38c09a011cf25fb37268830de2f03426b81ee646b7b287989401cc`) against unchanged REST metadata and its archive digest. Its 16 regular files use version-needed 2.0, DEFLATE, signed ZIP32 descriptors, Unix regular-file mode and no extras/comments/ZIP64. These observed header properties now have a synthetic regression, bringing collection tests to 19. The JS handoff's nested bundle/inventory paths violated the intentional flat-member policy when this was written; the candidate producer described below since exports the declared flat layout. One small archive's format compatibility does not authenticate its producing job, qualify the trusted dependency adapter or prove every upload size/profile. + +The owning recipe now retains typed socket-control/probe observations after successful denial evaluation, with boundary-instance fingerprint checks at capture, completion and checked access. Diagnostic receipts cannot reconstruct local denial capabilities; explicit pure-test fixtures have no measured receipt. A separate approved-source recipe-definition identity is shared across A/B, while the typed exact invocation digest preserves role-specific paths, jobs, environment and umask. Both retained getters rehash owned outputs, and real candidate helpers exercise them after source/target cleanup. This closes two producer-export prerequisites without implementing the final export/transport adapter or granting release authority. The combined verification batch passes 322 focused tests with independent review; actual hosted validation of these new prerequisites remains pending. + +A candidate producer now copies four flat files into owned storage: `artifact`, `composition.json`, the original `tsjs-inventory.json` and `candidate-build-record.json`. Checked access requires sealed composition receipts, the approved-source recipe definition and measured socket observations; it records actual single-build ambient conditions and keeps the shared recipe digest separate from the exact invocation digest. The recipe freezes checked JS facts and exact original inventory bytes while the preparation owner is valid, retains them independently at completion, and verifies their source/digest and retained file at subsequent handoffs. This prevents a later optional native B timeout from invalidating A's evidence through an expired JS preparation lease. Every copied file is rehashed, and altered, extra, missing or linked members are rejected. This explicitly unqualified diagnostic format still differs from the strict collector's contract: runtime acceptance, ambient pair claims, complete composition closure, authenticated bootstrap and trusted workflow producer identity remain declared gaps. All four canonical recipes proved the previous three-file export's lifetime in run 39290139247; the fourth member (`candidate-build-record.json`, introduced in `7bcf93e50`) and JS projection assertions have since passed in the candidate jobs of ordinary-CI runs 39417433680 and 39420011152 (verified from job conclusions, not per-assertion output). Pure diagnostic fixtures do not establish collector compatibility. No strict collector rule or qualified dependency constructor is relaxed. + +The Fastly path can additionally retain its actual final-byte Viceroy acceptance in the diagnostic record. Acceptance now binds the build role as well as approved source and final artifact size/hash, so equal A/B bytes cannot substitute one role's runtime run for the other's. Checked export rehashes the completed owner before/after formatting bounded observations, including actual Viceroy/fixture/configuration and publisher-response identities; JSON remains output only. Both real Fastly runtime owners are retained through export and cleanup checks. This removes the Fastly runtime-export gap only when the actual owning acceptance is supplied. Native startup export, ambient pair claims, strict collector integration, qualification and workflow authority remain open; native records continue to declare absent runtime acceptance. The Fastly job passed in ordinary-CI run 39417433680 (job conclusions verified). + +Further candidate implementation (2026-10-20): native startup now produces an owning acceptance after executing and cleaning up the exact copied final CLI. Its expected version output derives from the selected package's verified approved workspace origin and sealed composition, rather than the release helper's package version. Linux acceptance requires the three selected distributions and distinct actual local image identities; these images remain unqualified. Apple acceptance checks the actual host cohort, CPU and final Mach-O minimum OS before execution. Checked diagnostic export binds source, role, asset, final size/hash and package version, retaining actual bounded startup observations after build-root cleanup. Optional native B without a successful startup retains an explicit missing-acceptance gap. Independent spec and quality review found no outstanding native-startup findings; these assertions ran in the native candidate jobs of ordinary-CI run 39417433680 (job conclusions verified; not per-assertion output). Both Linux and Apple startup accepted outputs are recorded in the dated reports. + +The read-only HTTP backend now binds exact selected IDs/names/archive digests to strict raw REST repository/run/head metadata. It uses fixed-origin authenticated API requests and one manually checked, credential-free redirect to the supported Azure Blob storage host subset, with compiled WebPKI roots, no proxy or automatic redirects, asynchronous DNS, bounded streaming, cancellation and per-response/attempt deadlines. The unchanged flat ZIP reader remains the extraction gate. Local fictional TCP/TLS tests cover credential separation, changed metadata, denied redirects, transformed/truncated/oversized bodies, cancellation and injected-CA rejection. A test-only ordinary-CI hook exercises this backend without constructing trusted dependencies. Real upload-action interoperability, the trusted workflow dependency adapter, ambient pair claims, strict producer/collector integration, complete composition coverage and hosted qualification remain required; Task 12 is still open. + +An ordinary-CI producer/receiver fixture now uploads four inert fictional flat members using the pinned upload action and passes its exact ID/name/archive digest through job outputs. A separate read-only receiver downloads that original archive twice through the actual backend, corroborates current-run REST metadata and verifies every member's bytes using the unchanged ZIP reader. Fork PRs skip these jobs. The fixture distinguishes the API's PR head from checkout's synthetic merge and grants neither source approval nor release authority. The guarded test has since passed in hosted CI: `collection::transfer::ordinary::real_uploaded_archive` in job 118609812109 of run 39417433680 (0.77 seconds), with the producer job 118609761722 also succeeding. Independent transport spec review and transport/workflow quality review found no actionable findings. This small upload is interoperability evidence only; it cannot establish large-artifact coverage, complete composition or qualified producer identity. + +Local verification of this batch passed 853 native unit tests (seven ignored external/helper fixtures), 13 command tests, strict native Clippy, documentation generation, Rust/Markdown/YAML formatting, actionlint and staged domain lint. A full-suite failure exposed inherited nonblocking accepted sockets in the fictional macOS HTTP fixture; fragmented-header red/green and aborted-header regressions now cover the corrected test server. Independent review found no remaining findings. Linux cross-target Clippy on this Mac cannot compile the newly needed TLS C dependencies without a Linux C cross-compiler; hosted Linux validation remains required. No candidate or release qualification is inferred from these local checks. + +Cross-job candidate-entry transfer (2026-10-21, implemented; first hosted pass in run 39577910126): each real candidate job (Fastly, Linux CLI and both macOS legs) now exports its rechecked entry as six flat files: the final executable `artifact`, `tsjs-inventory.json` and the four unsigned companions. It writes a separate CI-internal transfer file and uploads the files with the pinned upload action under `candidate-entry---` (1-day retention, fork PRs skipped). A describe step merges the exact upload ID, name and archive digest with the producing job's own run attempt into a job-output descriptor; the macOS matrix uses per-leg keys. The new `test-release-candidate-index` job (`contents`/`actions` read only, token only in the download step, restore-only cache) runs the ignored `distribution::ordinary::real_four_job_candidate_index`. It strictly parses the four descriptors, retrieves each exact upload through the unchanged bounded backend and ZIP reader with REST run/head/name/digest corroboration, and rehashes the executable against build-evidence `artifact_sha256`/`artifact_size`. Existing bounds cover the largest measured executable (Linux CLI about 285 MB; 512 MiB member, 768 MiB archive). The descriptor is not a release schema and grants no trusted workflow dependency: companions keep `null` workflow/transfer identities and the `trusted-workflow-transfer-not-bound` gap. Pure tests cover descriptor parsing, export writing and transfer rejections. The trusted dependency adapter, ambient pair claims and qualification remain open, so Task 12 stays open. + +### Task 13: Assemble and validate the exact evidence graph + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** release tool `src/distribution.rs`, private `distribution::tests`, `crates/trusted-server-release/tests/fixtures/index/`. +**Schema boundary:** governing index v1 raw bytes remain immutable; any change requires an explicit reviewed amendment. + +- [ ] Port positive and adversarial schema fixtures: exact four artifacts, unknown capabilities and structurally compatible but currently forbidden `fastly-staging`, duplicate names/keys, source/cohort/recipe mismatch, unsafe names/newlines, altered lengths/digests and role-specific limits. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" distribution::tests` with the nonzero-selection guard; expect missing assembly/semantic checks. +- [ ] Assemble unsigned companions from successful observed outputs, then artifact provenance/SBOM bundles, then the signed index. Index names/hashes all companions and shared JS inventory; exclude own digest/bundle. Recipe/rebuild records are authenticated informational data, not executable programs or independent trust. +- [ ] Require SPDX parsed predicate/document equality plus separate file hashes. Producer verifies every exact expected provenance subject; no additional unapproved code subject. SHA256SUMS covers every other published asset and not itself. +- [ ] Cross-check schema and semantic joins on all real artifacts before signing/rehearsal. Commit `Assemble the authenticated release distribution`. + +Progress (2026-10-20, partial candidate index): a private `distribution` module assembles an unqualified candidate release index. `candidate_entry_from_completed` accepts only a canonical completed build at its approved source with the matching actual acceptance (Fastly runtime plus canonical/reproduction equality, or native startup), renders the candidate SPDX itself and renders build-evidence, build-recipe and rebuild-check companions. Each companion carries `"release_qualification": false`, sorted non-empty gaps and `null` workflow run and transfer IDs. Fastly records `matched` only from the checked equality; native assets record `unavailable`. `assemble_candidate_index` requires each fixed asset exactly once with one source commit, producer policy identity and inventory, emits artifacts sorted by name with no timestamps, and rechecks the rendered bytes against the retained v1 schema plus mirrored consumer semantics. Fixed per-asset identity comes from a compiled accessor equal to the schema row, so `fastly-staging` cannot appear. The tag is the sentinel `v0.0.0-unqualified-candidate`, the bytes are named only `candidate-release-index.json`, and both signature bundle slots are `AwaitingSignature`: they render the true empty-file record and their `Signed` variant is uninhabited. There is no command, writer or signer. Golden fixtures under `crates/trusted-server-release/tests/fixtures/index/candidate/` are byte-compared by the producer and run through the CLI's unsigned consumer joins. Each entry derives `policy_version` and `policy_sha256` from the same approved policy bytes, as the consumer does, and rejects a version other than the compiled one. The CLI verifier now rejects size-0 provenance and SBOM bundle records for every indexed artifact immediately after parsing the index, and again for each consumed artifact, so the candidate cannot pass full verification. Follow-up (see Task 15): once real signing exists, the consumer must also reject the sentinel tag and `"release_qualification": false` documents. Pure fixtures cannot exercise a successful completed-build entry, because they have no composition receipts or actual acceptances. Only owner wiring rejections are covered locally, and real four-asset assembly is deferred to Tasks 12 and 15. Signing, SHA256SUMS, provenance subject checks and qualification remain open. + +Hosted candidate-entry follow-up (2026-10-21): `4483e8256` wires independent +real canonical-entry checks into the candidate jobs. Run 39529922087 completed +Fastly, Linux CLI and Apple ARM jobs, but Intel and the overall run were cancelled. +Run 39540057202 subsequently passed all four per-asset entry checks for source +`d0cd53343fcbe1fb6f06eafc009e306e24b1245d`, with the same JS inventory digest +in all four jobs. The [candidate index report](../../../scripts/release/qualification/2026-10-21-candidate-release-index.md) +retains exact job/entry identities and distinguishes native measurement results +from the entries' deliberately `unavailable` rebuild outcome. This establishes +per-asset integration, not real cross-job four-entry assembly. Task 12's trusted +transfer adapter is the next join prerequisite; complete native/tool coverage, +authenticated acquisition, signing, publication and qualification remain separate +unfinished exits. Task 13 remains partial. + +Real cross-job assembly (2026-10-21, implemented; first hosted pass in run 39577910126): the +collector job rebuilds each transferred entry with the test-only +`CandidateArtifactEntry::from_ordinary_ci_transfer`, the one documented +exception to the production-only constructor. Production is unchanged. The +rebuild reruns every producer join over the transferred bytes and derives +`policy_version`/`policy_sha256` from the consumer policy read out of a +retained clean clone authenticated at `github.sha`. Fastly must be `matched` +with reproduction equal to canonical, native assets `unavailable`. Gaps must be +sorted, unique, a superset of the fixed entry gaps and identical across +companions. Every companion is rehashed, and each rebuilt entry must recheck to +the producing job's entry digest. The unchanged `assemble_candidate_index` then +writes `candidate-release-index.json`, the inventory and sixteen companions. The +collector prints the index and its SHA-256, the CLI's ignored +`ordinary_ci_assembled_candidate_passes_unsigned_consumer_joins` runs the +consumer's unsigned joins and empty-bundle rejection over those files, and the +directory is uploaded as 7-day unqualified candidate evidence. It runs only when +all four candidate jobs succeed and is not a required check. A pure round trip +reproduces the golden index byte for byte, alongside rejection, descriptor and +writer tests. The first hosted all-four assembly passed in run 39577910126 at +merge source `fb42d8268`: index SHA-256 +`4872ef96086e93f720187f363e7262ae2921e663fbc9a531f2a42cc5d803b14d`, accepted by +the consumer's unsigned joins (see the +[four-asset candidate join report](../../../scripts/release/qualification/2026-10-21-four-asset-candidate-join.md)). +The preceding run 39567710071 rejected its first download because the collector +passed one 20-minute deadline to a transport capped at 15 minutes; +`d774ee47c` bounds each download separately. Signing, publication and +qualification stay open. + +Pair comparison (2026-10-21, `de52e66fd`): the collector also rejects a +canonical/reproduction pair sharing one boundary instance and treats an equal +host locale or timezone as a missing supported variation. New pure tests +isolate the pair check per dimension, record every optional native +reproduction outcome, show no reproduction state hides a mandatory canonical +failure, and require Fastly runtime proof for both roles. + +Collector follow-ups (2026-10-21): + +- `f17377a4e`: an optional native reproduction without an upload is an + observed job (started flag, terminal conclusion, bounded elapsed/deadline) + with no upload identity fields; the collector derives `unavailable` (queued + at the deadline, skipped, cancelled before any step) or `failed` (started + then cancelled or still running, failure, timed out, success without an + upload) and rejects contradictory, premature or unbounded observations. Each + dependency channel accepts only its own build roles before any fetch, and the + Fastly runtime record must carry its record's role. That role is + producer-asserted, so it guards an honest pipeline; a per-run Viceroy + identity remains open. +- `15893ef1e`: the earlier "exact invocation" check compared a fingerprint + forced equal to the boundary instance, so it only proved distinct instances. + Records now carry `execution_fingerprint` (boundary instance, bound to the + denial receipt) and a separate `invocation_digest` from the exact Cargo plan + digest (program, arguments including jobs, environment, role checkout root, + umask); the pair must differ in both. A test-reachable producer renderer + emits this strict record from producer-owned facts. +- `5a98c18ad`: the collector parses the producer's real sealed composition + receipt instead of a fixture-only summary, keeping every earlier check and + adding materials, target, package-join, Fastly embedding, coverage-claim and + platform-shape bindings. Real candidate tests feed each real receipt through + the same check, so producer/collector drift fails ordinary CI. + +Still open: the trusted workflow adapter that fills observed job states and +upload identities; job/attempt identity supplied by the workflow; native +startup acceptance has no collector slot; native canonical input digests are +only compared against a reproduction; an invalid uploaded native reproduction +rejects the whole collection rather than recording `failed`; and the +`failed`/`unavailable` mapping is not yet normative in the spec. + +### Task 14: Expose trusted same-commit validation + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** `.github/workflows/release-validation.yml`, release tool private workflow-contract tests (register alongside the production policy/dispatch module; do not expose a test-only public API). +**Modify:** `.github/workflows/test.yml`, `format.yml` only to extract/reuse existing required jobs without losing existing triggers/gates. + +- [ ] Add static workflow fixtures for wrong SHA/event/ref, unlisted RC branch, malicious candidate allowlist, forged local remote refs, missing governance/ruleset/bypass approval, skipped/path-filtered gates, arbitrary commands, excessive permissions and any floating external action ref in the complete release/validation invocation. +- [x] Run the pure `workflow_contract` suite with the nonzero-selection guard. The scoped 2026-10-21 run selected and passed 19 tests; three release-binary-only command tests and 45 pure Node tests also passed. These checks do not establish hosted release eligibility. +- [ ] SHA-pin every external action in release.yml, release-build.yml and all called release validation jobs, including checkout/setup/cache/upload/download and attest. Preserve readable version comments, audit dependencies and reject floating @v\*/main refs. Local workflow calls are allowed only at the caller's checked source revision. Existing test/format action refs MUST NOT be inherited unreviewed into this path. +- [ ] Expose reviewed workflow_call or explicit jobs running every current AGENTS gate at the tag SHA. Maintain PR/main tests; RC is permitted only by exact reviewed cohort branch entries. Validate strict SemVer/lightweight tag independently; annotated tags fail. +- [ ] Wait up to 90 minutes for this invocation's required terminal validation results; fail cancelled/skipped/timeout results with rerun guidance. Do not trust arbitrary named successful checks or unrelated main commits. +- [ ] Include JS build/tests/format, all Rust aliases/parity/CLI/build-digest, the explicit host-only release-tool test/clippy gate (or reviewed aliases), docs/Markdown and `scripts/lint-wasm-clock.sh`. Commit `Validate release source at the exact tag commit`. + +Progress (2026-10-21, partial Task 14): `release-validation.yml` exposes only +`workflow_call` with one required source SHA. Its explicit jobs cover the current +AGENTS gates, host release-tool Linux/macOS tests and clippy, and existing parity, +codegen, JS prebuilt, documentation and trust-test checks. Every checkout selects +the supplied SHA with persisted credentials disabled. Both external actions use +reviewed commit pins and version comments; no composite or local workflow is +inherited. Shell Rust setup follows the existing native release-tool approach +without introducing a floating action or shared cache. + +The read-only `check-workflow-contract --workflow ` command checks exactly +one reviewed workflow and its fixed sibling helper. It executes nothing, performs +no network access and reports both source eligibility and release qualification +as false. Inputs are bounded to 1 MiB before parsing; held regular-file reads use +no-follow/nonblocking open. Raw workflow, canonical parsed structure and helper +digests bind commands, permissions, conditions, action pins and each version +comment. YAML references, merge keys, duplicate/non-string keys, tags and +unsupported ambiguous spellings fail. The restricted parser conservatively +rejects reference indicators outside literal run blocks, even in quoted scalars. +Formatting or helper changes require explicit review and pin updates. Additional +files and local invocation graphs remain unsupported; Tasks 15–16 must extend +this closed contract when their actual caller/signing workflows exist. The YAML +dependency reuses the workspace package; the lockfile adds only its dependency +edge, with no new package. + +The runtime helper requires the same full source/workflow SHA, official repository +identity, tag-push event, exact caller/ref and strict bounded SemVer. Fixed-origin +bounded read-only REST requests observe the unpeeled lightweight tag, actual +remote `main` tip and ancestry; neither local refs nor candidate branch entries +provide authority. No RC branch is approved. Admission records observations and +`source_eligibility: false`, allowing quality checks to run without authorizing a +release. The parallel waiter enumerates this run attempt's required terminal jobs, +rejects missing/duplicate/wrong-source results and non-success conclusions, and +uses an original-run, queue-inclusive 90-minute window with monotonic remaining +budgets for requests and polling. The first bounded REST read obtains the trusted +run creation time. Rerunning jobs cannot reset that window; diagnostics distinguish +reruns within it from an expired window requiring a fresh trusted invocation. + +Even if every quality gate succeeds, the terminal helper fails because an +independently approved governance/ruleset/bypass profile is not established. +No approval schema, ruleset identity or bypass actor is invented. Consequently +Task 14 remains open for independent governance authority and live callable +acceptance, and the remaining checklist exits stay unchecked. No tag/release +trigger, signing, publication, settings change or qualified capability is added. + +Preservation check: all 16 pre-existing `test.yml` jobs and five `format.yml` jobs +retain their exact names, conditions, dependencies and matrices. `test.yml` only +appends the pure helper-contract job; `format.yml` is byte-identical. Thus existing +triggers, path classification, caches, native-reproduction selectors, four +candidate legs and the candidate-index collector remain unchanged. The before/ +after listing was compared against `d774ee47c` before editing. + +Local verification passes the 19 pure Rust cases, three checker command cases, +45 pure Node tests, strict host clippy, documentation, owned-file rustfmt and +Prettier, and the forbidden-action guard. Modified workflows pass actionlint. +The all-workflow actionlint run reproduces three existing diagnostics in unchanged +`deploy-docs.yml`/`docs-proposal.yml` (unquoted output path and locally unsupported +concurrency/permission keys); the baseline at `d774ee47c` reports the same three. +The broad `.github` Prettier check also reports ten unchanged baseline files; +the owned workflow/helper files pass the pinned docs configuration. No unrelated +workflow or shared-tree formatting is changed. Hosted preservation/contract +results after this push remain pending; pure fixtures are not a live invocation. +Independent spec and quality reviews approve this scoped foundation; the narrow +official REST-host suppression also received explicit review. Review fixes cover +Node extraction quoting, least-privilege permissions, documented REST attempt +responses, tag bounds, per-request remaining budgets and composed source-API +coverage. Task 14 is not marked complete by those approvals. + +Hosted preservation follow-up (2026-10-21): successful [Run Tests 39596973649](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649) +has head `ac07047ed2b4f1bbe0e1f2e4aac9f845e70b7c7c`, including `2b642b509`. +All 20 expanded test jobs succeeded, including Fastly candidate +[119211581826](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649/job/119211581826), +Linux CLI [119211581675](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649/job/119211581675), +Apple ARM [119219615235](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649/job/119219615235), +Apple Intel [119219615180](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649/job/119219615180), +the index collector [119250788447](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649/job/119250788447) +and helper contracts [119211581539](https://github.com/IABTechLab/trusted-server/actions/runs/39596973649/job/119211581539). +The collector's built source is PR merge `1c78dd4556b03ddaa369680f2248edac1761bcd8`, +distinct from the run head. Matching [Run Format 39596973672](https://github.com/IABTechLab/trusted-server/actions/runs/39596973672) +passed all five jobs. Comparison with `d774ee47c` confirms the same 16 original +test and five format definitions, names, conditions, dependencies and matrices; +only the helper job was appended. Matrix expansion accounts for 19 original test +instances plus the helper. This establishes ordinary-CI preservation, not a live +release-validation invocation or governance/source authority. + +**Unresolved maintainer decision before Task 14 can advance:** + +1. Select an independently governed approval record and its approving maintainers. + Approve exact repository/organization ruleset identities, active enforcement, + ref inclusions/exclusions and restrictions covering `refs/tags/v*`, `main` + and every future exact reviewed RC branch. Specify release-tag creation + authority and effective update/deletion restrictions. Eligible branches remain + exactly `["main"]`; a candidate entry or name pattern cannot approve an RC. +2. Approve exact branch-required check identities, their expected reporting + applications and branch update policy. Separately approve the release + invocation's mandatory same-commit gates: admission, Fastly, native/parity, + Cloudflare/Spin, both host release-tool jobs, JavaScript, docs and terminal + eligibility. Record actual caller-qualified names when Tasks 15/16 define the + caller. Another source/run/attempt or reporting application cannot satisfy + these gates by reusing a successful check name. +3. Approve an empty bypass list or exact actor IDs, actor types, modes and scope + of every exception, including inherited organization rules and tag creation + authority. Record who reviews changes and approves exceptions. Administrator + status, visible settings and candidate assertions do not establish approval. +4. Approve the independent trust root and read-back evidence. Validation must + obtain bounded official API observations of repository identity, applicable + repository/inherited ruleset details, active enforcement and ref scope, + effective required-check/application bindings and exact bypass actors/modes. + Compare with the independently approved record, binding its reviewed revision + and approvers. Retain the unpeeled remote tag, actual approved branch tip and + ancestry, and this source/workflow/run/attempt's complete terminal gate results. + Missing, unreadable, truncated, ambiguous or changed approval/settings + evidence must deny eligibility; settings cannot approve themselves. + +These are decisions to obtain, not an implemented approval profile or settings +mutation. No ruleset ID, check configuration, bypass actor or approver is selected +here. GitHub describes rule applicability and application-bound checks in +[available rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets). +The terminal job continues refusing eligibility. Stop governance work here until +maintainers approve the record and Tasks 15/16 supply the reviewed caller and an +authorized live invocation; bounded Tasks 11/10 resume meanwhile. + +### Task 15: Orchestrate independent builds and isolated signing + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** `.github/workflows/release-build.yml`; extend workflow contract fixtures. + +- [ ] Test never-started/queued or scheduler-cancelled optional native B jobs as well as pre-step failure: the trusted collector MUST emit a bounded failed/unavailable measurement with absent observations represented honestly and no fabricated upload identity. Confirm the workflow cannot wait indefinitely or hide a mandatory A failure. +- [ ] Test topology: shared JS A/B, separate fresh Fastly A/B, three mandatory native A jobs, bounded optional native B jobs, validated current-run collector and no signing credentials in executable/lifecycle jobs. Require job configurations to apply Task 9 role profiles and publish observed variation/runtime records; tests reject default-only A/B jobs or collectors that bypass these gates. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" workflow_contract` with the nonzero-selection guard; expect absent graph. +- [x] (from Task 13) The consumer rejects the candidate sentinel tag `v0.0.0-unqualified-candidate` (exact match) as the first check of `verify_index` and `verify_build_evidence`, which every online and offline path reaches. It also rejects any consumed build-evidence, build-recipe or rebuild-check companion declaring `"release_qualification": false` or a non-boolean value, after the companion bytes are authenticated against the signed index; an absent field or `true` is accepted because the R-INDEX contracts do not define the field. Both rules hold independently of the empty-bundle rule: a golden candidate index with non-empty bundle records is still rejected (`ac07047ed`). Making the field normative in the spec's companion contract needs a reviewed amendment. +- [ ] Call local reusable workflow at source revision. Build jobs are read-only. Collector uses Task 12 outputs; signing grants only contents-read/id-token-write/attestations-write, plus actions-read when needed. No artifact-metadata write for file attestations. Caller grants enough permissions for called signing jobs but its own build jobs explicitly narrow them. +- [ ] Invoke pinned attest 4.2.2 twice per canonical asset: exact executable/SBOM/evidence/recipe/rebuild provenance subjects, then executable subject + SPDX predicate. Save named bundles, validate all proofs/joins and sign index after collection. Do not construct caller predicates or sign source supplied commands. +- [ ] Rehearse actual certificates/toolkit fields in a disposable repository with test-only policy fixtures. Verify repository IDs, exact SAN, issuer, build/caller/source/event/run fields for both predicate types. Commit `Sign validated release outputs through the reusable builder`. + +Progress (2026-10-21, partial, collector logic only): `f17377a4e` implements +the collector side of the never-started/queued/cancelled and pre-step-failure +bullet as pure logic (see Task 12 collector follow-ups): no upload identity can +exist for such a job, outcomes are derived from bounded observed job states, +and a missing mandatory canonical rejects collection under every optional +state. The bullet stays open until the trusted workflow adapter supplies real +job observations and `release-build.yml` bounds the wait; no workflow, signing +or publication was added. + +### Task 16: Publish through one immutable-release owner + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** `.github/workflows/release.yml`, release tool `src/publication.rs` and private `publication::tests`. + +- [x] Add fake API cases for matching/conflicting draft, missing/altered asset, concurrent rerun, lost response, already published tag, mutable/unknown final state and permission differences on approved RC commits. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" publication::tests` and `workflow_contract` separately with the nonzero-selection guard; expect absent publication owner. +- [ ] Keep tag-scoped concurrency without cancellation. Draft, upload, re-download and validate the exact asset set before publication. Match source/bytes for draft resume; never overwrite published assets/tags. Publication has contents-write only, no OIDC/provider/settings token. +- [ ] Require maintainer governance approval and immutable/tag-rule setup before enabling tags; afterward read immutable=true and verify GitHub release statement/membership with the normal token. A confirmed mutable release fails publication, is removed only when the workflow release-write role authorizes that still-mutable cleanup, and produces an incident with the cleanup outcome. Never delete an immutable release. Unknown state produces an incident and read-only reconciliation; it MUST NOT trigger blind deletion. A lost publish response also triggers read-only reconciliation before another mutation. +- [ ] Exercise all failure paths in an authorized disposable public repository and record measured evidence. Production tag/permissions require separate release authorization. Commit `Publish complete immutable attested releases`. + +Progress (2026-10-21, `9bd8be2c2`, closed pure owner): `src/publication/` +implements the sole-owner state machine against a `ReleaseApi` trait with an +in-process fake (33 tests). It refuses a tag that has a published release or +points at another commit, resumes a draft only on matching tag, commit and +bytes, re-downloads and validates the exact asset set within bounds derived +from the index asset set and role limits, re-checks the tag and draft before +publishing, and reconciles lost responses read-only. After publication it +requires an immutable release whose observed tag, commit and statement +membership equal the intended release; a confirmed mutable release is removed +only with cleanup authority after a fresh read, with an incident carrying the +API failure. Immutable or unknown releases are never deleted. Publication holds +only a contents-write marker built from the caller-attested job permissions. +There is no real API adapter, `release.yml`, `workflow_contract` publication +fixture, command or token handling, so the remaining bullets stay open; partial +uploads after lost responses need a manual recovery runbook. + +### Task 17: Implement strict consumer documents and embedded policy + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Modify:** CLI `release-policy.json` from Task 3. +**Create:** CLI `src/release/mod.rs`, `error.rs`, `policy.rs`, `documents.rs`, `src/release/tests/mod.rs`, `src/release/tests/documents.rs`, `tests/fixtures/release/`. +**Modify:** CLI `src/lib.rs`, `Cargo.toml` (host-only sha2/schema validator with justification). + +- [x] Before the first filtered run, wire the host-only `mod release` in CLI `src/lib.rs`, the production child modules in `src/release/mod.rs`, `#[cfg(test)] mod tests` there, and `mod documents` in `src/release/tests/mod.rs`. Add the test cases before checking selection; Rust does not discover this directory automatically. Keep these internals private. +- [x] Test closed schema/kind/profile/action combinations, structurally valid but policy-forbidden `fastly-staging`, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. +- [x] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::documents`; expect the intended missing implementation or assertion failure. When it compiles, use the nonzero-selection guard below before accepting red or green; a successful run selecting zero tests is a failure of this plan, not a pass. +- [x] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (independently approved source snapshot or fixed official GitHub Contents API acquisition from that exact independently approved full commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. +- [x] Follow [R-POLICY](../specs/2026-10-09-1235-release-attestation-design.md#r-policy): fetch only `https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref=`, `Accept: application/vnd.github.raw+json`, explicit read-only credential, 1 MiB maximum and attempt deadline. Reject arbitrary host/path or credential-forwarding redirects. Add source/API mismatch, unsupported producer v2 under consumer v1, reviewed historical v1 under newer consumer, and current-consumer-bound enforcement negatives. +- [x] Keep the already registered tests active and require nonzero selected counts on every focused rerun; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. +- [x] Fix repository/owner IDs, supported combinations, exact gh/action contract and all R-BOUNDS limits. Re-run tests/clippy. Commit `Parse release evidence under embedded consumer policy`. + +### Task 18: Implement the bounded isolated gh runner + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** CLI `src/release/gh.rs`, `src/release/tests/gh.rs`. + +**Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. + +- [x] Register the new gh production modules and `mod gh` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. + +- [x] Write fake-child tests for hangs/cancellation, simultaneous stdout/stderr saturation, malformed/truncated JSON, exit failure, multiple attestations, token precedence, host/config overrides and log redaction. +- [x] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::gh`; expect absent runner. +- [x] Resolve gh once through trusted PATH/explicit trusted installer configuration, require 2.97.0, and retain that executable target. Pass fixed argument vectors with exact certificate identity; never arbitrary forwarded verifier flags. Accept explicit GH_TOKEN then GITHUB_TOKEN, pass only GH_TOKEN, isolate home/config/state, disable prompts, reject loader injection and keep provider credentials out. +- [x] Concurrently drain bounded pipes: 120 seconds per child, 15 minutes per attempt, 128 MiB stdout/1 MiB stderr. Kill/reap on either limit/cancellation. Use real OS fake-child fixtures, not mocks that omit pipe pressure. Failed exit never yields trusted parsed claims. +- [x] Re-run runner negatives and an approved signed public fixture under the pinned gh binary. Actual attest 4.2.2 provenance and SPDX signatures, isolated token-free replay, offline SPDX replay and signed release output were measured; see [consumer qualification report](../../../scripts/release/qualification/2026-10-15-consumer-real-fixtures.md). Commit `Bound and isolate release verification subprocesses`. + +### Task 19: Verify signatures, evidence joins and official publication + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** CLI `src/release/evidence.rs`, `github.rs`, `artifact.rs`, `src/release/tests/verification.rs`. + +**Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. + +- [x] Register the new evidence/github/artifact production modules and `mod verification` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. + +- [x] Add a valid graph and independently altered edges for index, all provenance subjects, SPDX subject/document/root, JS prepared/retained records, policy/source/run/caller identities, signed names, REST lengths/immutability and tag object type. +- [x] Run verification tests; expect no implemented evidence graph. +- [x] Verify index provenance before trusting fields. Obtain only approved official GitHub host/repository data with explicit read-only credential and bounded responses. Validate lightweight source target, source approval, independently acquired fixed-path producer policy bytes at that commit, exact signed release names/digests and actual/index/REST sizes. Primitive gh success is insufficient. +- [x] Check verified certificate numeric IDs, exact SAN/issuer/build config/digest/trigger/run URI in both proofs, and provenance build type/caller/external parameters/resolved source/invocation. SPDX lacks SLSA caller fields; use its certificate plus subject/document constraints. +- [x] Separate BuildEvidenceVerified, PublicationVerified and consumer ActionAuthorized states; constructors remain private. A retained verified-release-asset owns private bytes/handle and source/policy/role evidence. Read-only CLI verification returns a VerificationReport, not deployment authorization. The linked integration plan adds the private action-scoped VerifiedArtifact only after concrete context approval; an asserted path/hash cannot construct it. Stream bounded asset responses into retained files, enforcing each role limit while downloading; do not buffer a 512 MiB executable through the gh JSON pipe or check limits only after writing it. Rehash before later use and reject offline official-deploy construction. +- [ ] Official project first-use remains pending: upstream attest 4.2.2 formats and both real predicate signatures are measured in the report above, but third-party fixtures cannot satisfy the official repository/index policy. Exercise first-use verification using an independently trusted previous verifier or source-built verifier from independently approved source, never the downloaded executable verifying itself. Commit `Verify complete official release evidence before authorization`. + +### Task 20: Expose read-only CLI verification and bootstrap instructions + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** CLI `src/commands/release.rs`, `tests/release_cli.rs`, `src/release/offline.rs`, `src/release/tests/offline.rs`, `docs/guide/release-verification.md`. +**Modify:** CLI `src/commands/mod.rs`, `src/run.rs`, `src/release/mod.rs`, `src/release/tests/mod.rs`; `docs/guide/cli.md`, `.vitepress/config.mts`. + +- [x] Register `mod offline` in both the private production and test roots before its first focused run. Register the public command module/dispatch before process tests; run `release::tests::offline` with the nonzero-selection guard in addition to the CLI process target. +- [x] Test the required parse path `ts release verify v1.2.0 --asset ts-aarch64-apple-darwin --expected-source-commit `, invalid source/asset/unsupported offline-deploy flags and no provider/config/build hooks on all failure paths. +- [x] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" --test release_cli`; expect unknown command. +- [x] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag MUST be documented and reviewed, not smuggled into an implementation example. +- [x] Document trusted gh installation and independent source/previous-verifier bootstrap before first executing downloaded ts. A source-built verifier provides approved-source trust, not official binary provenance for itself. Describe exact policy checks after primitive gh signatures; do not instruct self-verification or checksum-only installation. +- [x] Add an internal build-evidence-only offline verification path, reusing parser/policy/subject/SPDX joins and bounded gh runner with independently approved roots/source/digest. Test it with networking denied and token absent; its result cannot construct publication/deployment authorization. No initial public offline flag is added. Document the pinned primitive commands and the manual companion/document/policy checks needed beyond their exit status; offline success is build evidence only, no official release membership or offline deploy. Run CLI helper, docs lint/format/build and malformed/timeout/zero-provider-write fixtures. Commit `Expose official release verification and trusted bootstrap`. + +### Task 21: Validate and document manual Fastly production consumption + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Create:** release tool `src/package.rs`, private `package::tests`, bounded manual Fastly archive fixtures and public command tests. +**Modify:** `docs/guide/release-verification.md`, `docs/guide/cli.md`, `CHANGELOG.md`; correct obsolete config-embedding comments in source/test/setup files identified below. + +- [x] Test accepted four-member layout, generic manifest-only archive, swept-in files, reserved/empty package name, traversal, links, duplicates, wrong module/manifest, malformed/non-core wasm and max-size bounds. +- [x] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" package::tests` with the nonzero-selection guard; expect absent inspector. +- [ ] Use a fresh private directory, independently bootstrapped approved-source `trusted-server-release inspect-fastly-package`, an allowlisted deployment-only manifest and verified wasm. `compute pack --wasm-binary` has two output archives; select named archive only after exact layout/content checks. Pack/validate accepting a file does not prove valid Wasm or application execution. Verify valid core format and positive publisher-request fixture separately. +- [x] Inspector takes the approved expected executable digest and manifest identity from the successful verification/context procedure, rehashes both the retained archive and its actual module member immediately before submission, and never trusts a digest supplied by the archive. Document trusted-source acquisition for the helper; it is not a separately authenticated release asset. +- [x] Document observed service/base-version bindings and explicit production command with --service-id, --version and --no-default-domain. Explain manifest precedence, automatic clone/activation, possible same-hash skip and inability to supply integrated staging/preactivation guarantees. Serialize manual operator changes. No compute publish/source build is permitted. +- [ ] Run a separately authorized disposable Fastly service submission; compare submitted member and actual returned version, including skip/uncertain cases. Record live proof before declaring AC-9 passed. No customer context or provider credentials enter producer jobs. + + This exit remains unchecked: no live service submission is authorized in the + local Task 21 increment. Inspector/command tests and instructions do not supply + submitted-member, returned-version or application-runtime observations. + +- [x] Correct false config-embedding text in `.github/actions/setup-integration-test-env/action.yml`, `.github/workflows/test.yml`, integration runtime/framework/parity helpers, browser infra helper and Spin app/middleware/route comments; behavior changes remain separate. Commit `Document and validate verified-byte Fastly deployment`. + +- [x] Update `docs/guide/getting-started.md`, `edgezero.md` and `error-reference.md`: canonical `x-ts-version` is full source identity, external tag belongs to the index, and development retains #1213 display fallback. Annotate the historical #1213 goals. Validate manual retained-byte packaging/submission as AC-9/D6 and operator guidance as AC-9/D8; upstream EdgeZero delay MUST NOT block this manual route. Context/preactivation/staging/recovery remain AC-8. + +Local implementation (2026-10-21): `inspect-fastly-package` selects only the +named archive derived from a separately approved manifest, checks its four raw +TAR members without extraction and validates actual module bytes with the pinned +core-Wasm inspector. The initial manifest profile is deliberately narrow: exactly +`manifest_version = 3` and a portable nonreserved `name`, in canonical two-line +form without comments. It accepts no build hooks, setup payloads or local fixtures. +Held-file/path identity checks and repeated archive/member hashes precede the +diagnostic report; expected hashes are independent inputs and grant no authority. +Pure archive fixtures and command tests exercise local inspection only. + +The guidance documents trusted helper bootstrap, separate retained verified-byte +acquisition, service/base-version observations and explicit production limits. +`ts release verify` still has no public export option. The source-header split +and historical #1213 development fallback remain documented. The checked guidance +item above records the text implementation, not completion of its AC-9/D6 live +validation dependency. An actual pinned Fastly CLI pack/inspect compatibility run, +positive application-request runtime acceptance in this manual operation, and +separately authorized disposable-service submission remain unobserved here. +The pack/private-directory exit and live submission exit stay unchecked; AC-9 +and Task 21 are not complete. No production selector, workflow trigger, transfer +adapter, signer, deployment code or provider resource was changed. + +Local verification passed 17 pure package tests with a nonzero-selection guard, +both new public-command tests (spawning only the release binary), strict native +all-target/all-feature Clippy, documentation generation, formatting and workflow +lint. The acceptance command failed before dispatch existed and passed afterward; +a non-UTF-8 report-path panic was separately reproduced and corrected. Independent +spec and quality review found no remaining findings. No sandbox/container tests, +Fastly CLI process or provider submission were run locally. The complete hosted +CI matrix and actual pinned-pack compatibility remain separate validation. + +### Task 22: Full validation, qualification evidence and release handoff + +**Phase: 2+ (historical destination task).** Phase 1 uses the reduced implementation checklist above, rather than completing this task. + +**Modify:** the implementation PR's evidence record and docs; no automatic production mutation. + +- [ ] Run the complete command block below and collect exit statuses/log summaries. On local certificate/localhost sandbox failures rerun the same tests with needed access; do not weaken TLS or alter assertions. +- [ ] Rehearse hosted source authorization, shared JS, Fastly A/B, all three native A/SBOMs, optional B terminal failures, actual exact-action signatures, immutable draft/publication and full consumer verification together. Toy/synthetic component proofs cannot replace this complete composition. +- [ ] Recheck permission/config separation, governance/rules/bypass authority, bootstrap docs, actual platform evidence and live manual submission. Missing evidence blocks tag enablement; native B equality, packaging or upstream integration do not add hidden blockers. +- [ ] Review against AC-1–AC-7, AC-9–AC-10; keep AC-8 separate. Use @superpowers:requesting-code-review and @superpowers:verification-before-completion. Maintainer approval precedes any production tag. +- [ ] Commit validated documentation/evidence with `Record initial release qualification`; update existing PR #1262 with implementation and validation evidence only when the required checks pass; never create a replacement implementation PR. + +## Threat-to-negative-proof trace + +| Threat | Existing test families | Plan negative proofs | +| ------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | +| TH-1 | T-8, T-9 | Tasks 13, 17, 19: byte/subject/companion tampering and publication membership | +| TH-2 | T-8, T-9, T-10 | Tasks 17–20: independent source-policy acquisition, roots, historical identity and offline limits | +| TH-3 | T-2, T-3, T-4, T-5 | Tasks 3, 7–11: source/common-Git/env audit, actual lifecycle/compile denial, native and JS coverage | +| TH-4 | T-7 | Tasks 3, 14, 16: unlisted/self-approved RC, remote-ref substitution, governance bypass, skipped/wrong-SHA gates | +| TH-5 | T-4, T-7 | Tasks 8, 12, 15: authenticated tools/full closure, real boundary, current-run transfers and separated signer | +| TH-6 | T-9, T-11, T-12 | Tasks 18–21 and linked AC-8: exact gh bootstrap, retained substitution, bounded children, manual equality/recovery | +| TH-7 | T-7, T-8, T-9 | Tasks 15–19: exact certificate/source/caller/run semantics and immutable membership; GitHub remains a trust root | + +## Focused Rust test selection guard + +Before accepting any filtered private-module run, require an actual selected test. Register producer modules/tests before filters such as `documents::tests`, `preflight::tests`, `source::tests`, `materials::tests`, `process::tests`, `boundary::tests`, `preparation::tests`, `javascript::tests`, `qualification::tests`, `recipe::tests`, `inspect::tests`, `composition::tests`, `spdx::tests`, `collection::tests`, `distribution::tests`, `publication::tests` and `package::tests`. For Tasks 17–20 select `trusted-server-cli` and its registered `release::tests::{documents,gh,verification,offline}` filters instead. An intended compile failure for missing behavior can establish the first red state; successful zero selection never can. + +```sh +set -eu +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +test -n "$HOST_TARGET" +RELEASE_TEST_PACKAGE=trusted-server-release +RELEASE_TEST_FILTER=documents::tests +RELEASE_TEST_LIST=$(mktemp) +trap 'rm -f "$RELEASE_TEST_LIST"' EXIT +cargo test -p "$RELEASE_TEST_PACKAGE" --target "$HOST_TARGET" \ + "$RELEASE_TEST_FILTER" -- --list > "$RELEASE_TEST_LIST" +awk '/: test$/ { count++ } END { if (count == 0) { print "Focused release test filter selected zero tests"; exit 1 } print "Selected " count " release tests" }' "$RELEASE_TEST_LIST" +cargo test -p "$RELEASE_TEST_PACKAGE" --target "$HOST_TARGET" "$RELEASE_TEST_FILTER" +``` + +Set `HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p')` before each independent shell session/filter. `$APPROVED_SOURCE_COMMIT` MUST come from independent source approval, never the release index. These Cargo commands build a local developer tool only. Qualified production wrappers MUST select the independently approved source and OS-native toolchain described above. The ordinary `test-release-native` CI matrix on Ubuntu 24.04/macOS 15 directly runs all native helper tests, clippy/all-targets/all-features, documentation and actual nonzero native JS `release_prebuilt` tests/clippy. The prior WASI alias selecting zero native tests supplies no coverage. This source-built CI matrix is not production bootstrap or qualification proof. Future aliases require separate review; adapter/CLI aliases do not cover the helper. The explicit host commands below remain required until aliases are reviewed/wired. Never use default WASI output to bootstrap a native producer. + +## Full implementation verification commands + +Run from the repository root with pinned tools and installed npm dependencies. Linux CI uses its host triple; macOS can use the CLI helper. Record each command independently so a later command cannot mask an earlier failure. + +```sh +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo test-fastly +cargo test-fastly-reuse +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +./scripts/test-cli.sh +cargo test -p trusted-server-core --test template_build_digest +cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings +scripts/lint-wasm-clock.sh +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features +cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features +cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt +cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features -- -D warnings +cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings +``` + +Run the remaining commands with the repository-pinned Node/npm selected explicitly (subdirectory shell startup can otherwise pick a different Node): + +```sh +(cd crates/trusted-server-js/lib && node build-all.mjs && TRUSTED_SERVER__RELEASE_JS_TESTS=1 npx vitest run && npm run format) +(cd docs && npm run lint && npm run format && npm run build) +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "scripts/**/*.mjs" "tinybird/**/*.md" +RELEASE_HOST_TARGET="$(rustc -vV | awk '/host:/ { print $2 }')" +cargo run -p trusted-server-cli --target "$RELEASE_HOST_TARGET" -- dev lint domains --staged +git diff --check +git diff --cached --check +``` + +Stage the reviewed implementation files before the staged-domain check; each grouped command stops at its first failure. New producer/schema dependencies MUST be installed at their exact reviewed versions first. No bare workspace cargo test/build or global all-features clippy. + +## Traceability and milestone exits + +| Requirements | Tasks / principal negative proof | Acceptance | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------- | +| R-COHORT, R-PLATFORM, R-VARIANT | 1,3,8,9: exact cohort/profile, incompatible native runtime, unsupported reuse | AC-1, AC-2 | +| R-CONFIG, R-INPUTS, R-ENV | 3–5,7,9: operator inputs, unexpected source files, skips/overrides, source integrity | AC-2 | +| R-JS | 5–7,10: locked A/B, inventory joins, input versus retained bytes | AC-3 | +| R-BUILD, R-REPEAT, R-NATIVE | 8–12: actual confinement, complete wasm equality, valid transfers, honest native outcome | AC-3 | +| R-SBOM | 10,11,13,19: real Rust/native/JS coverage and SPDX root/predicate equality | AC-4 | +| R-FACTS | 4: strict full source, owning target/variant, external tag and unchanged service identity | AC-5 | +| R-SOURCE, R-SIGN, R-PUBLISH | 14–16: approved lightweight main/RC, exact-commit gates, isolated signer, immutable reconciliation | AC-6 | +| R-INDEX, R-EVIDENCE, R-POLICY | 2,13,17,19: schema/semantic joins, subjects and independent policy identity | AC-4, AC-7 | +| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | 18–20: trustworthy first use, limits/pipe pressure, exact REST/signed membership | AC-7 | +| R-OFFLINE | 19,20: approved build proof only; offline official deploy rejected | AC-7 | +| R-DEPLOY/R-PACK manual portion, R-MANUAL | 19,21: verified bytes before mutations, explicit manual limits and live submission | AC-9 | +| R-DEPLOY/R-PACK integrated portion, R-CONTEXT, R-EDGEZERO, R-RECOVERY | Linked upstream plan, not first-release closure | AC-8 | +| Operator/CLI/changelog guidance | 20–22: source-header identity, bootstrap and manual-route instructions (D8) | AC-9 | +| R-FOLLOWUP | 1,20–22: explicit unsupported adapters/reconstruction/offline; January supersession | AC-10 | + +A completed task means its described tests pass and reviewed code exists, not merely that its checkbox was checked. Recipe pins/qualification are concrete outputs of Task 8, not assumed values. An unavailable required host blocks the selected contract; it does not authorize weakening it. For optional CLI B only, bounded unavailability is an honest nonblocking result. + +## Planning-only verification and handoff + +For these Markdown plans, validate formatting, links, shell syntax, requirement coverage, task dependencies and independent review. Do not claim that runtime tests verify implementation that has not been written. Continue in the existing main checkout on branch `spec/1235-release-attestation` and PR #1262. Automated review is not maintainer approval. diff --git a/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md b/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md new file mode 100644 index 0000000000..23ecf8caf5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md @@ -0,0 +1,112 @@ +# CLI package attestation integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make installable CLI packages verifiably contain the canonical attested executables, without creating a second release authority or weakening #1235 evidence. + +**Architecture:** Consume final canonical Build A executables and their qualified evidence; packaging never recompiles or alters those bytes. A separately signed package inventory binds outer package digests to exact executable members. One publication job uploads canonical and selected package evidence before publishing a new immutable tag. + +**Tech Stack:** Existing #1292 shell helpers/nfpm packaging design, the host-only Rust release tool's strict metadata and bounded package/archive modules, SPDX canonical executable evidence, pinned GitHub attest/gh contracts from the primary plan. + +--- + +The [Rust migration addendum](../specs/2026-10-14-release-tooling-rust-migration.md) applies to every new packaging producer helper. Extend private `crates/trusted-server-release/src/package.rs` and `distribution.rs` modules; retain thin POSIX/nfpm adapters, no introduced Python or generic packaging framework. Native independently approved-source bootstrap and all exact executable/acquisition checks remain required. Unit tests stay private alongside modules; fixture data uses the crate's `tests/fixtures/` and public process tests use `tests/commands.rs`. + +## Inputs and prerequisites + +Read the [primary release plan](2026-10-13-attested-release-implementation.md), [#1235 contract](../specs/2026-10-09-1235-release-attestation-design.md), PR #1292's packaging spec/plan and its final reviewed revision. At planning time #1292 is documentation only; do not assume its workflow/helper paths exist. Fetch or stack the owning changes before editing them. This plan supplements that plan rather than copying its independent publish job. + +The governing draft #1235 index v1 covers three raw CLI binaries plus Fastly, no package-inventory reference and no Linux ARM64. The package integration therefore requires a reviewed additive contract and new schema version. Task P1 MUST approve it before P2–P7 implementation. Neither package provenance alone nor SHA256SUMS satisfies the executable's SPDX/provenance graph. + +**Selected initial integration:** preserve the existing three qualified CLI targets. Produce three Linux x86_64 installer formats and two macOS archives (five packages). This deliberately does not complete #863's Linux ARM64 requirement. Task P8 independently extends canonical qualification/SBOM/policy to ARM64 before publishing its three packages. If maintainers require all eight installer assets in the first packaging release, P8 becomes a prerequisite before that release; no half-qualified ARM asset is slipped into the three-target cohort. + +Initial #1235 release can happen first; packaging appears on a later new tag once its approved contract is enabled. Never backfill packages into an immutable release. No implementation step may remove #1262's raw assets to make room for packages. + +### Task P1: Approve the cross-PR contract and schema migration + +**Modify after owning branch is available:** `docs/superpowers/specs/2026-10-12-863-cli-release-packaging-design.md`, `docs/superpowers/plans/2026-10-12-cli-release-packaging.md`, #1235 contract. +**Create:** `docs/superpowers/specs/schemas/release-index-v2.schema.json`, `cli-packages-v1.schema.json`; keep index v1 immutable for historical verification. + +- [ ] Write a concrete spec delta: one release.yml owner; reusable release-build.yml signer; canonical three CLI profiles plus Fastly unchanged; five initial wrappers; ARM enablement separately selected; package code/member evidence is additive. +- [ ] Agree on #1262's GNU/macOS baselines and real Intel startup. Do not treat Ubuntu 22.04/glibc 2.35 or Rosetta alone as satisfying GNU 2.34/Intel qualification. Keep package ecosystem smoke tests as additional evidence. +- [ ] Resolve version policy explicitly: packaging milestone chooses #1292's tag equals owning CLI Cargo version, with documented maintainer bump/changelog procedure. This is a reviewed narrower packaging prerequisite, not an attempt to embed external tag into binaries. Initial un-packaged #1235 tags need not retroactively adopt it. +- [ ] Approve index v2's optional `package_inventory` reference and its attestation references with exact name/length/digests. Enable it only in the reviewed `phase1-fastly-cli-packaged` cohort and updated consumer policy. Approve `ts release verify --package --expected-source-commit ` as the distinct package-selection interface; `--package` and `--asset` are mutually exclusive. Older v1 consumers reject unsupported v2, with an independently trusted verifier upgrade path documented. +- [ ] Define package inventory records: schema/source/cohort/recipe/policy identity, each package name/format/architecture/size/SHA-256, exact executable member path/mode/size/SHA-256 and canonical raw asset name. Refer to raw asset digests, not release-index digest: index → inventory → package/member MUST NOT form a self-hash cycle. Bundle references are in the index, not inside the inventory that the bundles authenticate. +- [ ] Replace #1292's signed/annotated `git tag -s` release instruction with `git tag v ` followed by the explicit tag push. Validate lightweight object type; a tag signature does not replace the approved source/signing policy. +- [ ] Get the delta approved/merged and update plan baseline. Commit `Align CLI packaging with canonical attestation`. + +**Exit:** No implementer chooses their own signer, platform floor, ARM assurance, archive relationship or versioning rule. + +### Task P2: Consume canonical executables without transformations + +**Modify/create according to #1292 ownership:** `scripts/release/package-linux.sh`, `package-macos.sh`, `packaging/nfpm.yaml`, release tool private `package::tests` and `tests/commands.rs`. + +- [ ] Write tests rejecting wrong source/target/role, source recompilation, strip/sign hooks, unverified raw input, altered member and CLI B output substituted for A. +- [ ] Derive `HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p')`; run `cargo test -p trusted-server-release --target "$HOST_TARGET" package::tests` with the primary plan's nonzero-selection guard; expect missing integration behavior. These local source-built commands do not qualify native bootstrap. +- [ ] Accept only Task 12/13 primary-plan validated A output and identity table. All final stripping/signature transformations occur in the canonical recipe before its digest/SBOM, never in packaging. Remove #1292's independent strip step and compiled-cache restoration from the canonical build path. +- [ ] Add packaging configuration for the qualified x86_64 Linux executable and both Apple executables. Preserve member bytes; set documented installation modes. nfpm/tool acquisition has reviewed version/digest verification, not a floating installer. +- [ ] Test canonical file unchanged before/after all package formats. Commit `Package the canonical CLI bytes without modification`. + +### Task P3: Inspect whole package structure and bind members + +**Extend:** release tool `src/package.rs`, `src/distribution.rs` and private registered tests; add adversarial data under `crates/trusted-server-release/tests/fixtures/packages/` and process cases to `tests/commands.rs`. Proposed fixed commands are `trusted-server-release inspect-cli-package` and `make-package-inventory`, only after P1 approval. + +- [ ] Write tests for nested traversal, links/hardlinks, duplicate members, oversized compressed/decompressed content, extra executable members, wrong architecture/mode, substituted maintainer script, unsupported compression and altered ts bytes. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" package::tests` and `distribution::tests` separately with the nonzero-selection guard; expect absent inspector. +- [ ] Implement bounded format-specific inspection: Debian ar/control/data archives, RPM payload/header, Arch tar and macOS tar. Use maintained parsers/pinned utilities where possible. Each adapter validates allowed installation paths, executable ts, license/readme and explicitly approved metadata/scripts; reject unknown code-bearing entries. A raw tar scan is insufficient for .deb/.rpm. +- [ ] Compare extracted executable bytes against canonical retained SHA-256/size, check 0755 installation mode, then hash the final outer package. Generate deterministic inventory only after every selected wrapper passes. Do not compare an archive digest directly to executable digest. +- [ ] Validate closed package schema and source/target/profile joins with the canonical index data. Reject inventory attempts to approve an unknown raw target. Commit `Bind installer packages to canonical CLI members`. + +### Task P4: Smoke-test installation and provenance-aware extraction + +**Modify:** #1292 smoke helpers/workflow jobs; release tool private package-smoke tests and public process fixtures. + +- [ ] Test that failed install/startup/version or member mismatch blocks the selected package cohort; metadata-only architecture checks do not count as execution qualification. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" package_smoke` with the nonzero-selection guard after registering these cases; expect incomplete checks. +- [ ] Install Linux wrappers in reviewed clean Ubuntu/Fedora/Arch environments, run version/help/config-init outside a checkout, and compare installed /usr/bin/ts to canonical digest. Preserve the GNU compatibility qualification independently of package-manager tests. +- [ ] Extract/run macOS archives on actual qualified ARM/Intel hosts. Rosetta may be extra coverage, never the sole Intel gate. Restore executable permissions without altering bytes; verify loader requirements. +- [ ] Record exact container/tool/OS identities and outcomes. Smoke images MUST be pinned and qualified, not `latest`. Commit `Qualify installed CLI package behavior and identity`. + +### Task P5: Sign package evidence inside the existing trusted boundary + +**Modify:** `.github/workflows/release-build.yml`, release tool `src/distribution.rs`, workflow contract tests. + +- [ ] Test package metadata cannot replace executable SPDX/provenance, wrong signer/action/run, modified outer bytes, inventory-member mismatch and unapproved package subject. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" distribution::tests` and `workflow_contract` separately with the nonzero-selection guard; expect package evidence unsupported. +- [ ] Reuse exact same-revision signer and pinned attest 4.2.2; do not keep #1292's direct v2 provenance action in publish. Package provenance names each final package plus authenticated package inventory as exact subjects. Raw executable proofs still name only their approved executable/companions. +- [ ] Add a signed package-inventory proof and index v2 references for inventory/bundles. Root index is assembled after package proofs exist and covers them; no signature/checksum cycle. For the CLI's component composition, consumers use the executable's SPDX proof. Do not advertise an enclosing installer SBOM unless a separate container-level scope is actually generated. +- [ ] Verify actual pinned certificates/provenance and all wrapper/member edges before publication. Commit `Attest CLI installer and member evidence`. + +### Task P6: Extend consumer policy and bootstrap before v2 publication + +**Modify:** CLI `src/release/policy.rs`, `documents.rs`, `evidence.rs`, `release-policy.json`, release verification/CLI tests, `docs/guide/release-verification.md`, packaging install guide. + +- [ ] Add tests for v1 historical acceptance, v2 package selection, unknown package/version, altered inventory/member, verified outer package with unverified executable and the inverse. +- [ ] Run CLI release unit/process tests; expect unsupported v2. +- [ ] Implement the reviewed v2/member policy without letting a downloaded index select arbitrary package kinds or actions. Verify package signature/official membership, authenticate inventory, verify raw executable/SBOM graph and prove exact installed/extracted member equality before recommending installation/execution. +- [ ] Implement the exact package-selection syntax approved in P1; do not overload `--asset` silently to turn arbitrary archives into canonical executable subjects. Use the same internal verification library, with a distinct verified-package result and retained-member identity. +- [ ] Qualify independently trusted source/previous-verifier bootstrap for first v2 use. A CLI that only supports v1 cannot automatically authorize its own unverified upgrade. Commit `Verify packaged CLI members under reviewed v2 policy`. + +### Task P7: Integrate one complete immutable publication + +**Modify:** `.github/workflows/release.yml`, package release helpers, `CONTRIBUTING.md`, `CHANGELOG.md` and tests. + +- [ ] Test incomplete wrappers, mixed source versions, package-only draft, missing raw/SBOM assets, an existing immutable v1 tag and uncertain publish response. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" publication::tests` with the nonzero-selection guard; expect package cohort unsupported. +- [ ] Publish exact canonical plus selected wrapper assets/evidence/checksums through the single owner. No independent #1292 publish job races or finalizes the tag first. Require all selected wrapper gates, complete downloads/joins and post-publication immutable checks. +- [ ] Rehearse the whole v2 release and trusted package install in an authorized disposable repo, then run all AGENTS gates and producer/consumer tests. Only a separately authorized new production tag follows. +- [ ] Commit `Publish canonical and packaged CLI evidence together`. + +### Task P8: Qualify Linux ARM64 before expanding packaging scope + +**Modify through a separate reviewed extension:** cohort, tools, versioned schema, consumer policy, native recipe/composition fixtures and package matrix. + +- [ ] Obtain approval for an aarch64-unknown-linux-gnu canonical executable, provenance-sbom profile and qualified compatibility floors. Until then it is unsupported, not a weaker undocumented extra asset. +- [ ] Build actual ARM64 A with fresh JS/materials, validate native/external components, generate full SPDX and attest it under the same policy. Native B remains a bounded optional measurement unless explicitly promoted. +- [ ] Run startup on actual ARM64 baseline hosts and each claimed package ecosystem. If an Arch ARM64 runtime cannot be qualified, either retain an explicit metadata-only distribution limitation approved in the packaging spec or defer that package; do not advertise fully smoke-tested execution. +- [ ] Approve the `phase1-fastly-cli-arm64-packaged` cohort with five canonical executables and eight wrappers, and update the versioned schema/producer/consumer expected sets before runs; repeat P2–P7 for ARM packages. A failed selected ARM A/SBOM/runtime gate blocks the expanded cohort, with no after-failure fallback. +- [ ] Commit `Qualify and attest the ARM64 CLI distribution`; close #863 only when its approved platform/package acceptance is satisfied. + +## Completion and review + +P1–P7 produce verifiable packages for the initially qualified targets on a new release. P8 completes the original all-four-platform packaging intent after qualification. Packaging does not weaken or retroactively expand existing #1235 releases. Run each new focused test first, then the primary plan's full AGENTS command list and package-manager/actual-host qualification. Review the complete evidence graph, source/tag/version consistency and first-use bootstrap before publication. diff --git a/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md new file mode 100644 index 0000000000..bbbc5d41f9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md @@ -0,0 +1,125 @@ +# Fastly prebuilt EdgeZero integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add byte-preserving online production/staging deployment of verified Fastly wasm through a typed EdgeZero API with durable recoverable observations. + +**Architecture:** Trusted Server owns official verification, operator authorization and a private retained artifact. EdgeZero owns Fastly packaging/transport, observed context and concrete lifecycle operations. A durable operation sink records intent before every write and response afterward; uncertain provider state is reconciled read-only before any retry. + +**Tech Stack:** Existing Rust/error-stack consumer, reviewed upstream EdgeZero/Fastly CLI 15.1.0 or qualified API transport, restricted atomic local journal, provider-free runner fixtures and authorized disposable Fastly qualification. + +--- + +## Authority and prerequisites + +Read the [primary plan](2026-10-13-attested-release-implementation.md) and [contract R-DEPLOY through R-RECOVERY](../specs/2026-10-09-1235-release-attestation-design.md#r-deploy). This is AC-8, a separate integration milestone, not an initial #1235 closure condition. It depends on the primary verifier/retained artifact, package inspector and public qualification; it may be prepared upstream in parallel but cannot ship an official release path without that verifier. + +Baseline EdgeZero revision is `683202c66948146ec360f490126f1d60f920288b`. Its generic adapter operation returns unit, source deploy can execute manifest shell commands, and Fastly staging builds/autoclones before staging. These are reference mechanisms, not an existing typed prebuilt seam. + +Use a separate upstream EdgeZero checkout/worktree; do not edit Cargo's shared cached checkout. Upstream relative paths below belong to that repo. After review, update Trusted Server's Cargo pin/lock rather than vendor-patching the cache. + +## Proposed API boundaries + +Keep small concrete APIs, with default unsupported behavior for other adapters. Do not add speculative Cloudflare/Spin/Axum payload variants. + +The owning upstream design MUST implement these concrete records (not empty adapter variants): + +| Record / interface | Required fields / behavior | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreparedFastlyPackage` | Private retained archive handle/lifetime, checked archive length/SHA-256, canonical module length/SHA-256, deployment-only manifest SHA-256 and inspected package name | +| `PrebuiltFastlyRequest` | Strong service ID, exact base version, production/staging mode, prepared payload and resolved context/sharing policy | +| `WriteIntent` | Stable operation ID, monotonically increasing sequence, phase (`clone`, `upload`, `relink`, `selector-write`, `stage`, `activate`), exact resource/version where known, request identity digest | +| `WriteObservation` | Matching operation/sequence/phase, actual response resource/version, known/unknown result and sanitized evidence references | +| `OperationSink::before_write(&WriteIntent)` | Synchronous concrete fallible result; caller MUST flush durable intent and failure forbids the following mutation | +| `OperationSink::after_response(&WriteObservation)` | Synchronous concrete fallible result; failure stops subsequent writes while preserving already attempted effects | +| `PrebuiltOutcome` | Exact module/package/service identities, optional exact version, last confirmed phase, mutation-attempted flag and per-phase known/unknown results on success and error; no inferred active-version success | + +E1 follows the owning repository's AGENTS.md for concrete error conventions and visibility. A result-only deploy method is insufficient: it cannot let the caller flush a journal before hidden internal link/selector mutations. No publisher signing or universal attestation registry is introduced. + +### Task E1: Approve upstream API and operation-state contract + +**Upstream modify:** `crates/edgezero-adapter/src/registry.rs`, `crates/edgezero-cli/src/lib.rs`. +**Upstream create:** `crates/edgezero-adapter/src/prebuilt.rs`, `crates/edgezero-adapter-fastly/src/prebuilt.rs`. + +- [ ] Write fake adapter tests for unsupported adapter/action, payload/context mismatch, mutation attempts before authorization, unit-success ambiguity and failed durability sink, truncated payload, pre-send failure versus sent/no-response uncertainty and missing exact version. +- [ ] Run upstream targeted prebuilt tests; expect missing API. +- [ ] Define private retained payload identity, exact service/base version, action/mode, logical/physical mappings, staging selector identity and sharing policy. Return structured observed IDs/phase states and error outcomes with known/unknown state, not an active-version lookup. +- [ ] Define explicit phase enum and WriteIntent/WriteObservation with stable operation/sequence IDs; before_write MUST synchronously succeed before every provider/config mutation. Journal persistence errors halt further writes, even after earlier successful writes. +- [ ] Approve exact-source Fastly unsupported defaults and review public API/docs/error types. Commit `Add a typed prebuilt Fastly operation interface`. + +### Task E2: Prepare byte-preserving packages from retained inputs + +**Upstream modify/create:** Fastly `src/prebuilt.rs`, `src/prebuilt/package.rs`, provider-free package fixtures. + +- [ ] Write tests for retained-file/path replacement, changed package/member/manifest, generic archive selection, local-server config leakage, extra executable, traversal and unsafe names. +- [ ] Run package tests; expect no validated prepared-payload constructor. +- [ ] Adopt the primary plan's exact deployment-only manifest and bounded four-member inspection semantics. Rehash retained wasm, manifest and selected archive before transport. Never run source build, compute publish, annotation/strip/sign hooks or arbitrary manifest shell overrides. +- [ ] Keep module/archive digests distinct. Public callers cannot construct prepared identity from arbitrary asserted hashes; constructors validate owned bytes and context. +- [ ] Run byte-retention/adversarial fixtures against pinned real pack output. Commit `Prepare verified Fastly packages without rebuilding`. + +### Task E3: Resolve and validate actual deployment context + +**Upstream create:** Fastly `src/prebuilt/context.rs`; modify existing selector/link helper seams only as required. + +- [ ] Test declared logical IDs vs observed links mismatch, wrong selector/config key, unknown secret-reference metadata, conflicting shared staging selector and disallowed resource-sharing policy. +- [ ] Run context tests; expect absent observed-context validation. +- [ ] Resolve service/base version and fetch read-only provider links/selector state. Validate repository logical IDs, selected physical links/key, runtime selector entries and required secret-reference metadata. Secret values are write-only; do not claim plaintext readback or log/store resolved secrets. +- [ ] Declare which KV/secret/external-effect resources staging shares versus isolates. Config-selector isolation alone does not imply all resource isolation. Validate resulting exact draft again before stage/activation. +- [ ] Commit `Validate observed Fastly deployment bindings` after fake-provider negatives pass. + +### Task E4: Implement exact clone/upload/context/stage or activation + +**Upstream create:** Fastly `src/prebuilt/lifecycle.rs` and fake provider/command tests. + +- [ ] Write sequence tests: verify/package/context before first write; explicit clone returning draft ID; upload to that ID without autoclone; exact draft readback; stage or activate only that ID. Test same-hash skip, partial response and unsupported staged request. +- [ ] Run lifecycle tests; expect absent state machine. +- [ ] Invoke OperationSink before each mutation, including clone, upload, resource relinks, selector writes, stage and activation. Use explicit version update rather than compute deploy's automatic activation or combined update/autoclone. +- [ ] Capture returned IDs from operation responses. Require validated resulting context before activation; staged success reports staged, not activated. Never guess success from exit zero or whichever service revision is currently active. +- [ ] Stop on sink/provider uncertainty and preserve last known observation. Commit `Deploy prepared Fastly packages through exact lifecycle phases`. + +### Task E5: Implement durable journal and scoped serialization + +**Trusted Server create:** CLI `src/release/deployment/mod.rs`, `journal.rs`, `context.rs`; internal tests and `tests/release_deploy.rs`. + +- [ ] Write crash-injection tests before send/after send/before response, failed journal flush, receipt path replacement, process restart, shared-selector races and loss of temporary files. +- [ ] Run CLI deployment tests; expect absent journal integration. +- [ ] Implement persistent restricted storage, stable operation ID, private retained evidence/package/context and atomic state writes. Flush file plus directory at each durable intent/response boundary. A TempDir that disappears on process exit is not recovery storage. +- [ ] Lock by service and relevant shared-resource identity across processes/environments; define stale-lock inspection/recovery without blindly deleting locks. Preserve production previous-version observation before mutation. +- [ ] Record exact known/unknown state, policy/source/module/package hashes and sanitized context identities; no active config, token or resolved secrets. Deployment-only health is not_performed. Commit `Persist deployment intent and recovery observations`. + +### Task E6: Reconcile uncertainty without guessing or replaying mutations + +**Upstream create:** Fastly `src/prebuilt/reconcile.rs`; Trusted Server internal journal reconciliation tests. + +- [ ] Test known draft inspection, lost first-clone response, multiple indistinguishable candidate versions, changed selector ownership, unknown upload/activation and receipt failure after success. +- [ ] Run reconciliation tests; expect missing read-only recovery. +- [ ] Query exact known resources/versions where IDs exist; attach observations to the saved operation. A lost clone ID without provider idempotency cannot always be recovered. Keep it unknown; do not adopt a candidate by timestamp/package resemblance or mutate a guess. +- [ ] Require explicit operator-approved reconciliation before another write. Do not automatically rollback after uncertain state, bypass serialization, or replay create/clone on restart. Health and rollback remain separate operations consuming known exact identities. +- [ ] Review any public recovery command separately before adding it; internal support and runbook are required here. Commit `Reconcile uncertain Fastly operations without replay`. + +### Task E7: Review upstream revision and integrate release arguments + +**Trusted Server modify:** root `Cargo.toml`, `Cargo.lock`, CLI `src/run.rs`, `src/commands/mod.rs`. +**Create:** CLI `src/commands/deploy.rs`, `src/release/deployment/transport.rs`. + +- [ ] Land reviewed upstream tests/docs, pin its exact revision and rerun source deploy regression tests. Do not modify the shared Cargo cache or float a branch pin. +- [ ] Write command tests for source deployment preserved, release/passthrough/package/source conflicts, unsupported offline/adapter/variant, failed verification before journal/provider writes and unavailable prebuilt capability. +- [ ] Add wrapper flattening existing DeployArgs plus reviewed release/source/receipt inputs. Source mode delegates as before. Release mode selects a compatible retained VerifiedArtifact, validates local action/context and invokes only the typed prepared path. No fallback to a source build. +- [ ] Keep producer artifact capability and consumer implemented transport intersection: initially production only. Enable fastly-staging only after E8 qualification and reviewed cohort/policy change; old releases do not gain it by downloaded metadata. +- [ ] Run CLI source/release regressions and all AGENTS gates. Commit `Integrate verified release deployment with EdgeZero`. + +### Task E8: Qualify production/staging, recovery and operator guidance + +**Modify:** CLI/release guide, changelog and evidence records; upstream full validation. + +- [ ] Execute fake-provider coverage for all writes, crash boundaries, context mismatches, locks and unknown results; verify no provider writes occur on failed official evidence. +- [ ] Before enabling integrated production transport, execute end-to-end `ts deploy --release` production qualification on a separately authorized disposable Fastly service using an independently verified production-capable release. Assert canonical module bytes, explicit clone/upload to the exact returned draft, context validation before activation, the exact activated version, durable per-write observations and receipt/restart behavior. The primary manual compute-deploy rehearsal is a different transport and cannot satisfy this gate. +- [ ] First qualify direct upstream prepared-byte staging on a separately authorized disposable Fastly service, using source-built fixtures and no claim of an official staging-capable release. Compare actual module/context and shared/isolated bindings, plus separate health/rollback commands. The initial production-only CLI/release MUST continue to reject staging before writes. +- [ ] After that transport proof, approve the cohort and embedded policy capability change, then publish a separately authorized new staging-enabled release through the primary pipeline. Use the independently trusted verifier to qualify end-to-end `ts deploy --release --staging` against that new release. Test that historical production-only releases still reject staging; no downloaded metadata or test policy override enables it in the production CLI. +- [ ] Qualify durable recovery across process restart and uncertain responses without exposing secrets. An irreducibly unknown clone remains a documented manual resolution case, not a passed exactly-once guarantee. +- [ ] Record the already reviewed/qualified staging capability and historical producer/consumer compatibility explicitly. Document receipt limits and the actual loaded snapshot boundary for #161; unsigned operator observations are not publisher authority or remote execution proof. +- [ ] Run upstream tests and all Trusted Server AGENTS gates, independent staff review, then hand off for maintainer deployment approval. Commit `Qualify integrated Fastly artifact deployment and recovery`. + +## Exit criteria + +AC-8 is met only when reviewed upstream code is pinned, production/staging preserve canonical bytes, actual context is validated before finalization, durability precedes every write, exact/unknown outcomes survive failure and live disposable qualification is recorded. No other adapter, publisher claim signer, remote execution attestation or generic deploy framework is implied. diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json new file mode 100644 index 0000000000..68c91723d1 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -0,0 +1,594 @@ +{ + "base": "f9281917a7260b723f5f22175911c6d2147ef650", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "total": 434, + "notes": [ + "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." + ], + "groups": { + "phase-1-contract-and-linked-evidence": { + "commits": [ + "91c190710a821fad73835a5f32c3628ffff20636", + "24a8a7180a32a30e0438d9f5b77697c058c6e9c3", + "484d2c0845be35f006cd8ecafb54dd1fc41e9a9b", + "58d2e7a8b6f81ef4e6b7546321ddc2e51846e415", + "6d0efda7210983021ab3c9efe8a0a3190e9eb81b" + ], + "files": [ + "docs/superpowers/plans/2026-10-13-attested-release-implementation.md", + "docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md", + "docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md", + "docs/superpowers/specs/2026-01-15-attestation-design.md", + "docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md", + "docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md", + "docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md", + "docs/superpowers/specs/schemas/release-index-v1.schema.json", + "scripts/release/js-composition.md", + "scripts/release/qualification/2026-10-15-consumer-real-fixtures.md", + "scripts/release/qualification/2026-10-15-js-two-builds.md", + "scripts/release/qualification/2026-10-16-confined-cargo-candidate.md", + "scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md", + "scripts/release/qualification/2026-10-16-fastly-runtime-gate.md", + "scripts/release/qualification/2026-10-16-linux-cli-candidate.md", + "scripts/release/qualification/2026-10-16-producer-normalized-manifests.md", + "scripts/release/qualification/2026-10-19-apple-sdk-inputs.md", + "scripts/release/qualification/2026-10-20-apple-cli-candidates.md", + "scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md", + "scripts/release/qualification/2026-10-21-candidate-release-index.md", + "scripts/release/qualification/2026-10-21-four-asset-candidate-join.md", + "scripts/release/qualification/2026-10-21-native-reproduction-schedule.md" + ], + "phase": "1", + "state": "Reviewed documentation and historical evidence only" + }, + "phase-2a-strict-documents-policy-preflight-source": { + "commits": [ + "1cfca1357d0eee7158a4e6049444fef46807c564", + "90a87c35ac73c1f1a137740c48df5af41cc7df90", + "917620d2e383471e804d19205f235412ebede83d", + "8a68537344d6c9e1dbd7c8dea3731ea971346376", + "67aaa3fc3d8ba21011472be60e500b884f590742" + ], + "files": [ + "Cargo.lock", + "Cargo.toml", + "crates/trusted-server-release/Cargo.toml", + "crates/trusted-server-release/src/documents.rs", + "crates/trusted-server-release/src/documents/tests.rs", + "crates/trusted-server-release/src/error.rs", + "crates/trusted-server-release/src/git_objects.rs", + "crates/trusted-server-release/src/main.rs", + "crates/trusted-server-release/src/policy.rs", + "crates/trusted-server-release/src/policy/tests.rs", + "crates/trusted-server-release/src/policy/tools.rs", + "crates/trusted-server-release/src/preflight.rs", + "crates/trusted-server-release/src/preflight/child.rs", + "crates/trusted-server-release/src/preflight/tests.rs", + "crates/trusted-server-release/src/source.rs", + "crates/trusted-server-release/src/source/closure.rs", + "crates/trusted-server-release/src/source/config.rs", + "crates/trusted-server-release/src/source/git.rs", + "crates/trusted-server-release/src/source/materialize.rs", + "crates/trusted-server-release/src/source/tests.rs", + "crates/trusted-server-release/src/source/tests/closure.rs", + "crates/trusted-server-release/src/source/tests/layout.rs", + "crates/trusted-server-release/src/source/tests/objects.rs", + "crates/trusted-server-release/src/source/tests/process.rs", + "crates/trusted-server-release/src/source/tests/worktree.rs", + "crates/trusted-server-release/src/source/tree.rs", + "crates/trusted-server-release/src/source/worktree.rs", + "crates/trusted-server-release/src/tool_tree.rs", + "crates/trusted-server-release/src/tool_tree/tests.rs", + "crates/trusted-server-release/tests/commands.rs", + "crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin", + "scripts/release/bootstrap.sh", + "scripts/release/release-cohort.json", + "scripts/release/release-tools.json" + ], + "phase": "2.1", + "state": "Closed; no triggers or production selection" + }, + "phase-2b-process-and-native-sandbox": { + "commits": [ + "f407fe446fcb4daa431bae2ee1834e1ec16664fc", + "39fbf7cf2f6c7c3621e192d1897920250e5f9dab", + "60fafa7175f26685edd88c8336e958e8e709b000", + "d4408d2e015f44bd5e9c0641a9e9f1acc966907f" + ], + "files": [ + "crates/trusted-server-release/src/boundary.rs", + "crates/trusted-server-release/src/boundary/cleanup_and_system_root_tests.rs", + "crates/trusted-server-release/src/boundary/command.rs", + "crates/trusted-server-release/src/boundary/command/selector_tests.rs", + "crates/trusted-server-release/src/boundary/daemon.rs", + "crates/trusted-server-release/src/boundary/docker.rs", + "crates/trusted-server-release/src/boundary/docker/host_path_tests.rs", + "crates/trusted-server-release/src/boundary/entropy.rs", + "crates/trusted-server-release/src/boundary/host_inputs.rs", + "crates/trusted-server-release/src/boundary/macos.rs", + "crates/trusted-server-release/src/boundary/probe.rs", + "crates/trusted-server-release/src/boundary/proof.rs", + "crates/trusted-server-release/src/boundary/roots.rs", + "crates/trusted-server-release/src/boundary/seccomp.rs", + "crates/trusted-server-release/src/boundary/selector_placement_tests.rs", + "crates/trusted-server-release/src/boundary/sibling_tests.rs", + "crates/trusted-server-release/src/boundary/survivors.rs", + "crates/trusted-server-release/src/boundary/tests.rs", + "crates/trusted-server-release/src/boundary/tests/container.rs", + "crates/trusted-server-release/src/boundary/tests/linux.rs", + "crates/trusted-server-release/src/boundary/tests/macos.rs", + "crates/trusted-server-release/src/boundary/tests/modes.rs", + "crates/trusted-server-release/src/boundary/tests/unit.rs", + "crates/trusted-server-release/src/lifecycle_guard.rs", + "crates/trusted-server-release/src/process.rs", + "crates/trusted-server-release/src/process/environment.rs", + "crates/trusted-server-release/src/process/environment/selector_tests.rs", + "crates/trusted-server-release/src/process/pipes.rs", + "crates/trusted-server-release/src/process/redact.rs", + "crates/trusted-server-release/src/process/supervise.rs", + "crates/trusted-server-release/src/process/tests.rs", + "crates/trusted-server-release/src/process/tests/fixture.rs", + "crates/trusted-server-release/src/process/tests/outcomes.rs", + "crates/trusted-server-release/src/process/tests/redaction.rs", + "crates/trusted-server-release/src/process/tests/supervision.rs", + "crates/trusted-server-release/src/process/tests/variables.rs", + "crates/trusted-server-release/tests/boundary.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock", + "crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/control.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs", + "crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs", + "scripts/release/qualification/SECCOMP-LICENSE", + "scripts/release/qualification/seccomp-default.json" + ], + "phase": "2.2", + "state": "Closed; no triggers or production selection" + }, + "phase-2c-immutable-build-facts": { + "commits": [ + "bfb697404551a28217af8906024506095bcd9559", + "6a07ccc03278af1a3e07b30e25a1cd46efb2f57b" + ], + "files": [ + "crates/trusted-server-adapter-fastly/build.rs", + "crates/trusted-server-adapter-fastly/src/main.rs", + "crates/trusted-server-cli/build.rs", + "crates/trusted-server-cli/src/prebid_bundle.rs", + "crates/trusted-server-cli/tests/release_build_facts.rs", + "crates/trusted-server-core/build.rs", + "crates/trusted-server-core/build_support/git_version.rs", + "crates/trusted-server-core/src/build_info.rs", + "crates/trusted-server-core/src/constants.rs", + "crates/trusted-server-core/src/lib.rs", + "crates/trusted-server-core/tests/git_version_resolve.rs", + "crates/trusted-server-integration-tests/browser/helpers/infra.ts", + "crates/trusted-server-integration-tests/tests/common/runtime.rs", + "crates/trusted-server-integration-tests/tests/frameworks/mod.rs", + "crates/trusted-server-integration-tests/tests/parity.rs", + "docs/superpowers/specs/2026-09-25-git-version-header-design.md" + ], + "phase": "2.3", + "state": "Closed; no triggers or production selection" + }, + "phase-2d-js-inventory-composition-materials-two-builds": { + "commits": [ + "e1606fa3da0af4deb4d75b26a11d6677704d747c", + "eeb68f9188b4609bd3b8f7890a6fe51cbc26a57d", + "2edd46ce63b5684a5a0fce976a60bb9794e1c6cf", + "bf3057d60f99a92693c0836305129ba68962afd5", + "0c67be66ee57584c9107f3cb6ea796c77d8a20cb" + ], + "files": [ + "crates/trusted-server-js/Cargo.toml", + "crates/trusted-server-js/README.md", + "crates/trusted-server-js/build.rs", + "crates/trusted-server-js/build/bundle_set.rs", + "crates/trusted-server-js/build/inventory.rs", + "crates/trusted-server-js/build/prebuilt.rs", + "crates/trusted-server-js/lib/.gitignore", + "crates/trusted-server-js/lib/.prettierignore", + "crates/trusted-server-js/lib/build-all.mjs", + "crates/trusted-server-js/lib/build-module-options.mjs", + "crates/trusted-server-js/lib/eslint.config.js", + "crates/trusted-server-js/lib/package.json", + "crates/trusted-server-js/lib/release-composition.mjs", + "crates/trusted-server-js/lib/test/observe-npm.test.mjs", + "crates/trusted-server-js/lib/test/release-composition.test.mjs", + "crates/trusted-server-js/lib/vitest.config.ts", + "crates/trusted-server-js/tests/release_prebuilt.rs", + "crates/trusted-server-release/src/javascript.rs", + "crates/trusted-server-release/src/javascript/contributions.rs", + "crates/trusted-server-release/src/javascript/contributions/tests.rs", + "crates/trusted-server-release/src/javascript/tests.rs", + "crates/trusted-server-release/src/javascript/tests/adopt.rs", + "crates/trusted-server-release/src/materials.rs", + "crates/trusted-server-release/src/materials/archive.rs", + "crates/trusted-server-release/src/materials/cargo_vendor.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/differential.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/index.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/normalize.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/source.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/normalize.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs", + "crates/trusted-server-release/src/materials/cargo_vendor/writer.rs", + "crates/trusted-server-release/src/materials/extract.rs", + "crates/trusted-server-release/src/materials/extract/compatibility.rs", + "crates/trusted-server-release/src/materials/extract/tests.rs", + "crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs", + "crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs", + "crates/trusted-server-release/src/materials/git.rs", + "crates/trusted-server-release/src/materials/git/input.rs", + "crates/trusted-server-release/src/materials/git/tests.rs", + "crates/trusted-server-release/src/materials/locked.rs", + "crates/trusted-server-release/src/materials/manifest.rs", + "crates/trusted-server-release/src/materials/npm_bundle.rs", + "crates/trusted-server-release/src/materials/tests.rs", + "crates/trusted-server-release/src/preparation.rs", + "crates/trusted-server-release/src/preparation/tests.rs", + "crates/trusted-server-release/src/preparation/tests/full.rs", + "scripts/release/build-js.sh", + "scripts/release/collect-js.mjs", + "scripts/release/observe-npm.mjs", + "scripts/release/tests/js-fixture.Dockerfile", + "scripts/release/tests/prepare-js-fixture.mjs" + ], + "phase": "2.4", + "state": "Closed; no triggers or production selection" + }, + "phase-2e-recipes-inspection-manual-package": { + "commits": [ + "a7ae687547409540ed748fc89024b2c9dd84a5b0", + "03fa8e5a622fc7cf86d81195ccdd2f40df50ccfc", + "d472aaa50fb3d532ca997c6822a8c0b8907386de", + "4cc5700e834c3238f99a81b3a567edb634fecfe1", + "10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807", + "c78185b8d699f56d843a01c8812890f38e4a55f6", + "58fa27d3e8b45987fe51d36a80a7d6db74b3e072", + "9999c39584d5b2731bb83a6dae995aad59cc9e38" + ], + "files": [ + "crates/trusted-server-adapter-cloudflare/build.sh", + "crates/trusted-server-release/src/inspect.rs", + "crates/trusted-server-release/src/inspect/elf.rs", + "crates/trusted-server-release/src/inspect/elf/loader.rs", + "crates/trusted-server-release/src/inspect/macho.rs", + "crates/trusted-server-release/src/inspect/macho/signature.rs", + "crates/trusted-server-release/src/inspect/tests.rs", + "crates/trusted-server-release/src/inspect/tests/builders.rs", + "crates/trusted-server-release/src/inspect/tests/builders/macho_data.rs", + "crates/trusted-server-release/src/inspect/tests/elf.rs", + "crates/trusted-server-release/src/inspect/tests/macho.rs", + "crates/trusted-server-release/src/inspect/tests/wasm.rs", + "crates/trusted-server-release/src/inspect/wasm.rs", + "crates/trusted-server-release/src/package.rs", + "crates/trusted-server-release/src/package/tests.rs", + "crates/trusted-server-release/src/recipe.rs", + "crates/trusted-server-release/src/recipe/ambient.rs", + "crates/trusted-server-release/src/recipe/apple.rs", + "crates/trusted-server-release/src/recipe/apple/sdk.rs", + "crates/trusted-server-release/src/recipe/apple/toolchain.rs", + "crates/trusted-server-release/src/recipe/apple/toolchain/tests.rs", + "crates/trusted-server-release/src/recipe/artifact.rs", + "crates/trusted-server-release/src/recipe/artifact/tests.rs", + "crates/trusted-server-release/src/recipe/cargo_home.rs", + "crates/trusted-server-release/src/recipe/cargo_home/tests.rs", + "crates/trusted-server-release/src/recipe/cargo_output.rs", + "crates/trusted-server-release/src/recipe/cargo_output/tests.rs", + "crates/trusted-server-release/src/recipe/compare.rs", + "crates/trusted-server-release/src/recipe/completed.rs", + "crates/trusted-server-release/src/recipe/execute.rs", + "crates/trusted-server-release/src/recipe/fastly_tools.rs", + "crates/trusted-server-release/src/recipe/final_paths.rs", + "crates/trusted-server-release/src/recipe/fresh.rs", + "crates/trusted-server-release/src/recipe/gnu.rs", + "crates/trusted-server-release/src/recipe/gnu/image.rs", + "crates/trusted-server-release/src/recipe/gnu/tests.rs", + "crates/trusted-server-release/src/recipe/identity.rs", + "crates/trusted-server-release/src/recipe/identity/tests.rs", + "crates/trusted-server-release/src/recipe/native.rs", + "crates/trusted-server-release/src/recipe/paths.rs", + "crates/trusted-server-release/src/recipe/paths/fake.rs", + "crates/trusted-server-release/src/recipe/plan.rs", + "crates/trusted-server-release/src/recipe/remap.rs", + "crates/trusted-server-release/src/recipe/runtime.rs", + "crates/trusted-server-release/src/recipe/runtime/config.rs", + "crates/trusted-server-release/src/recipe/runtime/exchange.rs", + "crates/trusted-server-release/src/recipe/runtime/export.rs", + "crates/trusted-server-release/src/recipe/runtime/export/tests.rs", + "crates/trusted-server-release/src/recipe/runtime/logs.rs", + "crates/trusted-server-release/src/recipe/runtime/viceroy.rs", + "crates/trusted-server-release/src/recipe/sources.rs", + "crates/trusted-server-release/src/recipe/startup.rs", + "crates/trusted-server-release/src/recipe/startup/accepted.rs", + "crates/trusted-server-release/src/recipe/startup/accepted/tests.rs", + "crates/trusted-server-release/src/recipe/startup/lifecycle.rs", + "crates/trusted-server-release/src/recipe/startup/native.rs", + "crates/trusted-server-release/src/recipe/tests.rs", + "crates/trusted-server-release/src/recipe/tests/ambient.rs", + "crates/trusted-server-release/src/recipe/tests/bind.rs", + "crates/trusted-server-release/src/recipe/tests/candidate.rs", + "crates/trusted-server-release/src/recipe/tests/candidate_entry.rs", + "crates/trusted-server-release/src/recipe/tests/candidate_index.rs", + "crates/trusted-server-release/src/recipe/tests/cli.rs", + "crates/trusted-server-release/src/recipe/tests/cli_candidate.rs", + "crates/trusted-server-release/src/recipe/tests/compare.rs", + "crates/trusted-server-release/src/recipe/tests/complete.rs", + "crates/trusted-server-release/src/recipe/tests/complete_darwin.rs", + "crates/trusted-server-release/src/recipe/tests/darwin.rs", + "crates/trusted-server-release/src/recipe/tests/darwin_toolchain.rs", + "crates/trusted-server-release/src/recipe/tests/fixture.rs", + "crates/trusted-server-release/src/recipe/tests/full.rs", + "crates/trusted-server-release/src/recipe/tests/full_macos.rs", + "crates/trusted-server-release/src/recipe/tests/macos_candidate.rs", + "crates/trusted-server-release/src/recipe/tests/macos_cli.rs", + "crates/trusted-server-release/src/recipe/tests/native.rs", + "crates/trusted-server-release/src/recipe/tests/plan.rs", + "crates/trusted-server-release/src/recipe/tests/real_fixture.rs", + "crates/trusted-server-release/src/recipe/tests/remap.rs", + "crates/trusted-server-release/src/recipe/tests/runtime.rs", + "crates/trusted-server-release/src/recipe/tests/sources.rs", + "crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs", + "crates/trusted-server-release/src/recipe/tests/tools.rs", + "crates/trusted-server-release/src/recipe/tools.rs", + "scripts/release/build.sh", + "scripts/release/tests/cargo-fixture.Dockerfile", + "scripts/release/tests/cli-builder.Dockerfile", + "scripts/release/tests/prepare-cargo-fixture.mjs", + "scripts/release/tests/prepare-cli-fixture.mjs" + ], + "phase": "2.5", + "state": "Closed; no triggers or production selection" + }, + "phase-2f-composition-spdx": { + "commits": [ + "4176b81f2056f8ff639da48875bb4f9876a82582", + "6bc3dcf678c87ee3d3dc48090b34c32c81b72988", + "775696b28a45e4349ffed970c6d31452219cf0e1", + "1719cf15fd58fcf029f6b4ae5003215802bef405", + "416b43f43c5d7e69c08bca32753240abded8523c", + "5a98c18ada25863a896b93f379125e51497e83f8" + ], + "files": [ + "crates/trusted-server-release/schemas/SPDX-LICENSE", + "crates/trusted-server-release/schemas/spdx-2.3.schema.json", + "crates/trusted-server-release/src/composition.rs", + "crates/trusted-server-release/src/composition/capture.rs", + "crates/trusted-server-release/src/composition/external.rs", + "crates/trusted-server-release/src/composition/external/tests.rs", + "crates/trusted-server-release/src/composition/graph.rs", + "crates/trusted-server-release/src/composition/graph/tests.rs", + "crates/trusted-server-release/src/composition/javascript.rs", + "crates/trusted-server-release/src/composition/javascript/tests.rs", + "crates/trusted-server-release/src/composition/native.rs", + "crates/trusted-server-release/src/composition/native/tests.rs", + "crates/trusted-server-release/src/composition/origin.rs", + "crates/trusted-server-release/src/composition/origin/tests.rs", + "crates/trusted-server-release/src/composition/platform.rs", + "crates/trusted-server-release/src/composition/platform/tests.rs", + "crates/trusted-server-release/src/composition/tests.rs", + "crates/trusted-server-release/src/spdx.rs", + "crates/trusted-server-release/src/spdx/candidate.rs", + "crates/trusted-server-release/src/spdx/candidate/tests.rs", + "crates/trusted-server-release/src/spdx/cargo.rs", + "crates/trusted-server-release/src/spdx/cargo/tests.rs", + "crates/trusted-server-release/src/spdx/javascript.rs", + "crates/trusted-server-release/src/spdx/javascript/tests.rs", + "crates/trusted-server-release/src/spdx/native.rs", + "crates/trusted-server-release/src/spdx/native/tests.rs", + "crates/trusted-server-release/src/spdx/platform.rs", + "crates/trusted-server-release/src/spdx/platform/tests.rs", + "crates/trusted-server-release/src/spdx/tests.rs", + "crates/trusted-server-release/tests/fixtures/composition/README.md", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-host-metadata.json", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-messages.jsonl", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-target-filtered-metadata.json", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-cross-tree.txt", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-messages.jsonl", + "crates/trusted-server-release/tests/fixtures/composition/cargo-1.95-metadata.json" + ], + "phase": "2.6", + "state": "Closed; no triggers or production selection" + }, + "phase-2g-collection-workflow-jobs-candidate-index": { + "commits": [ + "6c065adf29b5be142d5c448c430259ab28987a55", + "0f43d4a81f9ea1339a254a4020d302f2eb7012ce", + "6ffebbd0872d9a8d52d0525dcb2ee55959a710fc", + "060172fb62856ea2b94ead6c196a21bb5e75ac1d", + "99a8c5803e22a717afd03a86b367d816bd1302f9" + ], + "files": [ + "crates/trusted-server-release/src/collection.rs", + "crates/trusted-server-release/src/collection/archive.rs", + "crates/trusted-server-release/src/collection/producer.rs", + "crates/trusted-server-release/src/collection/producer/strict_record.rs", + "crates/trusted-server-release/src/collection/producer/strict_record/tests.rs", + "crates/trusted-server-release/src/collection/records.rs", + "crates/trusted-server-release/src/collection/records/receipt.rs", + "crates/trusted-server-release/src/collection/tests.rs", + "crates/trusted-server-release/src/collection/transfer.rs", + "crates/trusted-server-release/src/collection/transfer/ordinary.rs", + "crates/trusted-server-release/src/collection/transfer/tests.rs", + "crates/trusted-server-release/src/collection/workflow_jobs.rs", + "crates/trusted-server-release/src/collection/workflow_jobs/tests.rs", + "crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs", + "crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs", + "crates/trusted-server-release/src/distribution.rs", + "crates/trusted-server-release/src/distribution/entry.rs", + "crates/trusted-server-release/src/distribution/joins.rs", + "crates/trusted-server-release/src/distribution/ordinary.rs", + "crates/trusted-server-release/src/distribution/summary.rs", + "crates/trusted-server-release/src/distribution/tests.rs", + "crates/trusted-server-release/src/distribution/tests/golden.rs", + "crates/trusted-server-release/src/distribution/tests/summary.rs", + "crates/trusted-server-release/src/distribution/tests/transfer.rs", + "crates/trusted-server-release/src/distribution/transfer.rs", + "crates/trusted-server-release/tests/fixtures/collection/README.md", + "crates/trusted-server-release/tests/fixtures/collection/build-record.json", + "crates/trusted-server-release/tests/fixtures/collection/composition.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json", + "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json" + ], + "phase": "2.7", + "state": "Closed; no triggers or production selection" + }, + "phase-2h-publication-owner": { + "commits": ["9bd8be2c20a2e188fbdddef7a838b63545279e82"], + "files": [ + "crates/trusted-server-release/src/publication.rs", + "crates/trusted-server-release/src/publication/api.rs", + "crates/trusted-server-release/src/publication/incident.rs", + "crates/trusted-server-release/src/publication/model.rs", + "crates/trusted-server-release/src/publication/owner.rs", + "crates/trusted-server-release/src/publication/role.rs", + "crates/trusted-server-release/src/publication/tests.rs", + "crates/trusted-server-release/src/publication/tests/drafts.rs", + "crates/trusted-server-release/src/publication/tests/fake.rs", + "crates/trusted-server-release/src/publication/tests/final_state.rs", + "crates/trusted-server-release/src/publication/tests/inputs.rs", + "crates/trusted-server-release/src/publication/tests/reconciliation.rs" + ], + "phase": "2.8", + "state": "Closed; no triggers or production selection" + }, + "phase-2i-candidate-ci-same-commit-validation": { + "commits": [ + "2b642b5091298b9278f4e64a2342fd57d23d1aae", + "3e3e30996714f3691319e4cb6ca96d4fbb3240e3", + "b4c8e0c0fded27f02930edc6de990f17a63f5820", + "4483e8256dc52c89e615f1388daa873324a871f5", + "073704cc7f65793767b129a5a330447e02926c7b" + ], + "files": [ + ".github/actions/setup-integration-test-env/action.yml", + ".github/workflows/release-validation.mjs", + ".github/workflows/release-validation.test.mjs", + ".github/workflows/release-validation.yml", + ".github/workflows/test.yml", + "crates/trusted-server-release/src/workflow_contract.rs", + "crates/trusted-server-release/src/workflow_contract/tests.rs", + "scripts/release/qualification/approvals.json", + "scripts/release/qualification/hosted-workflow.proposal.yml", + "scripts/release/qualification/results.schema.json", + "scripts/release/tests/candidate-changes.sh", + "scripts/release/tests/candidate-changes.test.mjs" + ], + "phase": "2.9", + "state": "Closed; no triggers or production selection" + }, + "phase-2j-consumer-cli": { + "commits": [ + "7450b37805131d307d402df089d7a585104dd543", + "da3c9ce6682915aef2ae48d02d8d343181e05702", + "4eebe62138606636e095aa975b7dd37a4fa899ca", + "ac07047ed2b4f1bbe0e1f2e4aac9f845e70b7c7c", + "d774ee47c274257fa0a088280f7df37ed530020c" + ], + "files": [ + "crates/trusted-server-cli/Cargo.toml", + "crates/trusted-server-cli/README.md", + "crates/trusted-server-cli/release-policy.json", + "crates/trusted-server-cli/src/commands/dev/lint/domains.rs", + "crates/trusted-server-cli/src/commands/mod.rs", + "crates/trusted-server-cli/src/commands/release.rs", + "crates/trusted-server-cli/src/lib.rs", + "crates/trusted-server-cli/src/release/artifact.rs", + "crates/trusted-server-cli/src/release/bounds.rs", + "crates/trusted-server-cli/src/release/documents.rs", + "crates/trusted-server-cli/src/release/environment.rs", + "crates/trusted-server-cli/src/release/error.rs", + "crates/trusted-server-cli/src/release/evidence.rs", + "crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs", + "crates/trusted-server-cli/src/release/evidence/joins.rs", + "crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs", + "crates/trusted-server-cli/src/release/gh.rs", + "crates/trusted-server-cli/src/release/gh/input.rs", + "crates/trusted-server-cli/src/release/github.rs", + "crates/trusted-server-cli/src/release/mod.rs", + "crates/trusted-server-cli/src/release/offline.rs", + "crates/trusted-server-cli/src/release/online.rs", + "crates/trusted-server-cli/src/release/policy.rs", + "crates/trusted-server-cli/src/release/process.rs", + "crates/trusted-server-cli/src/release/source_policy.rs", + "crates/trusted-server-cli/src/release/tests/documents/mod.rs", + "crates/trusted-server-cli/src/release/tests/documents/producer.rs", + "crates/trusted-server-cli/src/release/tests/gh/argv.rs", + "crates/trusted-server-cli/src/release/tests/gh/environment.rs", + "crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs", + "crates/trusted-server-cli/src/release/tests/gh/mod.rs", + "crates/trusted-server-cli/src/release/tests/mod.rs", + "crates/trusted-server-cli/src/release/tests/offline.rs", + "crates/trusted-server-cli/src/release/tests/online.rs", + "crates/trusted-server-cli/src/release/tests/verification/fixture.rs", + "crates/trusted-server-cli/src/release/tests/verification/mod.rs", + "crates/trusted-server-cli/src/release/tests/verification/publication.rs", + "crates/trusted-server-cli/src/run.rs", + "crates/trusted-server-cli/tests/fixtures/release/release-index.json", + "crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json", + "crates/trusted-server-cli/tests/release_cli.rs" + ], + "phase": "2.10", + "state": "Closed; no triggers or production selection" + }, + "phase-2k-docs-and-format-misc": { + "commits": [ + "8510fde38b494ad7c637f0981772709a7a9b0ad5", + "6a07ccc03278af1a3e07b30e25a1cd46efb2f57b", + "58fa27d3e8b45987fe51d36a80a7d6db74b3e072" + ], + "files": [ + ".github/workflows/format.yml", + "CHANGELOG.md", + "crates/trusted-server-adapter-spin/src/app.rs", + "crates/trusted-server-adapter-spin/src/middleware.rs", + "crates/trusted-server-adapter-spin/tests/routes.rs", + "crates/trusted-server-release/tests/fixtures/README.md", + "docs/.vitepress/config.mts", + "docs/guide/cli.md", + "docs/guide/edgezero.md", + "docs/guide/error-reference.md", + "docs/guide/getting-started.md", + "docs/guide/release-verification.md", + "scripts/docs-proposal/hunks.mjs", + "scripts/docs-proposal/hunks.test.mjs", + "scripts/release/qualification/README.md", + "scripts/release/tests/fixtures/README.md" + ], + "phase": "2.11", + "state": "Closed; no triggers or production selection" + } + } +} diff --git a/docs/superpowers/specs/2026-01-15-attestation-design.md b/docs/superpowers/specs/2026-01-15-attestation-design.md index 87b135bdbc..bec21f80b0 100644 --- a/docs/superpowers/specs/2026-01-15-attestation-design.md +++ b/docs/superpowers/specs/2026-01-15-attestation-design.md @@ -1,6 +1,6 @@ # Runtime Integration Attestation Architecture -> **Status:** Proposal +> **Status:** Historical proposal. Binary-attestation sections are superseded in direction by the [October release artifact specification](2026-10-09-1235-release-attestation-design.md), which becomes the implementation contract upon approval. Runtime/configuration proposals remain reference material for #161. > **Author:** Architecture Team > **Date:** January 2026 @@ -563,7 +563,7 @@ Sigstore uses DSSE envelopes carrying in-toto Statements. The runtime attestatio "attested_by": { "identity": "iab-tech-lab", "oidc_issuer": "https://token.actions.githubusercontent.com", - "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" + "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/v1.5.0" } }, "integrations": [ @@ -1164,6 +1164,12 @@ This section provides detailed code examples and implementation patterns for the ### GitHub Artifact Attestations (CI/CD) +This binary workflow example is historical and superseded by the [October +release contract](2026-10-09-1235-release-attestation-design.md#r-sign). Its +current certificate SAN names `release-build.yml`; the caller `release.yml` +does not satisfy that signer identity. Runtime/configuration and publisher +proposals below remain historical #161 material. + Use [GitHub Artifact Attestations](https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) to create SLSA Build Level 2/3 provenance for the WASM binary. ```yaml diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md new file mode 100644 index 0000000000..725aba2717 --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -0,0 +1,651 @@ +# Release artifact attestation and verified consumption + +## Phasing — 2026-10-22 + +This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. + +**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. + +**Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. + +For requirement headings below, R-CONFIG, R-INPUTS, R-SIGN and R-PUBLISH apply only to the Phase 1 subset described above; all additional clauses and every other R-ID belong to Phase 2+. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. + +**Issue:** [#1235 — Publish attested release binaries with build provenance and SBOMs](https://github.com/IABTechLab/trusted-server/issues/1235) + +**Date:** 2026-10-09. **Review revision:** 2026-10-12. + +**Status:** Draft for maintainer review. As directed on 2026-10-13, this specification, its implementation plans and implementation share PR #1262 on `spec/1235-release-attestation`; no separate implementation PR is opened. Contract/code approval is required before merge and all selected qualification gates remain required before official release enablement. Requirements below describe proposed behavior, not an existing release pipeline. The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) records measured prototypes, missing implementations and unresolved hosted/provider qualifications; document review alone is not feasibility proof. + +**Producer implementation constraint (2026-10-14):** Introduced release producer tooling MUST use the small domain-specific host-only `crates/trusted-server-release` Rust 2024/1.95.0 workspace member, excluded from default members and independent of core/CLI/JS crates. No introduced Python tooling is permitted. Existing Node JS collection and thin POSIX wrappers remain; native approved-source Rust bootstrap is independently authenticated and supplies no official binary proof for itself. `ts` remains the consumer, and the [migration addendum](2026-10-14-release-tooling-rust-migration.md) preserves every qualification, acquisition and evidence gate. The producer is not a fifth canonical asset or a generic attestation/publisher abstraction. + +**Implementation baseline:** `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52` includes #1213 and #1214, both merged on 2026-10-12. Release implementation extends their source-version and isolated JS/prebuilt mechanisms. The feasibility audit used the earlier `7f6056bf83eb96aa4167efca64d43280299fba73` snapshot with then-open PR overlays; those historical probes are not current release qualification. + +**Related work:** [#1213](https://github.com/IABTechLab/trusted-server/pull/1213), [#1214](https://github.com/IABTechLab/trusted-server/pull/1214), [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#1179](https://github.com/IABTechLab/trusted-server/pull/1179), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), [#1084](https://github.com/IABTechLab/trusted-server/pull/1084), and [#167](https://github.com/IABTechLab/trusted-server/pull/167). + +## Problem + +Publishers and vendors need evidence that distributed binaries were built from reviewed source, and operators need a route that submits the verified bytes. Ordinary CI publishes no attested distribution. Source deployment delegates to build-capable EdgeZero/Fastly commands. Fastly build annotation can also change Cargo's wasm output. + +The previous draft coupled four-target reconstruction and all-adapter enablement to the first release. This contract instead selects assurance requirements per artifact before execution. Required evidence remains mandatory; an optional measurement cannot quietly become a claim that it passed. Build provenance, publication, local authorization and remote execution are separate claims. + +## Goals + +1. Publish Fastly wasm and three Linux/macOS operator CLI executables with build provenance, artifact-specific SPDX SBOMs, bundles and immutable release membership. +2. Enforce configuration independence and fresh locked inputs, including one shared, twice-built JS module set. +3. Require complete-byte Fastly A/B equality; report native CLI comparisons without making native reproducibility a first-release prerequisite. +4. Document exact verification and a byte-preserving Fastly production route first, then integrate production/staging through a new EdgeZero API. +5. Provide stable build facts and deployment observations for #161 without claiming publisher authorization or remote execution proof. + +## Non-goals + +Operator reconstruction, retained-input archives, automated offline deployment, Cloudflare/Spin/Axum distributions, standalone tsjs assets, external rebuild witnesses and SLSA Build Level 3 require follow-up specifications. Runtime/configuration attestation, publisher enrollment, variable permission-policy distribution, request signing and vendor endorsements remain with their owners. Windows, Linux ARM64, containers, Apple notarization and self-update are excluded from this distribution. + +Offline build-evidence verification is documented here, but cannot establish fresh official release membership and cannot authorize `ts deploy --release`. The external Prebid bundle remains outside the embedded artifact's SBOM. CLI commands that need source resources retain documented prerequisites. + +## Contract vocabulary and ownership + +MUST, MUST NOT, SHOULD and MAY have the meanings in BCP 14, only when capitalized. Each numbered requirement is normative. Rationale, rollout ordering, informational records and deferred investigations do not add implicit acceptance gates. + +Canonical **Build A** is the selected output proposed for publication. **Build B** is a fresh comparison build: complete-byte equality is mandatory for Fastly and the shared JS set, while native CLI B reports a bounded measurement. + +“Retained” specifies its object: an owned byte copy held by the verifier; JS module bytes observed in the final executable; archived reconstruction inputs (deferred); or durable deployment recovery state. These claims are distinct. + +The **declared insensitive envelope** lists recipe inputs qualified to vary without changing output bytes. A **terminal outcome** records `matched`, `mismatched`, `failed` or `unavailable` before the deadline. An **action-scoped verified value** holds verified bytes and approval for one supported operation and context; it grants no unrelated deployment authority. Requirement headings below provide stable R-ID links; T-1 through T-13 identify the test families in the traceability table. + +| Owner | Responsibility | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release tooling | Controlled builds, composition evidence, required comparison gates, signing and immutable publication | +| Trusted Server verifier | Fixed release policy; distinct checks of build evidence, official publication and consumer/action authorization; construction of an opaque `VerifiedArtifact` retaining the bytes | +| EdgeZero | Prepared payload integrity, resolved provider context, concrete lifecycle operations and durable known/unknown outcomes; no IAB release-policy decisions | +| Consumer | Trusted verifier bootstrap, approved source/tag/action/context and credentials | +| #161 | Publisher authority, loaded configuration claims, signing/freshness and runtime transport | + +```mermaid +flowchart TD + A[Controlled build and composition evidence] --> B[Signing and immutable publication] + B --> C[Trusted Server verifier] + P[Trusted local policy and source approval] --> C + C --> D[Opaque VerifiedArtifact with retained bytes] + D --> E[EdgeZero prepared payload and resolved context] + E --> F[Observed operation state and recovery record] +``` + +## 1. Threat model and claim limits + +| Threat | Capability | Defending checks | Residual trust or limitation | +| ---------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| TH-1 Asset/mirror tampering | Replace executable, index or companions | R-INDEX, R-EVIDENCE, R-PUBLICATION: signed digests, exact subjects and release membership; negative proof families T-8, T-9 | Authentic malicious source is still malicious | +| TH-2 Malicious evidence transfer | Substitute roots, policy, approved commit or offline pins | R-POLICY, R-BOOTSTRAP, R-OFFLINE: obtain trust inputs independently of supplied evidence; negative proof families T-8, T-9, T-10 | A compromised trusted transfer channel defeats its pins | +| TH-3 Compromised dependency/build script | Generate altered code or fetch undeclared inputs | R-INPUTS, R-JS, R-SBOM, R-BUILD: locked materials, controlled environment, observed/declarative coverage, no build credentials; negative proof families T-2, T-3, T-4, T-5 | Locks authenticate origin, not safety; same malicious input can reproduce | +| TH-4 Rogue tag pusher | Tag unapproved source or bypass checks | R-SOURCE, R-PUBLISH: branch/source authorization, same-commit validation, restricted tag authority; negative proof families T-7 | Authorized maintainers and bypass actors remain trusted | +| TH-5 Compromised runner image/compiler | Produce altered bytes and consistent A/B results | R-BUILD, R-SIGN: checked environment identities, hosted runners, separated signing permissions; negative proof families T-4, T-7 | A/B is project-controlled repeatability, not independent builder trust | +| TH-6 Compromised operator host | Replace gh/ts, retained files or runtime; lie about deployment | R-BOOTSTRAP, R-DEPLOY, R-RECOVERY: trusted bootstrap, retained handles, pre-upload equality, recorded observations; negative proof families T-9, T-11, T-12 | Local checks cannot establish honest remote execution against this host | +| TH-7 GitHub compromise | Misstate workflow identity, source or immutable membership | R-POLICY, R-EVIDENCE, R-PUBLICATION: constrained identities and Sigstore evidence; negative proof families T-7, T-8, T-9 | GitHub/OIDC, applicable roots and release authority are trust roots; this design does not defeat their compromise | + +Build provenance establishes that the constrained workflow produced a digest. Immutable release evidence establishes publication membership. A/B records measured equality under declared conditions. None proves absence of vulnerabilities, truthful configuration claims, publisher/domain authority or that submitted bytes execute at every edge location. SLSA Build Level 2 is an implementation target; a local reusable workflow alone does not establish Level 3. + +## 2. Selected artifacts and assurance profiles + +### R-COHORT + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +`scripts/release/release-cohort.json` MUST select the complete artifact set and required assurance profile before jobs execute. The first cohort is `phase1-fastly-cli`. Every selected artifact MUST have successful canonical Build A, build provenance, SBOM, composition validation, shared JS input equality, artifact-specific retained-JS coverage and immutable publication. A failure of any selected requirement MUST block publication; the workflow MUST NOT shrink the set or lower a profile after failure. + +| Asset | Package / target | Required profile | Compatibility qualification | +| ----------------------------- | ------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | +| `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly` / `wasm32-wasip1` | `provenance-sbom-repeatable` | Fastly production defaults; variant `default` | +| `ts-x86_64-unknown-linux-gnu` | `trusted-server-cli` / `x86_64-unknown-linux-gnu` | `provenance-sbom` | glibc floor 2.34; clean UBI 9 (RHEL 9 family), Ubuntu 22.04 and Debian 12 startup fixtures | +| `ts-aarch64-apple-darwin` | `trusted-server-cli` / `aarch64-apple-darwin` | `provenance-sbom` | macOS loader deployment target 13.0; initial release qualification on hosted macOS 15 only | +| `ts-x86_64-apple-darwin` | `trusted-server-cli` / `x86_64-apple-darwin` | `provenance-sbom` | Same loader floor and macOS 15 qualification; actual Intel startup fixture required | + +### R-PLATFORM + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +Recipes MUST use explicit packages/targets and recorded effective features/release profile. A pinned Linux builder with glibc no newer than 2.34 MUST establish the GNU compatibility floor; scheduling it on Ubuntu 24.04 does not establish a 2.39 floor. Native link inspection MUST validate the actual ELF GLIBC symbol ceiling and reject undeclared libraries and runner-only absolute paths. + +GNU recipe completion MUST derive its forbidden host and execution paths from the bound checkout, retained JS, materials, Rust tools, whole Cargo state and target roots, plus the host-only output root. The whole Cargo state includes `HOME`, `TMPDIR` and `CARGO_HOME`. Completion MUST inspect and scan the same bounded, nofollow-read bytes, verify their recorded size and SHA-256, reject those paths and `/boundary/`, and require a fixed remapped prefix before recording success. A standalone ELF inspection records structural facts; it does not establish this build-path property. + +The GNU build boundary MUST expose ordinary writable storage only through its checked roots: `/tmp` MUST be an empty read-only tmpfs, private IPC MUST omit `/dev/shm`, and selected images MUST declare no implicit writable volumes. Actual controls MUST demonstrate denied writes outside the roots and successful bound-`TMPDIR` write/read/removal, followed by full JS and Cargo compatibility runs. Generic `/tmp/` runtime lookup strings, such as the locked browser dependency's default search location, MUST NOT alone be classified as runner identity. This Linux refinement does not relax macOS inspection or qualify its filesystem boundary. + +macOS MUST use a recorded matching-host recipe initially; a reviewed ARM-to-Intel cross recipe MAY replace it only after full CLI build and Intel execution qualification. `MACOSX_DEPLOYMENT_TARGET=13.0` MUST be explicit. It is a loader/ABI floor, not a claim that macOS 13/14 execution is qualified. + +The initial required startup matrix is macOS 15 on ARM64 and Intel. macOS 13 hosted runners have retired and macOS 14 is retiring; neither is a first-release test gate. Broader runtime support requires separately available execution hosts and evidence. Schema `compatibility.minimum_version` records the loader/libc floor; `build-evidence.compatibility_tests` records the actually qualified OS, architecture and outcome. Intel execution remains mandatory on an Intel host. `macos-15-intel` availability MUST NOT be assumed indefinitely: qualify a replacement execution host or review removal of the target before that runner becomes unavailable. Failure to establish these baselines blocks this cohort before its first tag; it does not justify advertising untested compatibility. A static musl distribution is an alternative requiring a reviewed cohort/target change. + +### R-VARIANT + +**Phase:** 2+. + +The first Fastly artifact MUST retain the existing default, single-request production variant, with `reusable-sandbox` disabled. This is a deliberate conservative shipping choice pending deployed reuse qualification, not a claim that reuse is publisher configuration. There is no Fastly `test` feature. A second attested `reusable-sandbox` variant, or promotion to the default after the four runtime bounds and reuse lifecycle pass qualification, requires a reviewed cohort/policy change. Operators requesting reuse MUST receive an explicit unsupported-variant diagnostic rather than be told an unattested custom build is official. + +Native CLI A/B outcome is reported as `matched`, `mismatched`, `failed` or `unavailable`. Its measurement job MUST reach a bounded terminal outcome, but equality is not required by `provenance-sbom`. A later change can promote an individual target after qualification. No profile in this contract claims separately bootstrapped operator reconstruction. + +## 3. Configuration independence and complete build inputs + +### R-CONFIG + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +Operator configuration MUST NOT be compiled into release binaries. Publisher domains, account/service IDs, physical bindings, active settings, secrets, selected integrations, configuration revisions and deployment-variable permission policy MUST enter at runtime. All artifacts MUST use the tracked repository `edgezero.toml` unchanged. Logical store identifiers, product defaults and the fictional CLI scaffold are source-defined product inputs shared by all operators. The scaffold MUST NOT be an active-config fallback. The permission work in #1045/#1084 MUST resolve any conflicting embedded variable-policy design before release enablement; this contract does not choose its distribution protocol. + +### R-INPUTS + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +Build evidence MUST record every input class below, with selected identities/digests and roles. Introducing an input requires updating the inventory in the same implementation PR. Generic SLSA provenance is not assumed to enumerate these transitively. + +| Input | Scope and control | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Git source snapshot | Exact peeled tag commit and tracked bytes of workspace/crate manifests, Rust/TS/JS source, embedded assets, build scripts, and release scripts | +| Cargo resolution | Root `Cargo.lock`, registry checksums, exact git revisions, resolved package/features, target and host dependency roles | +| npm resolution | JS `package.json` and `package-lock.json`, pinned Node and bundled npm version, installed package versions and integrity metadata | +| Toolchain configuration | `rust-toolchain.toml`, `.tool-versions`, `.cargo/config.toml`, Rust compiler/LLVM/sysroot identity, Node/npm identity | +| Build recipe | Package and binary target, target triple, explicit feature policy, release profile including current `debug = 1`, linker/native compiler settings and any byte transformations | +| Repository EdgeZero manifest | Entire tracked `edgezero.toml`, default logical configuration ID derived by core, and the manifest digest | +| Core template build digest | All non-editor regular files under `core/src`, core `build.rs`/`Cargo.toml`, and root `Cargo.toml`/`Cargo.lock`/`edgezero.toml`; normalized relative paths and original bytes | +| Embedded/generated JS | Fresh core and discovered integration IIFEs, exact module IDs/digests, and the generated Rust module table copied into `OUT_DIR` | +| Other embedded assets | Core GPT bootstrap, CLI browser collector/consent scripts, and the repository-root `trusted-server.example.toml` scaffold embedded by the CLI | +| OpenRTB bindings | Checked-in generated bindings and their tracked sources; normal releases do not regenerate protobuf or invoke an ambient `protoc` | +| Dependency-generated code | Build-script and proc-macro outputs, including EdgeZero CLI adapter registrations derived from its pinned manifest and enabled features | +| Release build facts | Validated source commit supplied after environment sanitization; target/package version from Cargo; variant derived by the owning adapter/product; release tag stays external | +| Native build inputs | Runner image version, SDK/Xcode where applicable, linker, compiler/assembler, build tools, vendored native library metadata, and linked external libraries | +| Paths and environment | Checkout/build paths, `CARGO_MANIFEST_DIR`, `OUT_DIR`, sanitized compiler/native-tool settings, explicit Vite mode, and permitted release environment values | + +`TEMPLATE_BUILD_DIGEST` includes complete raw manifest bytes and ordinary non-editor core source files, including untracked files. It is a template/cache compatibility input, not an executable digest or provenance. Fresh checkouts MUST contain only expected source/materials; generated outputs MUST remain outside source-only directories. Before/after checks MUST detect tracked-input changes and unexpected source-directory files. + +### R-ENV + +**Phase:** 2+. + +Release preflight MUST audit inherited variables before clearing them and reject `TSJS_SKIP_BUILD` with any value. It MUST NOT evade this audit by starting under an empty environment. Hosted/setup exports such as `RUSTFLAGS` or `VCPKG_INSTALLATION_ROOT` MUST be rejected unless their exact values and tool/path identities are explicitly reviewed and qualified. + +After auditing, the recipe MUST remove inherited operator `TRUSTED_SERVER__*` and `EDGEZERO__*`, sanitize the child environment, and reinject validated `TRUSTED_SERVER__RELEASE_BUILD=1` and the approved `TRUSTED_SERVER__GIT_VERSION`. Cargo supplies target/package facts and the owning product derives the typed variant (R-FACTS). Compiler/Cargo profile/linker flags, wrappers, Cargo home/configuration layers, native build selectors, `NODE_OPTIONS`, npm/Vite overrides and environment-file loading MUST be selected or rejected by the recipe. No ambient compiler wrapper is allowed. Release Vite mode MUST be explicit. Build jobs MUST have no provider/signing credentials. Wall-clock times, run IDs, publisher identity and final checksum MUST NOT be embedded. `SOURCE_DATE_EPOCH` MUST derive from the approved commit's committer timestamp. + +The CLI's compiled developer checkout fallback (`CARGO_MANIFEST_DIR`) MUST be disabled for official releases; explicit/current-checkout discovery can remain for documented source-dependent commands. Rust remapping does not rewrite arbitrary `env!` strings. CLI help, version and `config init` MUST run outside a checkout. + +## 4. Shared JS build and artifact repeatability + +### R-JS + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +Release work MUST extend #1214's private output, expected-module validation, checked test failures and `TSJS_PREBUILT_DIR`; it MUST NOT add a parallel `TSJS_RELEASE_BUILD` fallback mode. One release-wide JS job MUST independently run two fresh locked installations/builds with pinned Node/npm. Both MUST reject any skip flag, missing npm, failed `npm ci`, test failure, missing/empty/extra modules or changed source. Both actual A/B Vitest processes MUST unconditionally receive `TRUSTED_SERVER__RELEASE_JS_TESTS=1` and run all registered tests and type checks; a developer opt-out MUST NOT suppress them. Ordinary development skips only the two slow Git collector fixtures by default; ordinary CI opts into the full suite. Each build owns its installation/output directory and includes required build dependencies. + +The two JS runs MUST compare final bytes for core plus every sorted tracked integration entry point. Only on equality MAY the job publish its Build A bundles and `tsjs-inventory.json`. That version-1 inventory MUST record sorted module IDs, filename, size, SHA-256, contributing source/package identities and tool/lifecycle identities. Each Rust build MUST consume these current-run bundles through absolute `TSJS_PREBUILT_DIR` and `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths and a checked expected inventory, validate the exact set/digests of bytes copied into `OUT_DIR`, and run no npm. + +The first cohort MUST declare only `fastly-production` for Fastly until the integrated staging milestone is qualified. Adding `fastly-staging` requires a reviewed cohort and consumer-policy update. An artifact capability MUST intersect with an implemented local transport; downloaded metadata cannot enable an unavailable operation. + +The collector MUST verify identical prepared JS input inventories across all builds that compile the JS crate. Each artifact MUST separately record which module bytes remain in its final file. Claimed retained modules MUST match the shared inventory; a CLI executable MAY retain none after linking. Copies into `OUT_DIR` are build inputs and MUST NOT alone be called final embedded code, nor force unused JS into the CLI to satisfy an equality check. + +The shared JS set is a declared input to Rust builds. Sharing it means the Rust A/B pair does not independently test npm/Vite; the separate JS A/B gate supplies that evidence. Installed package inventory and permitted lifecycle outputs MUST be recorded separately from lockfile archive integrity. A platform esbuild binary removed from the prepared installation MUST cause the network-denied bundle phase to fail instead of downloading a replacement. + +### R-BUILD + +**Phase:** 2+. + +Container-backed builds and final-artifact startup MUST acknowledge a full immutable container ID before a separate attached start. Cleanup MUST stop, remove and confirm absence by that ID after execution failures as well as success. Failed or ambiguous creation MUST remain fatal and MUST NOT authorize a start; a name-based absence observation cannot resolve an in-flight creation request. The bounded timeout regression MUST exercise the same lifecycle as real execution. + +`scripts/release/build.sh` and `scripts/release/release-tools.json` at the approved source commit MUST define the executable recipes and checked tool/environment identities. A downloaded descriptor is informational and MUST NOT supply commands. Rust builds MUST use `--locked` (or `--frozen` after verified source preparation), a fresh target directory and checked dependency material. Compiled Cargo/JS outputs MUST NOT be restored from earlier runs. Verified download caches MAY be reused only after archive checksum/revision validation and fresh extraction. The compile/bundle phases MUST enforce network denial with a qualified OS/container boundary, including build-script/lifecycle downloads; a positive unconfined socket control and denied confined socket probe MUST run for each selected recipe. Cargo/npm offline flags, proxy settings or lockfiles alone do not enforce process network denial. Historical local macOS `sandbox-exec` prototypes demonstrated socket denial; replacement Rust boundaries and the full hosted native recipe still require qualification; preparation is a separate network-enabled phase. Required native/system SDK identities and code transformations MUST be checked before compilation. + +### R-REPEAT + +**Phase:** 2+. + +Fastly Build A and Build B MUST run in separate fresh hosted jobs with independent source/dependency materialization and Cargo outputs. Each MUST include every final byte transformation from the recipe. Build B MUST differ from A in each qualified insensitive input: checkout directory, Unix user where supported, umask, `TZ` and locale, start time and Cargo job count. `release-tools.json` MUST list the selected A and B values. The rebuild record MUST mark each dimension `varied`, `normalized` when the recipe pins it internally, or `not-varied` when the runner cannot change it. Only `varied` dimensions count as independence evidence. + +The collector MUST select outputs by the artifact ID and transfer digest returned by each expected job in the current run, verify artifact REST metadata's current workflow run/repository/source SHA, reject reused A/B IDs, validate source/recipe records, and byte-compare complete final wasm. Producing job/attempt association MUST come from the expected upload-step outputs and trusted workflow dependency topology; the artifact API does not expose an independent job/attempt-origin field. Transfer digest is the Actions archive digest, separately checked from final executable SHA-256; transfer mismatch warnings MUST be promoted to failure. It MUST NOT use artifact names as the sole binding. Missing output, inconsistent checked inputs or unequal bytes MUST block the entire selected release. No comparison-time stripping, normalization or excluded byte regions is allowed. + +Native CLI comparison MUST use the same fresh-job/result-binding discipline, but a recorded nonmatching or unavailable B result does not block its profile. Build A and its composition/smoke checks remain mandatory. A signed measurement stating failure MUST NOT be relabeled success. + +### R-NATIVE + +**Phase:** 2+. + +Native records MUST capture compiler, linker, archiver, SDK/sysroot, native source/libraries and relevant options. Linux SHOULD use a content-derived build ID. A future native repeatability-profile qualification MUST cover canonical rustup paths, OSO debug paths/mtimes, a tested content-derived UUID policy and any required ad-hoc signature. Random/disabled UUIDs or removing signatures only for comparison are not acceptable shortcuts. Developer ID signing, notarization and stapling remain outside scope. These controls support native measurements; they do not imply operator reconstruction is supported. + +## 5. Artifact-specific SPDX composition + +### R-SBOM + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +The repository-owned Rust producer command `trusted-server-release generate-sbom`, implemented in `crates/trusted-server-release/src/spdx.rs`, MUST emit SPDX 2.3 JSON for each canonical executable from selected Cargo JSON build messages, an unfiltered Cargo metadata identity catalog and explicit-target Cargo tree evidence, the JS lockfile and Rollup contribution inventory, plus declared-and-validated native inputs. Target-filtered metadata can omit dependencies compiled for the host during a cross-build; catalog membership alone MUST NOT establish contribution. Syft and a `RUSTC_WRAPPER` are not required. This settles the generator; its fixture proof precedes release enablement. + +Coverage MUST distinguish observed Rust units, embedded JS contributors, validated native components, external runtime requirements and build materials/tools. With explicit `--target`, Cargo output layout, target kind, package ID and features MUST distinguish host build scripts/proc macros from target units, including a package built in both contexts. Metadata/tree edges MUST be reconciled with actual compiled membership; ambiguous source/unit joins MUST fail. Cargo metadata features are package-level unions, so effective per-unit features MUST come from compiler messages and explicit-target output context. This is a conservative component inventory, not a promise to recover an exact compiler unit dependency DAG from package metadata. Uncompiled dev-only packages MUST NOT be claimed as runtime contributors. Compiled code later eliminated by linking MAY remain in the conservative inventory; exact final function reachability is not claimed. + +The Rollup hook MUST record package/source contributions and virtual/plugin-generated origins. Final bundle hashes MUST be computed after Vite/esbuild transformations. Type-only imports and modules eliminated by Rollup MUST NOT create runtime components. Later minification can remove more code; contribution evidence MUST state this conservative limit. The embedded Prebid shim is covered; a type-only reference to externally distributed `prebid.js` MUST NOT claim that bundle is embedded. Build-only npm dependencies and native bundler tools remain build material even when called dev dependencies. + +Native records MUST bind origin, version/revision, source or linked-library digest, selection policy and validation evidence. The current CLI graph includes AWS-LC, ring and bundled SQLite. The release recipe MUST set `AWS_LC_SYS_USE_SYSTEM=0` and reject conflicting OpenSSL/pkg-config discovery overrides, or explicitly select and digest a reviewed system library. Target-suffixed AWS-LC selectors MUST also be rejected or explicitly selected; a target-specific value can override generic `AWS_LC_SYS_USE_SYSTEM=0`. The recipe MUST set `LIBSQLITE3_SYS_USE_PKG_CONFIG=0` and reject unreviewed SQLCipher/loadable-extension selection; a nonzero pkg-config selector can bypass SQLite's bundled feature. It MUST validate SQLite bundled selection and capture actual native outputs/link directives; a Rust invocation graph alone cannot discover these components. Dynamically linked platform libraries MUST be reported as external requirements, not embedded bytes. Rust sysroot identity MUST also be recorded. + +SPDX root checksum MUST equal the final executable SHA-256. Package IDs MUST include source/revision or installation location where name/version is ambiguous. SPDX relationships MUST distinguish artifact contributors from build tools/materials. Modules compiled as inputs but absent from the final executable MUST remain build material rather than be claimed as embedded JS. Raw substring presence and known source/link behavior supply a conservative retained-byte check for the current uncompressed strings; they do not establish function reachability. Cargo manifests/license files and npm metadata supply licenses; unknown licenses MUST be `NOASSERTION`. Schema, unique IDs, endpoints, coverage and the attestation predicate-size limit MUST be validated; an oversized or incomplete SBOM MUST block publication. Each published SBOM MUST equal the parsed document carried by its artifact's SBOM attestation. + +## 6. Build facts and #161 handoff + +### R-FACTS + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +One immutable build-fact mechanism MUST extend merged #1213. Absence of `TRUSTED_SERVER__RELEASE_BUILD` selects development behavior; any present value other than exactly `1` MUST fail. The trusted recipe MUST set `TRUSTED_SERVER__RELEASE_BUILD=1` after inherited-environment audit and sanitation. + +Release Rust builds MUST receive absolute `TSJS_PREBUILT_DIR` and `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths, validate the inventory and copied bytes, expose its raw digest as `TRUSTED_SERVER__TSJS_INVENTORY_SHA256`, and MUST NOT run npm. Rust Cargo builds MUST reject any presence of `TSJS_SKIP_BUILD`, including empty, `0` and `1`; Node development build/Vitest entry points preserve its development behavior when release control is absent. + +The recipe MUST supply `TRUSTED_SERVER__GIT_VERSION` as the approved full source commit after sanitation, and release builds MUST reject missing/malformed values and prohibit ambient `.git` tag/branch fallback. Development fallback from #1213 MAY remain outside release execution and MUST be recorded as a development input. + +A typed `BuildInfo` MUST derive target from Cargo `TARGET` through `TRUSTED_SERVER__BUILD_TARGET`, package version from `CARGO_PKG_VERSION` and variant/features in the owning adapter/product. Core cannot infer a Fastly feature that is not forwarded to it. Caller-supplied target/variant strings MUST NOT override these derived facts. `TEMPLATE_BUILD_DIGEST` remains separately named. + +The release tag/version MUST stay external in the release index, not be compiled into bytes. Two tags on one source commit can therefore refer to identical bytes. `ts --version` MUST label the fields as `ts package= source= target= variant=default`; it MUST NOT imply the workspace's current `0.1.0` is the external release version. `X-TS-Version` is the approved full source commit in release mode. Development retains #1213's override/tag/branch/short-commit display behavior. A development checked inventory validates its own commit syntax and hashes but MUST NOT compare that source to the development display label. Fastly emits owning package/target/variant facts through its existing startup diagnostics; no additional response header or attestation authority is introduced. Fastly service version uses `X-TS-Fastly-Version`. Neither header is a binary checksum or remote execution proof. + +For the selected default Fastly artifact, settings load per request. Only a future qualified reusable variant retains settings for its sandbox lifetime. #161 MUST distinguish artifact identity, observed service/version and the configuration snapshot actually used; fetching the latest store after a request does not establish that snapshot. Build facts contain no secrets or active configuration. Deployment request-signing keys/JWKS remain unrelated to release signing. + +## 7. Source eligibility, workflow and immutable publication + +### R-SOURCE + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +`.github/workflows/release.yml` MUST accept protected `v*` tag pushes and strict stable/prerelease SemVer, including schema-supported build metadata. Official tags MUST be lightweight, so the verified tag target is the source commit; annotated tags MUST be rejected, not confused with their peeled commits. The current reviewed `eligible_branches` is exactly `["main"]`. Eligibility MUST use the actual remote branch tip and commit ancestry, with governance validation of active branch/tag rulesets and bypass actors. Local remote-ref lookalikes MUST NOT supply that authority. + +A future exact `rc/YYYYMM` branch requires maintainer review of its tracked cohort entry and governance inclusion before use, plus actual remote-tip ancestry and ruleset/bypass validation. A regex match or a candidate checkout that lists its own branch MUST NOT approve itself. The tag ruleset and branch policy MUST constrain creation/update/deletion and bypass actors. + +Eligibility MUST run trusted release validation at the exact tag commit for every required AGENTS.md gate. `test.yml`/`format.yml` currently lack push coverage for RC branches, so successful push-to-main checks MUST NOT be the only path. Implementation MUST expose reviewed `workflow_call` validation jobs or equivalent explicit same-commit release jobs. It MUST enumerate required jobs and reject skipped/cancelled/PR-only/fork results. A tag immediately after merge MUST wait for these release validation jobs with a checked deadline (initially 90 minutes), then fail with a rerun diagnostic on timeout. It MUST NOT publish using an unrelated passing check name. + +### R-SIGN + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +The local `.github/workflows/release-build.yml` at the caller's source revision MUST own build orchestration, collector validation and file attestations. Build/lifecycle execution MUST occur without OIDC/attestation writes. Signing MUST consume only validated current-run job outputs and MUST NOT compile untrusted source. The authenticated build boundary is the complete workflow invocation, not just the collector. No caller-supplied command, script, arbitrary path/repository or provenance predicate is accepted. All actions MUST be SHA-pinned; tools MUST have exact version/digest pins in `scripts/release/release-tools.json`. + +| Job | Permissions | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Eligibility/validation/build/compare | `contents: read`; `actions: read` / `checks: read` only for actual validation/artifact lookup needs | +| Validated artifact and index signing | `contents: read`, `id-token: write`, `attestations: write`; `actions: read` if collecting outputs | +| Draft upload/final publish/reconciliation | `contents: write`; no OIDC, provider credential or repository settings token | + +File attestations MUST NOT request `artifact-metadata: write`; no container storage record is being created. Each canonical artifact MUST get two pinned `actions/attest` invocations: build provenance with the executable and metadata subjects, then SPDX mode with the executable subject and published SBOM as predicate. `sbom-path` does not also create separate build provenance. The collector MUST verify all bundles before index signing. + +### R-PUBLISH + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +Maintainers MUST enable immutable releases and active tag rules before first publication and record approval of release authority. No `RELEASE_SETTINGS_READ_TOKEN` is introduced. A tag-scoped concurrency group MUST serialize publication without cancelling a publishing run. The publication job MUST create/resume a matching draft, upload the complete asset set, re-download and verify it, publish, then read `immutable == true` using `GITHUB_TOKEN` and verify the GitHub release attestation. It MUST NOT report success before that final check. + +If a release is confirmed mutable, the job MUST mark publication failed, remove that still-mutable release if authorized by the workflow's release-write role, and raise a release incident with the result. An indeterminate response MUST trigger reconciliation, not blind deletion. This is detection/recovery, not a guarantee that a transient mutable publication was never visible; consumers require immutable signed membership and therefore reject it. Published immutable releases MUST NOT be overwritten or retagged. Defects require an advisory and new version. + +A rerun MUST refuse an already published tag. A partial draft MAY resume only when source/tag and all existing asset digests match the intended set. Conflicting bytes require explicit recovery. A failed optional CLI comparison is reportable; a failed required profile leaves no published release. + +## 8. Distribution schema and evidence graph + +### R-INDEX + +**Phase:** 2+. + +The normative [release-index v1 JSON Schema](schemas/release-index-v1.schema.json) MUST be evaluated with the JSON Schema Draft 2020-12 dialect and defines closed kinds, roles, targets, variants, profiles and capabilities. The governing schema raw bytes MUST remain unchanged absent an explicit reviewed schema amendment. Its retained `$comment` pins Draft 2020-12; Rust `KnownSchema` explicitly selects that dialect with offline evaluation, and committed positive/negative tests exercise the actual governing schema. + +Producer and consumer MUST additionally require `recipe_source_commit` and `cohort_source_commit` to equal the independently approved `source_commit`, and enforce exact cohort membership, unique safe asset names, expected package/target/variant/profile combinations and equality of each size/digest with actual bytes. These repeated v1 source/recipe/cohort equalities intentionally expose the joins; a different revision requires a future reviewed amendment. Structural v1 compatibility may accept `fastly-staging`, but current producer/consumer policy and cohort MUST semantically reject it until reviewed enablement. Tasks 3, 13 and 17 MUST test that rejection. SemVer metadata and schema fields MUST NOT be narrowed without review. + +Unknown schema versions, duplicate JSON keys, duplicate/conflicting identities, traversal names, unknown capabilities and mismatched JS inventories MUST fail. No executable asset is an archive in this cohort. + +Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, `tsjs-inventory.json` and `SHA256SUMS`. Each executable has `.spdx.json`, `.provenance.sigstore.json`, `.sbom.sigstore.json`, `.build-evidence.json`, `.build-recipe.json` and `.rebuild-check.json`. The index MUST name and digest companions and the JS inventory. It MUST NOT include its own digest/bundle; those identities are external. `SHA256SUMS` MUST cover every other published asset, never itself, and is only a convenience until authenticated by release membership. No `release-inputs.tar.zst` is promised here. + +| Document | Version-1 field contract / status | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `release-cohort.json` | Producer policy: `schema_version` integer 1; `cohort` fixed string; `eligible_branches` unique exact branch strings; `artifacts` exact schema artifact combinations and required profiles. Source-controlled, never selected by downloaded metadata | +| `release-tools.json` | Producer policy: `schema_version` 1; `tools` sorted records of name/version/SHA-256/acquisition identity; `environments` checked image/SDK identities; `recipes` script paths/digests and effective configuration | +| `tsjs-inventory.json` | `schema_version` 1; `source_commit` 40 lowercase hex; `modules` sorted unique records with `id`, `filename`, `size` nonnegative integer, `sha256` 64 lowercase hex and contribution records; tool/material records | +| `.build-evidence.json` | `schema_version` 1; `source_commit`, `target`, `variant`, `artifact_sha256`; `js_inventory_sha256`; `prepared_modules` sorted input records and `compatibility_tests` records of actual OS/architecture/environment/outcome; `embedded_modules` sorted unique records of `id`, `filename`, nonnegative integer `size` and 64-character lowercase hex `sha256`, observed in the final executable and matched to the shared inventory; absence after linking is explicitly recorded, while `OUT_DIR` copies are prepared input evidence; `inputs`, `rust_units`, `js_contributors`, `native_components`, `external_requirements`, `tools` arrays containing identity/digest/role evidence from R-INPUTS/R-SBOM | +| `.build-recipe.json` | Informational: version 1; recipe source commit/script/tool identities and selected effective settings. MUST NOT be executed as a supplied program | +| `.rebuild-check.json` | Informational to consumers: version 1; profile, recipe/source/JS digest; A/B run/job/artifact IDs and transfer digests; environments, final size/SHA-256 or bounded failure status; outcome enum `matched`, `mismatched`, `failed`, `unavailable` | +| Operation record/receipt | Private lifecycle observation under R-RECOVERY, not a public claim schema. #161 owns any future authenticated receipt use; no normative `verified-publication.json` | + +These JSON files MUST use UTF-8, reject duplicate keys/nonfinite numbers and use safe relative asset names. Digests bind exact distributed file bytes; verifiers MUST NOT reserialize before hashing. Arrays with set semantics MUST have deterministic order. Producer tooling and the Trusted Server wrapper MUST implement strict parsed SBOM-document equality (object key order ignored, array order significant, duplicate keys rejected); it is not substituted for file-byte digest checks. gh verifies a predicate and returns it; it supplies no SBOM-file comparison operation. Duplicate keys MUST be rejected before signing as well as before consumer comparison. Informational descriptors/measurements remain authenticated companions, but consumers MUST NOT execute them or infer independent trust from their contents. + +### R-EVIDENCE + +**Phase:** 2+. + +Every successful verification MUST establish all equalities below: + +| Edge | Required equality/check | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Local policy → index | Repository, exact tag/source commit, cohort, package/kind/role/target/variant/profile/capability equal the approved local contract; recipe/cohort revisions equal approved source, and producer policy identity matches the locally computed current-policy identity or the trusted historical-policy allowlist | +| Index provenance → index | Exact index subject SHA-256; approved certificate/source/builder/caller constraints | +| Index → executable/companions/JS inventory | Exact names, lengths and SHA-256; no unlisted code or conflicting identity | +| Artifact provenance → executable/metadata | Expected executable, SBOM, build evidence, recipe and rebuild-check all present as exact subjects; no extra unapproved subjects | +| SBOM attestation → executable/document | Executable subject SHA-256; SPDX predicate equals published parsed SPDX; SPDX root checksum equals executable | +| Build evidence → JS inventory | Prepared input digest/table equals shared inventory; each claimed final retained module matches that inventory and per-artifact final-byte evidence | +| GitHub release evidence → distribution | Verified release statement binds repository/tag/target and consumed signed subject names/digests; signed index sizes equal actual bytes and REST asset sizes (release subjects have no size field) | +| Consumer → action | Approved source, local operation and service/environment/context before constructing the action-scoped verified value | + +The recipe and rebuild records are authenticated for audit, but consumers do not rerun a project-controlled comparison or treat its JSON outcome as an independent authorization authority. The reviewed producer workflow enforces the profile; consumers enforce its identity and selected profile. + +## 9. Fixed verifier policy, bootstrap and online commands + +### R-POLICY + +**Phase:** 2+. + +`crates/trusted-server-cli/release-policy.json` MUST be a tracked, embedded policy with `policy_version` integer and an external SHA-256 of its exact bytes. Only a reviewed CLI release MAY update it. Index `policy_version`/`policy_sha256` identify the producer policy tracked at `source_commit`, not a downloadable replacement for the consumer policy. The consumer MUST match that pair to independently trusted source-policy bytes, then accept either its own policy version and externally computed exact-byte digest, or a differing producer identity in a separately embedded reviewed historical allowlist. The current policy MUST NOT contain its own SHA-256 or be required to list itself; this avoids a self-hash cycle. Its own acceptance constraints always remain in force. New CLI policies MUST explicitly retain or retire historical identities; the index cannot pick its verifier policy by declaring a hash. Downloaded indexes, operator configuration, roots or arbitrary forwarded gh flags MUST NOT redefine it. The policy MUST fix GitHub host/repository numeric IDs, certificate identities/issuer, source/builder/caller semantics, supported artifact/profile/action combinations and resource bounds. Automation MUST also approve a source commit/tag; an index cannot approve its own source. + +Task 17 MUST obtain producer-policy bytes from an independently approved source snapshot or the fixed official GitHub Contents API endpoint `https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref=`, using `Accept: application/vnd.github.raw+json`. The ref MUST come from independent full-commit approval, never the index. Fetching MUST use an explicit read-only credential, the 1 MiB policy bound, and the overall verification deadline. Arbitrary hosts/paths and redirects that change that origin/path or forward credentials MUST be rejected. The producer hash/version MUST be computed from those approved-source bytes before applying the consumer's own compiled constraints. Offline checks require an independently transferred approved source-policy snapshot and establish build proof only, never official deployment. [GitHub Contents API](https://docs.github.com/en/rest/repos/contents#get-repository-content) documents raw media and explicit commit refs. + +The initial verified tool contract is GitHub CLI **2.97.0** and `actions/attest` **4.2.2** (SHA `1e69f48acb82d1966a394da916b4c1698aa569d6`). Implementation MUST record platform gh binary digests in `release-tools.json` and qualify this exact version. Other versions MUST fail with a compatibility diagnostic until a reviewed policy/tools update qualifies them. This is an exact supported version, not an unspecified minimum. Checking the version string is not proof that an executable is trustworthy. + +| Authenticated expectation | Required value | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Repository / owner IDs | `950860906` / `82903538`, with `IABTechLab/trusted-server` on `github.com` | +| Certificate SAN / provenance builder ID | `https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/` | +| Certificate issuer | `https://token.actions.githubusercontent.com` | +| Source ref / source digest | Exact `refs/tags/` / independently approved 40-character commit | +| Build signer digest | Same source commit for this same-revision local reusable workflow | +| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | +| Caller / external parameters | Exactly `workflow: {ref: "refs/tags/", repository: "https://github.com/IABTechLab/trusted-server", path: ".github/workflows/release.yml"}`; no arbitrary inputs/commands | +| Source resolved dependency | `git+https://github.com/IABTechLab/trusted-server@refs/tags/` with `gitCommit` equal to approved commit | +| Event / runner | `push` / `github-hosted` | +| Invocation | Repository Actions run/attempt identity consistent across collected evidence; no cross-run substitution | + +Certificate-backed fields MUST be checked using the pinned gh verifier. Build-type/caller/external-parameter/source-material/subject semantics MUST additionally be checked in successfully verified provenance, not merely inferred from certificate success. Unexpected required structures or missing constraints MUST fail. Policy fixtures MUST use actual output from the pinned action/toolkit; upgrading it requires explicit fixture review. A future separately protected Level 3 builder changes the signer identity: a reviewed policy update MUST define which CLI versions and releases accept it, document the migration and retain verification of supported historical identities. There is no distinct builder/source revision in today's local workflow layout. + +For both provenance and SPDX proofs, post-success gh JSON certificate checks MUST require `sourceRepositoryIdentifier`, `sourceRepositoryOwnerIdentifier`, `buildConfigURI`, `buildConfigDigest`, `buildTrigger` and `runInvocationURI` to match the numeric repository IDs, exact caller `release.yml@refs/tags/`, approved commit, `push` event and allowed shared run/attempt above. These fields are flattened under `verificationResult.signature.certificate` in the pinned verifier. Provenance `runDetails.metadata.invocationId` MUST equal the certificate run URI. SPDX has no SLSA workflow fields; it uses these certificate constraints plus subject/document checks, not an invented provenance predicate. + +### R-BOOTSTRAP + +**Phase:** 2+. + +Consumers MUST obtain gh through a trusted installation channel, use it plus their approved policy to verify the downloaded `ts` executable before first execution, then let verified `ts` drive gh. The exact qualified gh 2.97.0 executable MUST be privately selected through an authenticated installation path, resolved once and retained as the invocation target. Ambient `brew upgrade gh` is diagnostic only: an unsupported resulting version MUST fail, and version text alone MUST NOT authenticate it. A future reviewed list of qualified versions is a policy/tools amendment, not current broadening. + +A consumer compiled with policy v1 MUST reject an unsupported producer policy v2. A newer consumer MAY accept an explicitly reviewed historical v1 identity pair, but MUST enforce its own current bounds and constraints throughout verification. A source-built CLI can run checks but has no official binary/policy assurance until its bytes independently verify. A compromised operator PATH/host remains outside the guarantee. + +Online operations require GitHub authentication even for public artifacts. The integrated verifier MUST accept only an explicitly supplied `GH_TOKEN` or, as fallback, `GITHUB_TOKEN`, for a read-only public-repository request. It MUST pass the selected credential only as `GH_TOKEN` to the child, clear `GH_HOST`, enterprise tokens, inherited `GH_CONFIG_DIR` and other gh overrides, and set isolated private `GH_CONFIG_DIR` and `XDG_STATE_HOME` directories, a reviewed nonempty `HOME` and disabled prompts. Configuration isolation alone does not isolate gh telemetry/device state; an omitted home/state path can write relative to the current directory. Stored `gh auth login` credentials are not silently inherited; the diagnostic MUST explain the explicit-token requirement. Provider credentials MUST NOT reach gh. Normal direct gh commands can use their own trusted login configuration, but integrated behavior follows this narrower contract. The child environment MUST otherwise use reviewed OS/runtime variables and no dynamic-loader injection settings. + +### R-BOUNDS + +**Phase:** 2+. + +Each gh child MUST have a 120-second wall-clock deadline, at most 128 MiB of JSON stdout and 1 MiB of stderr; an integrated verification attempt MUST have a 15-minute overall deadline. Before parsing/invoking gh, index/policy/inventory files MUST be at most 1 MiB, each bundle/build companion at most 32 MiB, SPDX SBOM at most the pinned action's 16 MiB limit, and trusted-root snapshot at most 1 MiB, and each executable at most 512 MiB. The integrated verifier MUST verify one retained, membership-checked bundle per invocation and accept exactly one matching attestation result; JSON output includes both bundle and parsed predicate, so its bound is larger than the input bound. Metadata nesting MUST be limited to 64 levels and the cohort to four executables with their declared companions. Timeout, cancellation, oversize input/output, parser ambiguity, failed exit or incomplete JSON MUST fail, terminate/reap children and construct no `VerifiedArtifact`. Bounds can change only through reviewed policy. Diagnostic output MUST exclude credentials and secret context. + +### R-PUBLICATION + +**Phase:** 2+. + +Online verification MUST combine pinned gh signature verification with explicit REST and verified-statement policy checks; successful gh exit statuses alone are insufficient. `release verify-asset` matches the file digest even when its local name differs, does not check raw tag type or the API immutable flag, and does not authenticate an asset-size field. The wrapper MUST compare signed subject names/digests, independently approved source/type, REST immutable state and index/actual/API lengths. Online verification MUST verify the GitHub immutable release, confirm its lightweight tag object is a commit, match the signed release target to the approved commit, authenticate index provenance before using its fields, verify R-EVIDENCE, and verify immutable membership for every consumed asset. Successful verification of one predicate MUST NOT stand in for all predicates. Only then, after local action/context authorization, MAY the library construct private `VerifiedArtifact` with retained bytes. No provider write, secret/config creation or build hook is allowed during verification. + +The following fictional release example shows the primitive commands. They require the approved gh version and authentication. `` comes from trusted consumer approval checked against the signed lightweight release target, not from the downloaded index. The literal #1235 D4 `--signer-workflow .../release.yml` fails because the certificate SAN names `release-build.yml`. Naming `release-build.yml` satisfies only the basic compatible prefix check; its prefix semantics do not meet this contract's exact-identity policy; `--cert-identity` is used instead. These flags are mutually exclusive. + +```sh +RELEASE_TAG=v1.2.0 +REVIEWED_COMMIT='' +SIGNER="https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/$RELEASE_TAG" + +gh release download "$RELEASE_TAG" \ + -R github.com/IABTechLab/trusted-server \ + --pattern 'trusted-server-fastly.wasm*' \ + --pattern 'release-index*' --pattern tsjs-inventory.json --pattern SHA256SUMS + +gh release verify "$RELEASE_TAG" -R github.com/IABTechLab/trusted-server + +verify_build() { + gh attestation verify "$1" \ + -R IABTechLab/trusted-server --hostname github.com \ + --cert-identity "$SIGNER" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 --format json +} + +verify_build release-index.json +verify_build trusted-server-fastly.wasm + +gh attestation verify trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server --hostname github.com \ + --cert-identity "$SIGNER" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 --format json + +gh release verify-asset "$RELEASE_TAG" release-index.json \ + -R github.com/IABTechLab/trusted-server +gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm \ + -R github.com/IABTechLab/trusted-server +gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm.spdx.json \ + -R github.com/IABTechLab/trusted-server +``` + +The full R-EVIDENCE checks, JSON Schema validation and membership checks for remaining companions/JS inventory are still required; these commands alone do not construct a verified capability. The implementation MUST provide `ts release verify --asset --expected-source-commit ` as a read-only complete online policy check, using the same verifier library as deployment. It MUST report policy version/digest, source, asset digest and required profile, and distinguish optional comparison outcomes. It does not export an offline publication-authorization record. + +## 10. Offline evidence limits + +### R-OFFLINE + +**Phase:** 2+. + +Offline build verification MUST use securely transported roots, an independently approved source commit and artifact SHA-256, and published provenance/SBOM bundles. Roots or digest pins supplied alongside arbitrary evidence MUST NOT become trust inputs. Preparation MAY obtain a root snapshot with `gh attestation trusted-root --hostname github.com`; artifacts/bundles are downloaded online. A file-mode bundle verification does not need GitHub authentication. Verification MUST work with network access denied and apply the same fixed certificate/source/predicate bounds. + +```sh +RELEASE_TAG=v1.2.0 +REVIEWED_COMMIT='' +SIGNER="https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/$RELEASE_TAG" + +gh attestation verify trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server --hostname github.com \ + --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ + --custom-trusted-root trusted_root.jsonl \ + --cert-identity "$SIGNER" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 --format json +``` + +Apply equivalent checks to index and SBOM bundles, use SPDX predicate type for the latter, compare the independent executable pin and all authenticated companion digests, and compare the SPDX document. Offline success establishes build evidence and approved byte identity only. It does not re-establish GitHub release membership, current release state or fresh revocation. `gh release verify-asset` requires the API; `gh attestation verify` is not an offline verifier for GitHub's release-attestation certificate profile. Automated offline official deployment and a `verified-publication.json` protocol are deferred; `ts deploy --release --offline` MUST be rejected in this cohort. + +## 11. Fastly verified-byte consumption and recovery + +### R-DEPLOY + +**Phase:** 2+. + +Canonical identity is SHA-256 of final `trusted-server-fastly.wasm`, not a publisher-specific tarball. The new `ts deploy --adapter fastly --release ` path MUST verify before side effects, retain the verified bytes in private owned storage and bypass source-build and manifest deploy overrides. Artifact flags without `--release`, unsupported variants/adapters, conflicting package/source arguments, arbitrary passthrough hooks and offline deployment MUST fail. A local `--artifact ` MAY be used only when its retained bytes equal the authenticated official asset and all online checks succeed. Matching source/version strings alone are insufficient. + +A delayed EdgeZero API MUST NOT block raw release publication or documented manual verified-byte production deployment. Integrated production/staging is separate completion item AC-8 below, not a prerequisite to build/sign/publish. Source deployment remains available for development/custom distributions, with an explicit diagnostic that canonical identity has not been established. + +### R-PACK + +**Phase:** 2+. + +Packing MUST use a validated deployment-only manifest derived from the selected publisher-owned manifest and retained verified wasm in a fresh private directory. The sanitized package name MUST be nonempty and MUST NOT equal `package`; that reserved name collides with the generic archive. With pinned Fastly CLI 15.1.0 and an accepted name, `compute pack --wasm-binary` writes `pkg/.tar.gz` and also an unrelated `pkg/package.tar.gz`; it can sweep pre-existing package contents. + +The chosen archive MUST have exactly `/`, `/bin/`, `/bin/main.wasm` and `/fastly.toml`. Inspection MUST reject links, traversal, duplicates, extra entries or mismatched manifest/wasm bytes, with bounded extraction. The archive MUST be selected by validated name and contents, not a guessed generic filename. Recheck retained archive/member digests immediately before submission. + +The deployment-only manifest MUST exclude `[local_server]`, inline fixture settings/secrets, active app-config payloads and build hooks; preserve only reviewed provider envelope/resource-reference fields. The original operator manifest is not silently edited. Fastly pack and `compute validate` can succeed on non-wasm bytes and neither strips local fixtures, so they MUST NOT be used as proof of wasm validity or privacy. + +Producer validation MUST parse the actual final core module with a pinned validator and execute an application request under pinned Viceroy; `/health` alone bypasses app settings. Live provider/runtime validation remains separate. Tar metadata is nondeterministic: package SHA-256 identifies this operation's envelope, not the canonical release. + +### R-CONTEXT + +**Phase:** 2+. + +Before any provider mutation, the integrated route MUST resolve an existing service, selected base version, approved logical config ID, physical resource mapping and runtime selectors, and compare declarations with observed links/selectors on that exact version. Existing-service `[setup]` declarations alone do not provision or update bindings. New-service provisioning MUST be a separate supported operation, otherwise fail read-only. Store/config/secrets push remains separately authorized and MUST NOT be hidden in verification. + +After clone/upload, the route MUST validate actual bindings and selectors on the exact returned draft version before staging or activation. Production MUST preserve intended config mappings; staging MUST use a verified isolated config mapping and selectors rather than silently retain inherited production links. Missing logical IDs, absent links, contradictory observed selectors or incomplete isolation MUST fail before activation/stage. Staging isolation here covers app-config mappings/selectors, not automatic isolation of KV/Secret Stores or external side effects. The approved context MUST explicitly declare shared versus isolated resources, and secret validation MUST use references/presence/metadata, not pretend write-only secret plaintext can be read back. Participating operations MUST serialize at service/shared-resource scope; an environment-only lock does not cover shared production/staging resources or other operators. Mutable context MUST be revalidated at the applicable phase; this is not a transaction or a guarantee against concurrent provider changes. + +### R-EDGEZERO + +**Phase:** 2+. + +The pinned EdgeZero adapter interface is `execute(AdapterAction, &[String]) -> Result<(), String>`; context is string tokens and outcomes are logs. Implementing `deploy_prebuilt` requires a new additive request/result API and reviewed upstream work. Its initial scope MUST be Fastly only. The request MUST contain retained package/path/size/SHA-256, existing service ID, selected base version, production/staging mode and resolved manifest/store context. The result MUST contain service ID, exact version if known, last confirmed phase, whether mutation was attempted and known/unknown outcomes. + +EdgeZero MUST own concrete clone/upload/context-validation/stage/activation operations and payload integrity without implementing official release policy. It MUST NOT run `compute build`, `compute publish`, `compute update --autoclone --version=active` as a combined guessed-version path, or infer the uploaded version from whichever version is now active/highest. It MUST inspect the exact selected version and capture the version returned by its own clone/upload operation. `compute deploy` activates internally and cannot expose the required draft-binding validation boundary, so the integrated API cannot be implemented by merely wrapping its exit status. + +### R-RECOVERY + +**Phase:** 2+. + +Before first mutation, the integrated route MUST durably persist a restricted operation journal plus retained package/evidence and original resolved context. The journal MUST bind a unique operation ID, policy/source/artifact/package identities, intended service/base version/mode/mappings, previous active version when observed, last confirmed phase and mutation/uncertainty state. It MUST exclude credentials, resolved secrets and active configuration payloads. Immediately before every clone, upload, selector/link write, stage or activation request, the journal MUST atomically persist and flush a pending/attempted marker and explicit uncertainty. A crash between request transmission and response MUST NOT leave a false no-mutation claim. Confirm responses in atomic durable updates through `Prepared → VersionAllocated → Uploaded → ContextBound → Staged/Activated`; allocated version and uploaded package are distinct observations. An abrupt interruption MUST leave enough durable intent/state for inspection. + +After any attempted write, retained recovery inputs MUST survive temporary-directory cleanup, a failed final receipt write and process termination until explicit reconciliation or abandonment. Before the first write, failed preparation MAY clean up. The consumer MUST serialize operations for the same service and any shared resources across environments; no distributed lock is promised. EdgeZero owns read-only reconciliation of exact observed/allocated versions and uncertain writes. The operator/orchestrator owns the retry, explicit rollback or abandonment decision. Recovery MUST NOT rebuild from today's edited manifest, blindly retry activation/upload or guess a rollback target. If the first clone commits but its response is lost before the allocated version is known, reconciliation MAY remain unresolved: the endpoint exposes no documented caller operation ID/idempotency key. Candidate versions, timestamps or matching packages are not unique ownership proof. Recovery MUST preserve unknown and MUST NOT adopt, activate, delete or roll back a guessed candidate. The local operation ID is not a provider transaction ID. Local receipt output MAY summarize observations but is non-normative for #161 and proves neither publisher authority nor live execution. + +The proposed integrated interface is new work: + +```sh +ts deploy --adapter fastly --service-id '' \ + --release v1.2.0 --expected-source-commit '' \ + --receipt deployment-receipt.json + +ts deploy --adapter fastly --service-id '' --staging \ + --release v1.2.0 --expected-source-commit '' \ + --receipt staged-deployment-receipt.json +``` + +### Manual production route during integration + +### R-MANUAL + +**Phase:** 1 for the reduced acceptance subset in Phasing; remaining clauses are Phase 2+. + +Operator guidance MUST provide verification, private-directory packing, R-PACK inspection, observed existing-service binding checks and an explicit production submission command before the first release. A lower-level verified-byte transport can use: + +```sh +fastly compute pack --wasm-binary ./trusted-server-fastly.wasm +fastly compute deploy --package './pkg/.tar.gz' \ + --service-id '' --version '' --no-default-domain +``` + +Run in a private directory with exactly the validated deployment-only `fastly.toml`; inspect the package before deployment. At pinned 15.1.0, `compute deploy` clones an active/locked version and activates production, has no staging mode, and can successfully skip deployment when the selected version already has the same package hash. No upload/new version/activation can be inferred from exit zero. It reads a working-directory manifest before the packaged one and otherwise may create a random default domain; explicit service selection, the inspected integer `--version` and `--no-default-domain` are required here. The operator MUST serialize manual changes to that service and its shared resources across environments and recheck the selected version's bindings; omitting `--version` resolves active at execution time and falls back to latest if no active version exists, which can select a different version from the inspected one. `[setup]` stores are applied only for new services. Built-in availability checks do not establish application correctness. + +This manual route preserves inspected wasm bytes but does not supply the integrated preactivation binding check, staging isolation or durable structured outcome. Operators MUST document that limit, record the version actually reported, and independently inspect binding/context rather than claim the integrated contract passed. The proposed integrated route is the eventual default; this documented verified-byte production route satisfies #1235's initial artifact-deployment choice while upstream integration is pending. + +## 12. Follow-up ownership and stable adapter invariants + +### R-FOLLOWUP + +**Phase:** 2+. + +Additional adapter support MUST preserve configuration outside canonical bytes, external complete-byte SHA-256 identity, verification and action authorization before provider mutation/process start, and artifact-specific provenance/SBOM, reproducibility policy, runtime/binding, readiness and recovery proof. It MUST be selected in a reviewed cohort/policy before execution. One adapter's successful proof MUST NOT qualify another. These invariants do not make additional adapters part of #1235's acceptance or require a cumulative nine-artifact launch. + +The following are investigation inputs for separate specs, not approved transports/ABIs in this contract: + +| Follow-up | Questions and proof to resolve | +| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reproducible operator rebuild | Retained authenticated source/dependency/tool inputs, legally obtainable SDKs, separately bootstrapped replay and offline official-release policy. Consider a supported library verification of GitHub-signed release attestations rather than a consumer-authored publication record. Promote native comparison gates individually | +| Cloudflare (existing [KV/Worker-variable mismatch #1140](https://github.com/IABTechLab/trusted-server/issues/1140), deferred here) | Define final executable modules and inventory subjects (prefer raw module assets plus `modules.json` unless an archive is justified). The Worker reads `TRUSTED_SERVER_CONFIG` and caches settings, while current EdgeZero config push writes KV; these channels MUST be reconciled in the adapter spec with explicit config-envelope/secret references and positive/negative publisher-request fixtures. Define prepared version upload and explicit activation identities | +| Cloudflare build/transport | Verify/preseed worker-build 0.8.5 tools: wasm-bindgen matching locked 0.2.126, Binaryen 130 and esbuild 0.28.1 (distinct from the JS lockfile). Account for `WASM_BINDGEN_BIN`, `WASM_OPT_BIN`, `ESBUILD_BIN`, cache/download fallbacks, `CUSTOM_SHIM`, `RUN_TO_COMPLETION`, coredump switches, minification, optimization, panic-unwind and producers metadata. Current `build.sh` installs without `--locked` and loads operator env. For inheritance, investigate `bindings_inherit=strict`. Ordinary script/Upload Version inheritance is latest-uploaded-only; an explicit non-latest `version_id` requires the beta Workers API Versions endpoints and a separately reviewed API contract. Do not prescribe that beta field for the ordinary endpoint; latest uploaded can differ from deployed | +| Spin | `spin-sdk` 6.0.0 locks `wasip3` with WASI `0.3.0-rc-2026-03-15`. A `wasm32-wasip2` compiler target alone does not define a compatible ABI. Pin/test that exact world and runtime, migrate all owning targets/manifests/CI/commands together, and reassess RC support lifetime. Existing secret `default = ""` fields serialize into published locked-app data; define unresolved secret references. Default OCI composition and current-time annotations MUST not silently transform the canonical component; registry manifest identity is observed separately | +| Axum | Define direct verified local launch and a tested bridge from EdgeZero's local config envelope to runtime selectors/config variables and secret references. The binary uses `PORT` and loopback, not the source launcher's adapter port or `axum.toml`; a living error router is not readiness. Require an application request, child cleanup and signal handling. No generic remote deployment implied | +| #161 | Publisher authority, actual loaded snapshot, claims/freshness, remote execution limits and optional authenticated deployment observations. Fastly default is per-request; reusable Fastly/Axum/Cloudflare/Spin have distinct snapshot lifetimes | + +The Cloudflare configuration mismatch and inaccurate configuration comments predate this spec. Runtime repair is separate work; no issue is automatically opened by this document. + +## Feasibility evidence and implementation dependencies + +The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) covers every requirement and identifies concrete implementation boundaries. Local JS/wasm/native builds, application execution and pinned gh primitives were exercised. Hosted production recipes, full real-artifact SBOM coverage, GNU/Intel qualification, actual project signing/publication and live Fastly submission remain unproven release gates. Approval of prose does not waive them. The audit records the known unavailable old-macOS matrix, final-CLI JS distinction, primitive verifier limits, manifest fixture leakage and irreducible first-clone uncertainty corrected here. + +## Test plan and requirement traceability + +These are implementation acceptance tests, not tests claimed to pass in this specification and implementation PR. Every normative requirement maps below to a test family and acceptance item. D1–D8 refer to the eight “Done when” bullets of #1235 in their original order. + +| Requirements | Test family and essential positive/negative cases | Acceptance | #1235 Done when | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------- | +| R-COHORT, R-PLATFORM, R-VARIANT | T-1 exact four assets/profiles; no downgrade on required failure; CLI B mismatch reported; Fastly failure blocks; ELF symbol/link/runtime checks; macOS 15 ARM/Intel startup; old-OS claims remain unqualified; reject unsupported reuse | AC-1, AC-2 | D1, D3 | +| R-CONFIG, R-INPUTS, R-ENV | T-2 build with differing fictional publisher inputs gives identical permitted bytes; operator values absent; unchanged repository manifest; inventory covers embedded roots/generated assets/native inputs; prohibited flags/files/env fail | AC-2 | D2, D5 | +| R-JS | T-3 #1214 private prebuilt path; skip absent/empty/0/1; missing npm, failed install/test, stale/empty/extra module; inventory tampering; two JS builds differ; cross-artifact digests differ; missing esbuild with network denied | AC-3 | D5 | +| R-BUILD, R-REPEAT, R-NATIVE | T-4 separate run/job/ID/digest binding; wrong-run/artifact substitution; fresh outputs; varied declared ambient inputs; final wasm equality/no normalized comparison; native A/B outcomes and signature/path observations; enforced socket-denial controls | AC-3 | D3, D6 | +| R-SBOM | T-5 actual target and host proc-macro/build roles, dual-role package, dev exclusion, real JS contribution, Prebid type-only exclusion, plugin helper, AWS-LC/SQLite/ring native coverage and forbidden system selection, SPDX relationship/root/predicate equality and size limit | AC-4 | D3, D4 | +| R-FACTS | T-6 target/feature ownership; malformed/missing commit fails without git fallback; source header vs service version; two tags on same commit do not change bytes; no final checksum/config/run timestamp embedded | AC-5 | D2 | +| R-SOURCE, R-SIGN, R-PUBLISH | T-7 approved main/RC tag; unauthorized RC and annotated tag rejected; pending/cancelled/skipped/path-filtered/wrong-commit checks; timeout; untrusted caller; permission separation; matching/conflicting draft reruns; immutable/mutable/unknown publish state | AC-6 | D3, D7 | +| R-INDEX, R-EVIDENCE, R-POLICY | T-8 schema positive fixture and unknown enums/version/extra field/duplicate keys/names/traversal; tampered subjects/companions; policy cannot self-select; wrong caller/build type/material/source/builder/prefix lookalike, trailing control characters, actual size limits; no extra unapproved subjects | AC-4, AC-7 | D3, D4 | +| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | T-9 verified CLI first-use fixture; absent token; isolated gh config/host; unsupported tool; hangs/cancel/oversize/nesting/partial JSON; unpublished or mutable artifact; source approval mismatch; all fail before provider mutation | AC-7 | D4, D6 | +| R-OFFLINE | T-10 pinned roots/source/digest with networking denied; artifact/predicate tampering; hostile transferred roots; offline build success cannot construct official-deploy capability; offline deployment rejected | AC-7 | D4 (specified offline limits) | +| R-DEPLOY/R-PACK integrated portion, R-CONTEXT, R-EDGEZERO, R-RECOVERY | T-11 retained-file substitution/package/member mutation; all four Fastly pack members plus extra manifest-only archive; observed links disagree; exact draft mismatch prevents activation; staging isolation; no source hooks; exact/unknown outcomes; interrupted selector writes, first-clone/send-before-response crashes including indistinguishable candidate allocations, secret/reference privacy, shared-resource isolation boundaries and final receipt failure; read-only reconciliation | AC-8 | D6 (integrated enhancement) | +| R-DEPLOY/R-PACK manual portion, R-MANUAL | T-12 provider-free pack/member equality; documented native production limits including skipped upload and manifest precedence; authorized disposable-service production test with submitted wasm equality | AC-9 | D6 | +| R-FOLLOWUP | T-13 docs/policy reject unsupported adapters/actions; #1235 criteria contain no adapter migration, operator replay or offline deploy gate; January supersession link | AC-10 | D1 | +| Operator/CLI/changelog guidance | T-12 manual-route instructions and T-13 unsupported-action diagnostics; document source-header identity and bootstrap prerequisites | AC-9 | D8 | + +Implementation changes MUST run appropriate target-matched tests/lints after each behavioral change and the complete AGENTS.md CI gates before PR handoff. The deferred AC-8 milestone additionally requires upstream EdgeZero API/lifecycle tests; those are not a prerequisite for the AC-9 manual route. No real customer/domain/secret fixture data is permitted. PR #1262 contains implementation as well as documents/schema/plans. Document/schema formatting, committed positive/negative fixtures, reference/traceability checks and automated design review accompany implementation tests; these do not establish pending hosted qualification or maintainer approval. + +## Rollout + +1. Review this contract and its plans, then implement on the same `spec/1235-release-attestation` branch and PR #1262. Approve the contract/code together before merge; this explicitly replaces the separate-spec-PR delivery procedure for this work, without waiving qualification or release authorization. Extend merged #1214/#1213 and reconcile permission-config separation. +2. Prototype Fastly A/B equality, shared JS output, SBOM native/host/JS fixtures and CLI compatibility baselines with throwaway recipes. Record feasibility and review any cohort change before production work; this step does not promise replay of nonexistent retained inputs. +3. Implement reviewed release recipes/tools/cohort, JS inventory, composition generator, facts, validation, measurements, schema/policy, signing and immutable publication. Remove obsolete claims that configuration is baked into wasm from CI workflows, the integration-test setup action, adapter comments/tests and integration helpers, not just `test.yml`. +4. Prepare governance/settings/tag rules, trusted gh/CLI bootstrap and documentation. Exercise publication and verification in a disposable public repository using policy fixtures for that repository; no production-policy override is added. Document/validate the manual verified-byte Fastly production route. Publish the first eligible four-artifact release after all its selected requirements pass. +5. Implement the separate Fastly-only EdgeZero prebuilt request/result and recovery lifecycle, review/pin that upstream revision, then adopt integrated online production/staging in `ts`. Upstream delay does not block step 4, and MUST NOT cause the artifact path to rebuild. +6. Update `docs/guide/getting-started.md`, `docs/guide/edgezero.md`, `docs/guide/error-reference.md` and `docs/guide/cli.md`, annotate #1213's historical header goals, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document platform/tool/token prerequisites, policy identities, manual/integrated distinctions, offline limits and optional comparison results. Historical source builds MUST NOT be relabeled attested. +7. Follow-up specs independently qualify operator reconstruction, offline deployment, each other adapter and stronger builder isolation. #161 continues publisher/config/runtime work; none gates #1235 closure. + +## Acceptance criteria + +- [ ] **AC-1:** Spec, plans and implementation reviewed/approved together in PR #1262; implementation records the selected cohort/profiles before release execution. +- [ ] **AC-2:** Configuration independence, complete input inventory, selected variant and qualified CLI compatibility hold for every selected Build A. +- [ ] **AC-3:** The two fresh JS builds produce byte-identical bundles. Fastly Build A and Build B produce byte-identical `trusted-server-fastly.wasm`. Each native CLI Build B records `matched`, `mismatched`, `failed` or `unavailable` before its deadline; its result MUST NOT change whether that CLI's mandatory A, SBOM or provenance checks pass. +- [ ] **AC-4:** Per-artifact provenance/SPDX/bundles/index/checksums and composition coverage pass their exact evidence and schema checks. +- [ ] **AC-5:** Source facts reconcile #1213, derive target/variant correctly and keep release tags, service versions and final digests distinct. +- [ ] **AC-6:** Approved main (or future explicitly reviewed exact RC) lightweight `v*` tags run same-commit gates and publish the exact immutable verified distribution with least-privilege authority. +- [ ] **AC-7:** Trusted bootstrap, complete online verification, bounded subprocesses and offline build-evidence checks pass; unsupported offline official deployment fails before writes. +- [ ] **AC-8 (integration milestone):** New reviewed/pinned EdgeZero Fastly API supports byte-preserving online production/staging, observed context validation and durable recovery. This item tracks the subsequent integrated enhancement and MUST NOT keep #1235 open when AC-9 satisfies its deployment choice. +- [ ] **AC-9:** Operator verification/CLI/changelog guidance and the manual verified-byte production route are implemented and validated; ordinary source builds cannot be mistaken for canonical bytes. +- [ ] **AC-10:** January binary-attestation guidance is superseded; #161 and deferred adapter/reconstruction scopes are explicit and unsupported actions fail. + +#1235 can close when AC-1 through AC-7, AC-9 and AC-10 satisfy its D1–D8 criteria. AC-8 remains a separately tracked integration milestone if upstream work is delayed. There is no requirement to implement Cloudflare, Spin, Axum, native CLI reproducibility, retained archives or operator replay to close #1235. + +## Appendix A. Review disposition and rationale + +This table accounts for all 32 inline comments in [review 5484754857](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857). It records proposed spec resolution; GitHub conversations are not automatically marked resolved and maintainer acceptance is still required. + +| Review concern | Resolution / requirement | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reusable sandbox (4243031371) | R-VARIANT retains current production default and plans qualified attested reuse; R-FACTS corrects snapshot lifetime; removes nonexistent test feature | +| All-artifact reproducibility gate (4243031376) | R-COHORT/R-REPEAT select Fastly equality, native observation; reconstruction/offline deploy deferred | +| Repository-root scaffold (4243031380) | R-INPUTS names the external-to-CLI-crate repository-root input | +| Ambient variation and job output binding (4243031389) | R-REPEAT varies declared-insensitive inputs and binds artifact IDs/digests to job outputs | +| Distinct assurance dimensions (4243031391) | Profiles chosen before execution; claim/threat tables retain independent limits | +| CLI host baselines (4243031395) | R-PLATFORM keeps glibc 2.34 as a qualification target not yet demonstrated; macOS loader floor 13 with initial hosted-15 qualification, matching-host recipes first; cross-build requires full qualification and actual Intel execution | +| Index/schema and informational records (4243031397) | R-INDEX links closed JSON Schema and field contracts; recipe/rebuild consumer semantics informational; receipts private observations | +| #1214 fit (4243031404) | R-JS adds release delta to private prebuilt interface, no competing strict mode | +| One identical release-wide tsjs set (4243031409) | R-JS twice-builds one shared set, checks prepared inputs and separately checks modules actually retained after linking | +| Compiler wrapper/Syft cost (4243031418) | R-SBOM uses Cargo/lock/Rollup evidence and a repository-owned SPDX generator | +| Native and esbuild coverage (4243031423) | R-SBOM declares/validates actual native selection; R-JS tests denied fallback download | +| Release branches and in-progress CI (4243031426) | R-SOURCE approves exact main/RC policy and runs bounded same-commit release validation | +| Local signer/source revision (4243031429) | R-POLICY pins equal signer/source revisions and defines future identity migration | +| Attestation permission (4243031433) | R-SIGN removes `artifact-metadata: write` for file subjects | +| Settings token (4243031442) | R-PUBLISH uses governance plus actual immutable release check/recovery; no settings-read secret | +| gh prerequisites and bootstrap (4243031449) | R-BOOTSTRAP specifies token/env/path/trust chain; exact qualified gh version | +| Normative evidence graph (4243031456) | R-EVIDENCE/R-POLICY fix all digest/source/builder/caller/action constraints | +| Annotated tag commit confusion (4243031459) | R-SOURCE requires lightweight tags and explicit commit-object validation | +| Prefix signer match (4243031464) | R-POLICY/commands use exact SAN `--cert-identity` | +| Resource bounds (4243031467) | R-BOUNDS supplies concrete limits/deadlines, failure and child cleanup | +| Offline observation protocol cost (4243031470) | R-OFFLINE retains bundle/pin checks; offline official deployment/record deferred | +| Actual EdgeZero surface (4243031476) | R-EDGEZERO sizes new Fastly-only API and bypasses existing build/log/version fallbacks | +| Observed binding validation (4243031479) | R-CONTEXT checks existing base version and exact resulting draft before stage/activation | +| Pack archive facts (4243031481) | R-PACK specifies four prefixed entries, fresh directory and separate manifest-only archive | +| Durable recovery ownership (4243031485) | R-RECOVERY persists before mutation, survives failures/crashes and assigns reconciliation/retry ownership | +| Native deploy limits (4243031488) | R-MANUAL documents production activation, skip, domain creation and manifest precedence | +| Derived facts / #1213 (4243031494) | R-FACTS uses established name, source-only override, Cargo/adapter derivation, external release tag | +| Cloudflare config mismatch (4243031498) | §12 preserves concrete binding-channel investigation and fixture prerequisites for separate adapter spec | +| Spin ABI and OCI facts (4243031502) | §12 records exact WASI 0.3 RC dependency, secret defaults and composition/annotation caveats; no premature migration gate | +| Rollout dependencies (4243031506) | Feasibility precedes recipes; publication/manual route precedes independent upstream integration | +| Stale config assertions beyond CI (4243031509) | Rollout step 3 explicitly covers workflows/action/adapter comments/tests/helpers | +| Phase 2 closure scope (4243031515) | R-FOLLOWUP/acceptance remove cumulative adapter gates from #1235 | + +Body-level findings are addressed by the threat model, BCP 14 requirements, requirement→test→acceptance→issue mapping, named producer-policy paths and the January supersession banner. Deferred Cloudflare notes are retained in §12 rather than lost during scope reduction. + +Verified-byte deployment is selected first because it directly preserves an attested subject without requiring an operator SDK/compiler. Fastly A/B adds affordable measured repeatability; native CLI reconstruction has separate linker/SDK/availability costs. Selected required profiles remain blocking, so this is a reviewed assurance split, not a fail-open release. A custom whole-lockfile SBOM or bare wasm scan misses target/host/JS/native selection; an extra compiler wrapper does not solve native library provenance. A complete-package attestation would vary with publisher metadata, so raw wasm identity and observed package identity stay separate. A future independent builder strengthens a different threat boundary than matching project-controlled builds. + +## Appendix B. Supersession and primary references + +This draft supersedes the binary-attestation direction in [the January proposal](2026-01-15-attestation-design.md); after approval it is the implementation contract for those sections. January runtime/configuration examples remain historical reference for #161, not approved claims, embedded active-config guidance or self-reported executable proof. + +- [Original issue](https://github.com/IABTechLab/trusted-server/issues/1235), [review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857), [JS PR #1214](https://github.com/IABTechLab/trusted-server/pull/1214), [version PR #1213](https://github.com/IABTechLab/trusted-server/pull/1213). +- GitHub [artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations), [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases), [release verification](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/verify-release-integrity), and [reusable workflow limits](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/increase-security-rating). +- gh 2.97.0 [exact/prefix identity policy](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/pkg/cmd/attestation/verify/policy.go) and [tag-target lookup](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/pkg/cmd/release/shared/fetch.go); [verifier manual](https://cli.github.com/manual/gh_attestation_verify), [bundle download](https://cli.github.com/manual/gh_attestation_download), [trusted roots](https://cli.github.com/manual/gh_attestation_trusted-root). +- Pinned [attest 4.2.2 dependency lock](https://github.com/actions/attest/blob/1e69f48acb82d1966a394da916b4c1698aa569d6/package-lock.json) and matching [toolkit provenance semantics](https://github.com/actions/toolkit/blob/7987771a2b727fe14b36351f6c877fa3a56b9c36/packages/attest/src/provenance.ts). +- Pinned [Sigstore certificate summary fields](https://github.com/sigstore/sigstore-go/blob/v1.2.2/pkg/fulcio/certificate/summarize.go). +- [SLSA build requirements](https://slsa.dev/spec/v1.2/build-requirements), [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/), [BCP 14 keyword clarification](https://www.rfc-editor.org/rfc/rfc8174.html). +- Cargo [build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html), [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html), [source remapping](https://doc.rust-lang.org/rustc/remap-source-paths.html) and [Apple targets](https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html); [Rollup output hooks](https://rollupjs.org/plugin-development/), [npm ci](https://docs.npmjs.com/cli/v11/commands/npm-ci/). +- Fastly 15.1.0 [packing](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go), [deployment](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/deploy.go), [build annotation](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/build.go). +- Pinned EdgeZero [adapter surface](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter/src/registry.rs), [Fastly lifecycle](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter-fastly/src/cli.rs), [CLI dispatch](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-cli/src/lib.rs). +- [Cloudflare binding inheritance](https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/), [worker-build pipeline](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/main.rs), [tool versions](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/versions.rs); existing [Axum bridge guidance](../../guide/axum-dev.md). + +Tool availability and API semantics MUST be revalidated at implementation pin selection. Incompatible changes require reviewed policy/spec updates rather than silent fallback. diff --git a/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md b/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md new file mode 100644 index 0000000000..df928fa01a --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md @@ -0,0 +1,121 @@ +# Release attestation feasibility and implementation dependencies + +**Date:** 2026-10-12. + +**Contract:** [Release artifact attestation and verified consumption](2026-10-09-1235-release-attestation-design.md), PR #1262, issue #1235. + +**Status:** Feasibility evidence for a proposed contract. The core build, verification and packaging mechanisms work in local prototypes. The complete four-target release is **not qualified**: hosted production recipes, full SBOM coverage, signing/publication rehearsal, Linux compatibility, Intel execution and live Fastly submission remain required evidence. + +Document/schema checks and independent design review establish consistency. They do not establish that a new workflow can publish, that every native target runs, or that provider operations preserve an intended runtime context. + +**2026-10-14 implementation correction:** Audit prototype scripts and their Python paths/counts are historical measurements, not active implementation instructions or Rust migration proof. Introduced Python release tooling has been superseded by the independent host-only Rust producer in the [migration addendum](2026-10-14-release-tooling-rust-migration.md). Existing Node composition remains. The original native acquisition/SDK, actual hosted-platform, full SPDX, same-run transfer, signing/publication and live-provider gaps remain mandatory; local macOS 26.6.2 does not establish macOS 15 execution. + +## Evidence scope + +The historical audit baseline was `7f6056bf83eb96aa4167efca64d43280299fba73`. Read-only PR checks identified #1214 head `e150780b4a922be0cd0dfbbe1167c72aa076d3e8` and #1213 head `959e503b9e35b0aecd42439edc85ad744bbb92f2`. Both were open during that audit; both merged on 2026-10-12 and are ancestors of the current implementation planning baseline `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52`. Open-PR overlays are historical probe conditions, not current prerequisites. Isolated JS/interface probes overlaid #1214 files onto the baseline; its old `build-all.mjs` diff requires reconciliation with the current refactor. These synthetic snapshots are not an approved release commit. + +Local tools were Rust 1.95.0, Node 24.12.0/npm 11.6.2, macOS 26.6.2 ARM64 with SDK 15.5, Fastly CLI 15.1.0, Viceroy 0.17.0 and downloaded GitHub CLI 2.97.0. The Fastly CLI separately reports a bundled Viceroy 0.21.1; that was not substituted for the pinned runtime probe. Compilation outputs were fresh; Rust source caches/compiler were shared. Prototype epoch input was fixed rather than produced by the final commit-derived recipe. Source-fact #1213 changes and CLI checkout-fallback removal were not applied to the full artifact builds. + +Build outputs and application fixtures stayed in temporary directories. A gh probe with omitted `HOME`/`XDG_STATE_HOME` briefly created anonymous device state relative to the repository; the behavior was reproduced, the exact generated file and empty directories were removed, and R-BOOTSTRAP now requires private home/state handling. Provider operations, repositories, workflows, releases and attestations were not created or modified. Online probes read existing public GitHub release evidence. Runtime requests used localhost fixtures with fictional configuration and secrets. + +## Historical local measurements + +| Probe | Measured result | What it proves | What it does not prove | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Fresh locked JS A/B | All 13 final bundles byte-identical; each test run passed 50 files and 1,208 tests | The historical bundler could produce stable shared input under varied local paths/locale/timezone | Separate hosted workers, all package-origin joins or independent toolchain trust | +| #1214 prebuilt interface | Exact copied module set/hashes; succeeded with npm absent; 7 bundle-set tests passed | Rust can consume one prepared JS set without rerunning npm | Trusted expected-inventory input and a release presence-reject guard; historical upstream accepted skip alongside prebuilt | +| Missing esbuild binary | Network-denied bundle step exited 1 without fetching replacement | Selected Darwin bundler does not silently succeed without its prepared tool | Every installation-time/platform download fallback | +| Network confinement | Unconfined localhost socket succeeded; confined socket failed with `EPERM`; full Rust/JS builds ran inside that profile | Local macOS process-level network denial is available | Qualification of each actual hosted recipe or hostile-runner resistance | +| Full Fastly release A/B | Both complete files were 69,647,265 bytes and matched SHA-256 `2a740c43e93c47b31237861118e1bd75c5bd2940eb3daf1bb382d1104547c658`; no normalization | Fresh Cargo outputs can repeat across source/output paths, umask, locale/timezone and 4-versus-2 build jobs; all 13 JS strings remained | Final Linux hosted recipe, fresh independent dependency materialization, final source facts, publication or operator reconstruction | +| Actual Fastly runtime | Viceroy 0.17.0 `/health` returned 200; publisher `/` separately returned 200, origin fixture body and injected script | This complete prototype wasm parses/links and executes meaningful app behavior with the test envelope | Fastly upload/activation, PoP behavior, production credentials/config or remote execution proof | +| Full ARM64 CLI release | Network-denied `--frozen` release build succeeded; final file 37,977,296 bytes; outside-checkout help/version/config init succeeded | Current whole CLI graph builds/starts locally; scaffold bytes equal repository template | macOS 13/14/15, Intel or GNU startup; final release build-facts/version policy | +| CLI native inputs | Build messages/static archives identified `aws-lc-sys` 0.42.0 (vendored AWS-LC 5.1.0), ring 0.17.14 and `libsqlite3-sys` 0.30.1 (bundled SQLite 3.46.0); four native archives; loader floor 13.0, SDK 15.5 | Whole-CLI native evidence is observable and the explicit source-selection recipe works locally | Complete source/SDK/sysroot/license provenance, final native reachability or cross-platform determinism | +| Final JS byte membership | All 13 complete bundles present in Fastly, none present in the final CLI, despite JS crate compilation | OUT_DIR/compiled membership is not final embedded payload; per-artifact classification is necessary | Exact final function reachability; absence of every possible transformed fragment | +| CLI path literal | Compiled temporary checkout fallback survived Rust path remapping | Literal fallback removal needs code; remapping is insufficient | A qualified official CLI resource policy | +| Cargo dual-context fixture | Compiler JSON separated host/target units/features; metadata merged their feature sets | Effective unit features can be collected from messages; package metadata alone is insufficient | Exact compiler dependency DAG from metadata/tree | +| Rollup hook | Contributions collected for all 13 outputs without changing bundle bytes; current contributors were project modules, not full external Prebid.js | Hook-based contribution evidence is available | Full third-party/license joins, generated helper and elimination fixtures | +| Limited native SPDX prototype | Actual native-linked fixture ran; 30 packages/23 Rust units; 21,933-byte document passed official SPDX 2.3 schema | Selected-unit/native evidence can be serialized as valid SPDX | Production generator coverage, full CLI/Rust/sysroot/JS semantics or signed SBOM equality | +| Exact gh offline verification | gh 2.97.0 verified a signed reusable-workflow fixture under denied networking and absent token; wrong SAN/source/predicate rejected | Exact certificate/source flags, offline roots/bundle primitive and flattened certificate JSON work | Future action/workflow OIDC, official offline release membership or deployment | +| gh state isolation | An omitted home/state path created relative anonymous device state despite isolated gh config; reproduced and cleaned | `GH_CONFIG_DIR` alone does not isolate gh state | Production environment hygiene without explicit home/state paths | +| Live public release verification | gh 2.97.0 verified its immutable release and archive; independent REST checks reported immutable release and commit tag type | Current online release primitives work | This project's governance, RC permissions or new publication workflow | +| Membership negative probe | Renamed downloaded archive still passed `gh release verify-asset` | Command checks digest, not expected local/published filename; wrapper needs signed-name checks | Full release policy from exit zero | +| Pinned toolkit execution | Integrity-checked toolkit 3.2.0 emitted expected current build type/caller/builder/source/run shape with mocked OIDC claims | Current action's predicate construction matches the proposed semantics | Actual certificate issuance for the future hosted local reusable workflow | +| Fastly packaging negatives | Named archive retained exact bytes; generic archive lacked wasm; extra package file was swept in; local settings/secrets were copied; ASCII non-wasm passed both pack and validate | Fresh directory, nonreserved package name, strict inspection, deployment-only manifest and separate wasm/runtime validation are necessary | Deployability or confidentiality from pack/validate exit zero | + +These are measurements from this audit, not claims about a published release. The CLI result is a conservative byte check plus source-use analysis; unused JS must not be added to the CLI merely to make a cross-artifact inventory assertion true. + +## Corrections made to the contract + +1. **Remove unavailable macOS qualification gates.** macOS 13 hosted runners retired on December 4, 2025; macOS 14 retires November 2, 2026. A hosted-only 13/14/15 startup requirement cannot be treated as a routine supported matrix. The initial required matrix is macOS 15 on ARM64 and Intel. Deployment target 13.0 is a loader floor; older runtime support remains unqualified. Intel cross-build is an optional replacement after whole-project qualification, not an assumed first implementation. [macOS 13 retirement](https://github.blog/changelog/2025-09-19-github-actions-macos-13-runner-image-is-closing-down/), [macOS 14 retirement](https://github.blog/changelog/2026-10-01-github-actions-macos-14-runner-image-retirement/). +2. **Distinguish prepared JS from retained code.** One shared input set is feasible, but the current optimized CLI discards its complete bundles. Only final retained modules get an embedded-payload claim. Compiler inputs remain build material. +3. **Make native selection enforceable.** Target-suffixed AWS-LC variables can override a generic selector; SQLite pkg-config can override the bundled feature. The recipe must control both channels and validate actual archives/link directives. Effective Rust features come from compiler messages, not metadata unions. +4. **Implement release policy beyond gh exit statuses.** Additional REST/verified-statement checks are needed for raw tag type, immutability, subject names and source authority. Signed release subjects have names/digests but no size; lengths bind through the signed index, actual bytes and REST metadata. The successful renamed-file negative probe prevents treating `verify-asset` as a name check. +5. **Attribute SBOM comparison correctly.** gh returns the verified predicate; it does not compare a downloaded SPDX file. The strict duplicate-rejecting document comparison belongs to the new Trusted Server wrapper and producer validation. +6. **Match actual artifact-transfer identity.** Actions artifact metadata contains run/source identity but no independent producer-job/attempt field. Job binding comes from the expected upload outputs and workflow topology. Archive transfer hash is distinct from executable SHA-256; mismatch warnings must fail the collector. +7. **Close schema gaps.** Newline-suffixed tag/names matched regex end anchors. Explicit newline rejection now accompanies patterns. Inventory and companion declarations have role-specific size limits, including 16 MiB SBOM. Parser/download limits and cross-document checks still belong to the implementation, not JSON Schema alone. +8. **Prevent deployment-envelope fixture leakage.** Packing carries the entire manifest, including local test values. The production envelope must use an allowlisted deployment-only manifest, without local-server sections, active settings or secrets; such a minimal manifest packed successfully. Sanitized empty/`package` names are rejected to avoid archive selection collisions. Pack/validate do not establish valid wasm; actual core-module parsing and app execution are separate gates. +9. **Bound recovery and isolation claims.** The clone API has no documented caller idempotency key. A lost first-clone response can leave multiple indistinguishable candidate versions; reconciliation can remain unknown and must not mutate a guess. Config staging isolation is not automatic KV/secret/external-effect isolation. Secret values are write-only, so readback validates references/metadata rather than plaintext. [Fastly version API](https://www.fastly.com/documentation/reference/api/services/version/), [resource links](https://www.fastly.com/documentation/reference/api/services/resource/), [secret-store semantics](https://www.fastly.com/documentation/reference/api/services/resources/secret-store-secret/). +10. **Qualify Cloudflare inheritance by endpoint.** Ordinary upload inherits latest and rejects a different version ID. Explicit non-latest version inheritance belongs to beta Workers API Versions endpoints and its own reviewed adapter contract. [Cloudflare error 10057](https://developers.cloudflare.com/workers/observability/errors/). + +## Requirement feasibility register + +“Demonstrated” means only the stated local probe. “Supported” means pinned source/docs expose the needed primitive. “New code” is an implementable contract absent from the existing application. “Unqualified” means a mandatory shipping proof is missing. A composite row can have more than one status because a working primitive does not supply the full policy. + +| Requirement | Status and evidence | Implementation or qualification still needed | +| ------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R-COHORT | New code; fixed profiles are structurally expressible | Source-controlled cohort/collector; all four canonical Build A qualification gates | +| R-PLATFORM | ARM build/run and loader floor demonstrated; GNU/Intel/hosted 15 unqualified | Actual glibc 2.34 builder and ELF symbols/UBI/Ubuntu/Debian tests; hosted ARM/Intel execution; review any baseline change before enablement | +| R-VARIANT | Supported by current Cargo/runtime source; default artifact ran locally | Release variant allowlist/diagnostic; reuse remains separate qualification | +| R-CONFIG | Source-supported separation; runtime fixture loaded independently of wasm | Whole-release differing-publisher-input negative fixture; permission-spec compatibility | +| R-INPUTS | Important inputs and native/script env evidence observed | Complete tracked source/dependency/sysroot/tool/native identities; new generated input coverage | +| R-ENV | Denied socket and selected fresh environments demonstrated | Checked release env whitelist, private gh home/state, all skip values, Cargo/config layers, Vite env suppression and source mutation guards | +| R-JS | Fresh A/B, tests, prebuilt handoff, Rollup/missing-tool probes demonstrated | Current Rust recipe proof, trusted inventory binding, final retained classification, hosted transfer | +| R-BUILD | Full frozen network-denied local builds demonstrated | Digest-pinned hosted recipes; verified fresh material extraction; every selected confinement profile and native bootstrap | +| R-REPEAT | Complete Fastly equality demonstrated locally | Separate hosted Linux jobs, final facts/source, explicit upload outputs/transfer failure gate; optional-native terminal outcomes | +| R-NATIVE | Whole ARM CLI and static native evidence demonstrated | Effective tool/SDK pins, platform linker/signature policy; GNU/Intel measurements; no native equality shipping claim | +| R-SBOM | Limited selected-unit/native serialization prototype demonstrated | Full real artifact SPDX coverage, source/license/roles/sysroot/native/JS joins and negative fixtures; real selected-artifact validation beyond prototype serialization | +| R-FACTS | Cargo inputs and exact #1213 helper semantics supported/probed | Strict full-commit release validation, no git fallback, owning adapter variant, typed facts, CLI source version and literal fallback removal | +| R-SOURCE | Existing branch/CI shape inspected; APIs supported | Protected main/approved-RC policy, trusted same-commit workflow-call validation and pending-check deadline; annotated-tag rejection | +| R-SIGN | Action inputs/toolkit predicate source-supported and shape executed | Real hosted exact-version provenance+SPDX certificates, caller permission propagation, least-privilege jobs and collector | +| R-PUBLISH | Existing public immutable release consumer path demonstrated | Authorized disposable publication rehearsal, repository settings/rules/bypass audit and RC workflow-difference/minimal-token test | +| R-INDEX | Schema and negative structural checks demonstrated | Producer and strict consumer parser; all semantic source/name/policy/digest/size joins | +| R-EVIDENCE | Existing signed bundle/release graphs observed | Full new index/executable/companion/SPDX relationship checker and actual project fixtures | +| R-POLICY | Exact tool flags/certificate JSON demonstrated | Embedded current/historical policy identity, action/caller/run checks, closed profile/action permissions | +| R-BOOTSTRAP | Auth requirement and isolated no-auth offline primitive demonstrated | Trusted gh installation, external first-use CLI verification procedure, explicit token/env/path handling | +| R-BOUNDS | Actual fixture outputs measured; limits expressible | Concurrent bounded-pipe reader, timeouts/cancel/kill/reap, strict JSON/depth/duplicate checks and negative tests | +| R-PUBLICATION | Primitive signatures work; missing name/type/immutable checks confirmed | REST plus verified-statement wrapper and source approval; cannot use command success alone | +| R-OFFLINE | Exact build proof with denied network and rejection cases demonstrated | Document transport trust/pins; official offline deployment remains intentionally unsupported | +| R-DEPLOY | Canonical byte check/source-supported transport | Shared verifier/retained handles/action validation; integrated upstream API is separate milestone | +| R-PACK | Byte-preserving pack and adversarial local envelope cases demonstrated | Allowlisted deployment-only manifest, strict bounded archive inspector and final real-module/runtime validation | +| R-CONTEXT | Exact links/selectors endpoints source-supported | Preflight/resulting-draft readbacks and explicit sharing policy; live propagation/readiness proof; secret plaintext cannot be read | +| R-EDGEZERO | Clone → exact draft update → context → stage/activate APIs supported | New typed upstream API and structured outcomes, no source/manifest hooks; current adapter does not implement it | +| R-RECOVERY | Filesystem journal design implementable; provider uncertainty boundary identified | Flush/atomic records before each write, exact-version inspection where known, unresolved clone handling/crash fixtures; no exactly-once guarantee | +| R-MANUAL | Pack/flags verified; actual wasm ran only locally | Complete verified policy/inspection instructions and separately authorized disposable Fastly production submission | +| R-FOLLOWUP | Existing adapter facts inspected with endpoint correction | Independent Cloudflare/Spin/Axum/reconstruction specs; no additional issue-closure gate here | + +## Executable implementation order + +The remaining implementation MUST produce these concrete interfaces and evidence; this is a dependency analysis, not a claim that the named scripts already exist. + +1. **Reconcile inputs and facts.** Extend merged #1214 and #1213; add exact expected JS inventory validation, strict release fact/skip/env handling and disable the compiled CLI checkout fallback. Preserve ordinary development behavior separately. Exercise these guards before adding signing. +2. **Qualify actual platform recipes.** The approved-source native Rust producer, reached through thin `scripts/release/build.sh`/`bootstrap.sh` adapters, owns source/material preparation, effective settings, confinement and final artifacts; CLI/core/JS dependencies cannot enter the producer bootstrap. Linux can use a digest-pinned OCI environment with no compile network. macOS needs a tested confinement/SDK/native profile on the selected hosted machines. Material preparation and network-denied compilation are separate. Establish GNU 2.34 and macOS 15 ARM/Intel startup before approving the fixed first cohort; a failed baseline requires a reviewed change, not an after-failure downgrade. +3. **Generate actual evidence and SPDX.** Collector inputs are Cargo JSON, selected metadata/tree, checked material identities, Rollup inventory, native output/link declarations, sysroot/tools and final files. Produce prepared and retained-module lists separately. Validate real Fastly and each CLI SBOM, not only a toy fixture. Then qualify the Rust schema evaluator with explicit offline Draft 2020-12 and Draft 7 semantics, retain the authoritative schema raw-byte identity, and prove negative/actual coverage tests. +4. **Implement same-commit validation and transfers.** Refactor current CI into reviewed callable validation or equivalent explicit release jobs. Run required validations at the tag SHA, shared JS A/B, Fastly A/B and required native Build A with bounded optional comparisons. Upload selected raw outputs; pass artifact ID/archive hash through explicit job outputs. Fetch exact current-run ZIP bytes, fail transfer mismatch and inspect/extract only declared bounded members. Do not restore compiled state across the supposedly independent builds. +5. **Sign and publish through a tested boundary.** A separate signing/collector job authenticates source/input/output/evidence relationships before invoking the pinned action twice per artifact and signing the index. An existing lightweight tag avoids relying on automatic tag creation. Caller job permissions must allow the called signing jobs while build jobs explicitly remain read-only. The publication job handles draft/resume/upload/redownload/publish/reconcile with contents-write. Rehearse main and RC workflow divergence, partial drafts, reruns, optional failures and mutable/unknown publication before production enablement. [Release API permission behavior](https://docs.github.com/en/rest/releases/releases#create-a-release). +6. **Implement the online consumer.** A shared library performs strict schema/policy joins, invokes bounded pinned gh on retained bundles, checks all certificate/provenance/SPDX fields and verifies release API/statement semantics. Its private verified result retains selected bytes. `ts release verify` is read-only; manual packing uses that result plus a deployment-only envelope and archive inspector. Exact identity flags alone do not implement the evidence graph. +7. **Deliver the manual verified-byte production route.** Authorize a disposable provider test separately and retain the submitted-member/version observations. This route does not certify integrated staging, preactivation validation or remote execution. It can precede the upstream API. +8. **Implement the separate Fastly upstream milestone.** EdgeZero `deploy_prebuilt` takes retained payload identity, exact base/service/mode and resolved context. Use explicit clone, non-autoclone update of the returned draft, exact draft link/selector validation, stage/activate. Durable journal/pending markers and read-only reconciliation belong here. Lost first-clone identity remains unknown if the provider cannot uniquely establish it; do not invent an exactly-once contract. + +Future stronger builder isolation, independently reconstructed executables, offline official membership and additional adapters use separate proof boundaries. They are not shortcuts to close missing evidence in this initial pipeline. + +## Remaining qualification boundaries + +The local machine had no usable Docker daemon, old-macOS or Intel execution host. GNU compatibility was not inferred from Cargo metadata or an ARM native fixture. Actual hosted action/OIDC, artifact transfer, repository governance and RC release-token behavior require an authorized disposable GitHub rehearsal. Live package/bindings/stage/activation require an authorized disposable Fastly service. No such resources were mutated during this audit. + +These gaps do not establish impossibility of the narrowed architecture; their APIs and locally measured primitives support implementation. They do prevent claiming that all required release behavior is already feasible as a tested composition. Release enablement remains blocked until those selected qualifications and the new code pass. The known unavailable-host gate and overstated existing-tool/final-JS/recovery claims have been removed or corrected rather than passed to an implementation plan as assumptions. + +## Primary implementation references + +Historical temporary probe scripts/logs are not retained or reviewer-reproducible current proof. The measurements above preserve their historical scope. Current committed Rust document/schema tests supply local utility evidence; actual selected native boundaries, complete recipes and hosted release/provider proofs remain pending. + +Pinned implementation references: [gh 2.97.0](https://github.com/cli/cli/tree/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55), [attest 4.2.2](https://github.com/actions/attest/tree/1e69f48acb82d1966a394da916b4c1698aa569d6), [toolkit provenance](https://github.com/actions/toolkit/blob/7987771a2b727fe14b36351f6c877fa3a56b9c36/packages/attest/src/provenance.ts), [Fastly 15.1.0 pack](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go), [validate](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/validate.go), [clone](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/service/version/clone.go), [EdgeZero adapter](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter/src/registry.rs). Cargo [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html) and [build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html), [SPDX 2.3 schema](https://github.com/spdx/spdx-spec/blob/v2.3/schemas/spdx-schema.json). diff --git a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md new file mode 100644 index 0000000000..d291333901 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md @@ -0,0 +1,55 @@ +# Release tooling Rust migration addendum + +**Status:** Draft implementation revision for PR #1262 on `spec/1235-release-attestation`. The user authorized implementation on this branch and directed removal of introduced Python tooling on 2026-10-14. This addendum implements that language constraint; maintainer contract/code approval, independently approved source and every selected release qualification remain outstanding. No new branch, worktree or PR is required. + +**Authority:** The [governing contract](2026-10-09-1235-release-attestation-design.md), [feasibility register](2026-10-12-1235-release-attestation-feasibility.md) and [22-task implementation plan](../plans/2026-10-13-attested-release-implementation.md) retain their security requirements. This migration does not amend the index schema, cohort, capabilities or vendor-source acquisition rules. + +## Decision and alternatives + +Use a small domain-specific host-only `crates/trusted-server-release` binary. It follows the existing host-only codegen workspace-member precedent, uses Rust 2024 and pinned Rust 1.95.0, and is a workspace member outside `default-members`. It has no dependency on core, CLI or JS crates and is not a fifth canonical release asset. Existing `collect-js.mjs` and the Node composition observer remain responsible for actual bundling/contribution capture; POSIX wrappers remain thin fixed adapters. + +Moving the producer into `ts` would reuse command dispatch but introduces CLI → core → JS build dependencies before JS preparation. That bootstrap cycle prevents the CLI from producing its own prerequisite JS evidence. Tasks 17–20 therefore keep `ts` as the read-only consumer. Expanding Node to own all native release security would reuse an installed runtime but duplicate native process/archive/platform enforcement and still require separately authenticated Node acquisition. A generic attestation crate adds an unsupported abstraction and unnecessary public APIs. The independent Rust tool is the smallest safe boundary for the existing producer requirements. + +## Modules and data flow + +Create only modules needed by the current migration stage. `documents`, `policy`, `preflight` and `source` establish bounded parsing and independently approved fixed inputs. `materials`, `process`, `boundary` and `preparation` authenticate original acquisitions and execute fresh work under the selected native denial boundary. `javascript` invokes retained Node tooling, records actual lifecycle observations, completes two independent offline installations/tests/builds and compares all final bundles. `qualification` checks reviewed platform evidence. `recipe`, `inspect` and `composition` drive actual Cargo compilation, final-byte/runtime acceptance and observed Rust/native/sysroot/JS evidence. `spdx`, `collection`, `distribution`, `publication` and `package` supply the remaining existing producer tasks without changing signing/publication ownership. + +The flow is independently approved source → authenticated acquisitions → current native installed-tool rehash → actual confined execution → private typed completion → checked artifact/composition/transfer records → isolated signer → sole immutable publisher. Constructors for source/acquisition approval and JS completion remain private to their successful verification/execution paths. Serialized booleans, source labels, an arbitrary inventory or a pathname plus checksum cannot construct authority. Retained bytes are rehashed at handoff/use. All-cohort remotely reviewed qualification receipts are separate from evidence about tools actually installed in the current process environment; neither substitutes for the other. + +Unit tests remain private alongside their modules or in registered child `tests.rs` files. Public command-process tests live in `tests/commands.rs`; data fixtures live in `tests/fixtures/`. Register tests before filtered runs and require a nonzero selection with the plan's POSIX `awk` guard. Do not expose internal APIs for integration tests. + +Reuse existing workspace serde/serde_json, error-stack, derive_more, clap, SHA, log, tempfile, toml, gix, libc and flate2 only where needed, with minimal dependency features. Existing bounded TAR/ZIP, Draft 2020-12/Draft 7 schema and ELF/Mach-O/core-Wasm requirements justify maintained format parsers. Exact replacement versions require primary-source research, lockfile review, acquisition identity and qualification; no library selection itself is a qualified tool pin. The researched [jsonschema 0.58.6 options](https://docs.rs/jsonschema/0.58.6/jsonschema/struct.ValidationOptions.html) are a candidate for explicit-dialect, offline-only validation with default network/file features disabled. No optional language binding, arbitrary public schema path or automatic reference retrieval is selected. + +Strict JSON parsing rejects duplicate keys through a recursive Serde visitor before insertion, limits container depth to 64, preserves original bytes for SHA-256 and enforces every role size bound before allocation/download. Before `deserialize_any`, a string-aware lexical scanner validates every numeric token across the whole document, including opaque fields and top-level values: negative integers fit `i64`, nonnegative integers fit `u64`, and decimal/exponent tokens parse as finite `f64`. Quoted number-like text and escaped string contents remain text. The visitor's finite-float check alone cannot reject integer overflow that Serde converts to a finite float; the plan's example is an ambiguity parser requiring this strict prevalidation. The existing JS build inventory scanner supplies a behavioral reference without a JS-crate dependency. Tests cover opaque/top-level integer overflow, decimal/exponent overflow and quoted-text controls; closed role checks additionally enforce safe-integer limits and negative/noninteger-length rejection. Schema checks accompany semantic joins and actual coverage. Governing schemas use Draft 2020-12; the official SPDX 2.3 schema uses Draft 7. The retained [official schema source](https://github.com/spdx/spdx-spec/blob/aadf3b0b8dbbabdb4d880b0fc714255fea436ff7/schemas/spdx-schema.json) is 45,312 bytes, SHA-256 `239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b`. This measured candidate identity is not schema-engine or real-SBOM qualification. + +## Bootstrap and unchanged release gates + +POSIX bootstrap first establishes independently approved source and an authenticated OS-native Rust source-build chain, then builds/selects the host tool and records its complete executable source/dependency/tool closure. A colocated self-authored SHA manifest does not authenticate that chain. Developer `cargo run/test` commands establish local behavior only; the source-built tool has no official release-binary proof. The producer executable is not distributed as a canonical asset. Pending approval, acquisition roots or measured pins fail closed; downloaded evidence cannot open a candidate/production selector. + +The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. + +The four canonical assets remain default Fastly Wasm plus three `ts` executables. Complete final Fastly A/B equality, both final-byte application checks and all canonical A/SPDX proofs are mandatory. Native B remains a bounded optional measurement. GNU remains glibc ceiling 2.34 with three clean compatibility runtimes; macOS requires actual native 15 ARM/Intel execution with loader floor 13.0. macOS 26.6.2 local diagnostics cannot satisfy those gates. + +Real positive/denied native socket controls and actual Cargo/npm missing-tool/download attempts use the same boundary as production execution. Source/object stores and acquired shared inputs are read-only; private profile/install/output writes remain bounded. Children and descendants are killed/reaped on timeout, cancellation or pipe limits, and temporary profiles/resources plus Docker containers are cleaned up with bounded stop/remove behavior. Rust does not waive acquisition/SDK/native archive identity, Cargo effective unit features, final retained JS classification or conservative reachability limits. Original Fastly vendor archive authentication remains a separate unsatisfied gate until independently reviewed vendor-source evidence is available. + +One `release.yml` owns publication and same-source `release-build.yml` owns signing. Exact action 4.2.2/full SHA and gh 2.97.0 contracts, SLSA Build Level 2 target, exact current-run artifact/attempt/topology bindings, strict SPDX/graph joins and immutable publication remain unchanged. `ts release verify` remains read-only. Manual Fastly consumption inspects the approved four-member archive with verified exact Wasm bytes; integrated EdgeZero deployment/recovery and future packaging retain their separate approval/proof boundaries. + +## Migration order and evidence + +The active plan keeps Task IDs 1–22 and prepends migration checkpoints: M0 verify removal/fail-closed wrappers; M1 Rust documents/preflight/source; M2 original materials/process/OS boundaries; M3 real two-installation JS completion; M4 actual canonical recipes/inspection/composition; then finish Tasks 10–22. Full AGENTS CI and independent review remain required before handoff. No resource creation/dispatch, signing, publication or provider operation follows from this language correction. + +Introduced Python producer tooling and requirements are removed. The host-only crate implements strict documents/policy, local source/preflight checks, a helper-closure fingerprint, bounded process supervision and final-artifact inspection. Rust build-fact and checked-inventory guards and Node composition are implemented. The experimental macOS profile kills and rejects descendants that escape their process group, even with a scrubbed environment, by identifying its per-run sandbox instance; it remains unqualified for M2 lifecycle containment. Original npm/Cargo Git material verification and M3 real two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI ([evidence](../../../scripts/release/qualification/2026-10-15-js-two-builds.md)). That CI candidate is not release qualification. Qualification of M2 and M4 (candidate recipes for all four assets pass ordinary CI, unqualified), qualified macOS lifecycle containment, independently approved acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. + +## Mandatory replacement regressions + +Tasks 3 and M2/Tasks 7–9 MUST carry the original failure cases into real Rust tests before qualification: + +- Audit the true Git common directory for linked-worktree alternates/grafts/shallow/replacements/config/environment channels; reject unsupported linked indirections instead of partially checking them. Preserve bounded detailed Git errors. Reject untracked extensionless `rust-toolchain` before toolchain selection. Join approved source and original inventory commits. +- Audit inherited hosted/setup variables before clearing them. Exact reviewed/qualified values and path identities are required for accepted `RUSTFLAGS`, `VCPKG_INSTALLATION_ROOT` and other selectors; unreviewed values fail. Production selection MUST reject all pending/null pins by default. Named audit diagnostics cannot construct authorization state. +- Docker MUST use its default private PID namespace, not invalid `--pid=private`. Both host-owned seccomp profiles and empty Docker configuration MUST be outside container-writable roots; scan before creation and rehash both before every host command. Select authenticated client/config/Unix daemon, no TTY/plugins/context/environment overrides, read-only anchors, and reject escaping/dangling aliases in the container namespace. Actual positive/denial/whole-recipe execution is required, not argv checks alone. +- Every immutable macOS source/object regular file MUST have `nlink == 1` before execution. Actual hard-link write-through/restoration negatives are required. Signals MUST be restricted to `same-sandbox`, with own-child success and outside same-user sibling/orchestrator denial. +- Observations MUST join the approved-source catalog, selection/version/authentication identities, SDK/raw receipts and the complete compiled helper closure, including all path-dependency manifests such as `crates/trusted-server-openrtb/Cargo.toml`, tool-version files and selected executable digests. Source changes invalidate the closure. Labels, boolean claims and checksums cannot self-qualify. +- Container CPU/memory limits MUST fit actual host capacity; Cargo A jobs 4/B jobs 2 does not require four container CPUs on a two-vCPU host. Record actual errno instead of inventing `ETIMEDOUT`, hash the actual shell instead of assuming `/bin/sh --version`, and use host-independent environment/tool-path fixtures without ambient shim or interpreter trust. +- Both actual JS A/B test processes MUST unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` and execute the full registered suite/type checks. Lifecycle/Cargo/compiler/proc-macro/native children MUST inherit OS-enforced denial. Timeout/cancellation/pipe limits MUST kill and reap descendants; token/provider/injection environment is cleared. + +The [primary plan](../plans/2026-10-13-attested-release-implementation.md#threat-to-negative-proof-trace) maps these pending proofs to TH IDs and existing T-1–T-13 families. Ordinary native CI checks utility behavior and nonzero test selection; it does not qualify native bootstrap or recipes. Manual verified-byte consumption/documentation is AC-9/D6/D8; integrated context, staging, preactivation and recovery remain AC-8 and do not block the manual route. diff --git a/docs/superpowers/specs/schemas/release-index-v1.schema.json b/docs/superpowers/specs/schemas/release-index-v1.schema.json new file mode 100644 index 0000000000..1adaa0d20f --- /dev/null +++ b/docs/superpowers/specs/schemas/release-index-v1.schema.json @@ -0,0 +1,581 @@ +{ + "$comment": "Evaluate explicitly with JSON Schema Draft 2020-12 as required by R-INDEX. This dialect declaration is part of the normative specification.", + "title": "Trusted Server release-index version 1", + "description": "Normative structural contract. The specification adds exact cohort membership, unique asset/companion names, companion naming, byte digest checks, source approval and evidence-policy checks.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "repository", + "tag", + "source_commit", + "recipe_source_commit", + "cohort_source_commit", + "cohort", + "policy_version", + "policy_sha256", + "tsjs_inventory", + "artifacts" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "repository": { + "const": "IABTechLab/trusted-server" + }, + "tag": { + "type": "string", + "maxLength": 128, + "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "recipe_source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "cohort_source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "cohort": { + "const": "phase1-fastly-cli" + }, + "policy_version": { + "type": "integer", + "minimum": 1, + "description": "Producer policy at source_commit. Must match an independently trusted accepted historical producer policy; does not select consumer policy." + }, + "policy_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 of producer policy bytes at source_commit; consumer acceptance is defined by the specification.", + "not": { + "pattern": "[\\r\\n]" + } + }, + "tsjs_inventory": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "name": { + "const": "tsjs-inventory.json" + }, + "size": { + "maximum": 1048576 + } + } + } + ] + }, + "artifacts": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/artifact" + }, + "allOf": [ + { + "contains": { + "properties": { + "name": { + "const": "trusted-server-fastly.wasm" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "name": { + "const": "ts-x86_64-unknown-linux-gnu" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "name": { + "const": "ts-aarch64-apple-darwin" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "name": { + "const": "ts-x86_64-apple-darwin" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + } + ] + } + }, + "$defs": { + "file": { + "type": "object", + "additionalProperties": false, + "required": ["name", "size", "sha256"], + "properties": { + "name": { + "type": "string", + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 536870912 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "not": { + "pattern": "[\\r\\n]" + } + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "size", + "sha256", + "role", + "kind", + "package", + "target", + "variant", + "assurance_profile", + "capabilities", + "compatibility", + "js_inventory_sha256", + "companions" + ], + "properties": { + "name": { + "enum": [ + "trusted-server-fastly.wasm", + "ts-x86_64-unknown-linux-gnu", + "ts-aarch64-apple-darwin", + "ts-x86_64-apple-darwin" + ] + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 536870912 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "role": { + "enum": ["server", "operator-cli"] + }, + "kind": { + "enum": ["wasm-core", "native-executable"] + }, + "package": { + "enum": ["trusted-server-adapter-fastly", "trusted-server-cli"] + }, + "target": { + "enum": [ + "wasm32-wasip1", + "x86_64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ] + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "enum": ["provenance-sbom-repeatable", "provenance-sbom"] + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": ["fastly-production", "fastly-staging", "cli-execute"] + } + }, + "compatibility": { + "$ref": "#/$defs/compatibility" + }, + "js_inventory_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "companions": { + "type": "object", + "additionalProperties": false, + "required": [ + "sbom", + "provenance", + "sbom_attestation", + "build_evidence", + "build_recipe", + "rebuild_check" + ], + "properties": { + "sbom": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 16777216 + } + } + } + ] + }, + "provenance": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "sbom_attestation": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "build_evidence": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "build_recipe": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "rebuild_check": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + } + } + } + }, + "oneOf": [ + { + "properties": { + "name": { + "const": "trusted-server-fastly.wasm" + }, + "role": { + "const": "server" + }, + "kind": { + "const": "wasm-core" + }, + "package": { + "const": "trusted-server-adapter-fastly" + }, + "target": { + "const": "wasm32-wasip1" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom-repeatable" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": ["fastly-production", "fastly-staging"] + }, + "contains": { + "const": "fastly-production" + } + }, + "compatibility": { + "properties": { + "runtime": { + "const": "fastly-compute" + }, + "architecture": { + "const": "wasm32" + } + } + } + } + }, + { + "properties": { + "name": { + "const": "ts-x86_64-unknown-linux-gnu" + }, + "role": { + "const": "operator-cli" + }, + "kind": { + "const": "native-executable" + }, + "package": { + "const": "trusted-server-cli" + }, + "target": { + "const": "x86_64-unknown-linux-gnu" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom" + }, + "capabilities": { + "const": ["cli-execute"] + }, + "compatibility": { + "properties": { + "runtime": { + "const": "linux-gnu" + }, + "architecture": { + "const": "x86_64" + } + } + } + } + }, + { + "properties": { + "name": { + "const": "ts-aarch64-apple-darwin" + }, + "role": { + "const": "operator-cli" + }, + "kind": { + "const": "native-executable" + }, + "package": { + "const": "trusted-server-cli" + }, + "target": { + "const": "aarch64-apple-darwin" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom" + }, + "capabilities": { + "const": ["cli-execute"] + }, + "compatibility": { + "properties": { + "runtime": { + "const": "macos" + }, + "architecture": { + "const": "aarch64" + } + } + } + } + }, + { + "properties": { + "name": { + "const": "ts-x86_64-apple-darwin" + }, + "role": { + "const": "operator-cli" + }, + "kind": { + "const": "native-executable" + }, + "package": { + "const": "trusted-server-cli" + }, + "target": { + "const": "x86_64-apple-darwin" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom" + }, + "capabilities": { + "const": ["cli-execute"] + }, + "compatibility": { + "properties": { + "runtime": { + "const": "macos" + }, + "architecture": { + "const": "x86_64" + } + } + } + } + } + ] + }, + "compatibility": { + "type": "object", + "additionalProperties": false, + "required": ["runtime", "minimum_version", "architecture"], + "properties": { + "runtime": { + "enum": ["fastly-compute", "linux-gnu", "macos"] + }, + "minimum_version": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "architecture": { + "enum": ["wasm32", "x86_64", "aarch64"] + } + }, + "oneOf": [ + { + "properties": { + "runtime": { + "const": "fastly-compute" + }, + "minimum_version": { + "const": "wasip1" + }, + "architecture": { + "const": "wasm32" + } + } + }, + { + "properties": { + "runtime": { + "const": "linux-gnu" + }, + "minimum_version": { + "const": "glibc-2.34" + }, + "architecture": { + "const": "x86_64" + } + } + }, + { + "properties": { + "runtime": { + "const": "macos" + }, + "minimum_version": { + "const": "13.0" + }, + "architecture": { + "enum": ["x86_64", "aarch64"] + } + } + } + ] + } + } +} diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md new file mode 100644 index 0000000000..aeb877b790 --- /dev/null +++ b/scripts/release/js-composition.md @@ -0,0 +1,269 @@ +# JS composition producer interface + +`collect-js.mjs` reuses the ordinary build pipeline with a read-only Rollup +observer. Its version-1 output follows the closed Task 5 root/module schema. +It requires the existing `TRUSTED_SERVER__RELEASE_BUILD=1` control and a full +`TRUSTED_SERVER__GIT_VERSION`. Any skip presence or invalid release control fails. +Expected entry points come from the selected full commit’s Git tree, independently +of the current index. The collector builds only its own checkout, so imported +build/tool code cannot be measured as a different checkout’s installation. Source-byte immutability remains preparation’s check. +The release options disable Vite configuration and `.env` loading and select +production mode; Vitest uses the same release controls. These options do not +remove inherited Node/npm/Vite environment variables: release preflight owns +that rejection. + +## Contribution semantics + +Contributions with positive Rollup `renderedLength` carry +`stage: "rollup-rendered"` and `rendered_length`. Type-only and wholly eliminated +modules are excluded. Later esbuild minification can remove additional code. +These records describe conservative post-Rollup reachability, not final function +reachability or attribution of final bundle bytes. Final module sizes and SHA-256 +are measured from files after Vite/esbuild finishes. + +Source contributions contain repository-relative `path`, original-file `sha256` +and `transform` (`identity`, `raw` or `inline`). npm contributions additionally +contain exact `lock_path`, `package_name`, `package_version` and archive +`integrity`; their paths are relative to the owning installed package. Only the selected registry’s canonical HTTPS tarball sources are supported; +links, missing sources and file/git sources fail. Parent/root aliases are +normalized before nearest-owner lookup; package-root aliases and escapes fail. Unsupported +queries, unmapped owners, symlink escapes and ambiguous identities fail. + +Generated contributions contain `id`, `plugin`, `owner_lock_path` and +`generated_sha256` of the actual transformed virtual code. The reviewed mappings +are Vite's bundled CommonJS helper, CommonJS ES-import wrappers and Vite's preload +helper. Wrappers additionally record the underlying file's `input` origin. +Unknown virtual producers fail. Wrapper IDs use normalized input paths; +transformed-code hashes still measure actual code, which can include checkout +paths. Contribution inventories are not a claim that different checkout paths +produce identical virtual-code hashes. The bundle-byte comparison is separate. + +## Checked preparation input + +The producer accepts a bounded strict UTF-8 JSON document with exactly: + +- `schema_version: 1`; +- `packages`: the full measured installed `npm-package` material set; +- `distributions`: records with exactly `material` and `path`; +- `tools`: records with `name`, `version`, `role`, `material_id`; +- `lifecycle_events`: observed executions, including executions with no output; +- `lifecycle_outputs`: observed output files. + +A package material contains `kind: "npm-package"`, `id: "npm:"`, +`lock_path`, `name`, `version`, `integrity` and `installed_tree_sha256`. +Only installed locked locations are recorded; optional absent platform packages +are not claimed installed. The selected native esbuild and Rollup packages are +mandatory through the actual-tool checks. + +Each distribution material contains `kind: "tool-distribution"`, `id`, `name`, +`version`, `sha256`. `path` selects the actual measured tool entry file or native +executable, never a command to run. Required tools are Node, npm, Vite, Rollup, +esbuild, Vitest and the selected esbuild/Rollup native executables. Paths, versions, +roles and file digests MUST match the producer's actual tool selection. Whole +locked npm package trees are recorded separately. Tool entry/native file hashes +are measured identities; they are not authenticated distribution acquisition or +qualified host evidence. Task 8 supplies that boundary. + +Lifecycle events contain `kind: "lifecycle-event"`, unique `id`, +`package_material_id`, `event` and `script_sha256`. The script digest is SHA-256 +of the UTF-8 command string in the owning installed package's +`package.json.scripts[event]`. Outputs contain `kind: "lifecycle-output"`, unique +`id`, the same event/script/package join, installation-relative `path`, `size` +and `sha256`. Each output MUST remain inside its owning package and match an +explicit observed event. Registry-install coverage requires exactly the installed packages’ declared +preinstall, install and postinstall hooks, including hooks producing no files. +Registry dependency prepare scripts do not run in the selected npm ci recipe. +Implicit binding.gyp installs without explicit reviewed hooks are rejected. Neither array is inferred from lockfile flags. Task 7 MUST produce +these records from actual preparation execution; semantic checks here cannot +prove that an execution occurred or that the observations are exhaustive. +Synthetic fixture observations exercise joins but do not claim genuine execution +history. Missing required events cannot produce an inventory. + +## Installed tree hash + +`installed_tree_sha256` hashes the UTF-8 domain separator +`trusted-server-installed-tree-v1\n`, then entries in recursive sorted pathname +order. A directory contributes compact JSON `[relative_path,"directory"]` plus +newline. A regular file contributes compact JSON +`[relative_path,"file",byte_length,is_executable]`, newline, actual bytes and +newline; `is_executable` means any executable bit is set. A symlink contributes +compact JSON `[relative_path,"symlink",relative_target]` plus newline and MUST +resolve inside its package. Separately locked nested package roots and their +contents are excluded from the parent's hash. Timestamps, owners and +non-executable permission bits are excluded. Files are read through held +regular-file descriptors with mutation checks and bounded buffers. + +JSON and final inventory are limited to 1 MiB; source and final bundle reads are +limited to 32 MiB. Duplicate keys, invalid UTF-8, BOM, unpaired surrogates, +unsafe integers, non-finite numbers and more than 64 nested containers fail. +The inventory writer sorts module/material/tool records and appends one newline. +The inventory MUST live outside the bundle directory. This task does not perform +fresh installation, network confinement, A/B comparison, signing or publication. + +## Independent Rust preparation recipe + +The private Rust preparation and comparison recipe uses the retained Node collector. +`build-js.sh` remains a thin POSIX adapter to the independently approved-source +OS-native `trusted-server-release build-js` command. Until its authenticated Rust +bootstrap and qualified source-owned selections are implemented, the wrapper +refuses before creating work directories. No public fixture bypass, unchecked +interpreter or colocated self-authored SHA file establishes bootstrap authority. +The host-only tool has no core/CLI/JS crate dependency; it retains this Node +collector and composition pipeline. See the [migration addendum](../../docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md). + +The recipe lives in private `materials`, `process`, `boundary`, +`preparation` and `javascript` modules. Each installation owns a fresh copy of +approved tracked source bytes, cache, node_modules, HOME, TMP, lifecycle observations +and bundles. The parent authenticates the approved Git objects and derives the +complete module set; it supplies that set and the source commit through a protected +read-only source-context document. JS working copies contain no inherited `.git`, +configuration or history. Git material snapshots for Cargo remain separate. +Original sources/object stores and shared authenticated archives/tools are read-only; +sibling data/metadata, shared writes and network sockets are denied. The shared JS +execution path selects Linux containers with owned descendant cleanup; macOS native +recipes require their own lifecycle containment and qualification. Current installed-tool +rehashing is separate from independently reviewed all-cohort hosted evidence. +Local macOS 26.6.2 diagnostics cannot qualify macOS 15 ARM/Intel or GNU. + +Archive filenames remain locked SHA-512 hex digests followed by `.tgz`. Authenticate +original bytes before bounded decompression/TAR inspection. Preserve the source-owned +exception for three exact locked npm archives containing identical +`package/./dist/index.js` and `package/dist/index.js` entries: agent-base 7.1.4, +http-proxy-agent 7.0.2 and https-proxy-agent 7.0.6. Resolve their original locked +SHA-512 identities and require matching regular type, size, mode and bytes; +materialize the canonical file once. Other duplicates, dot aliases, traversal, +links, devices and privileged modes fail. Eight exact locked type-package +versions use reviewed legacy roots: `@types/chai` 5.2.3 (`chai`), +`@types/deep-eql` 4.0.2 (`deep-eql`), `@types/estree` 1.0.8 (`estree`), +`@types/jsdom` 27.0.0 (`jsdom`), `@types/json-schema` 7.0.15 (`json-schema`), +`@types/json5` 0.0.29 (`json5`), `@types/node` 24.10.13 (`node v24.10`), +and `@types/tough-cookie` 4.0.5 (`tough-cookie`). Seven of these originals +(all except json5) may use the observed node-tar timestamp layout: empty +130-byte prefix, a zero separator, two eleven-digit octal timestamps with +NUL terminators, and zero remaining reserved bytes. No timestamp propagates +to extracted files. Other hidden name/prefix bytes fail. + +Only locked `gulp-wrap` 0.15.0 may carry local PAX metadata. Each bounded +record uses the reviewed metadata-key allowlist; duplicate, unknown and +malformed fields fail. Its path and size must exactly match the immediately +following raw regular-file header, and its own name must be +`PaxHeader/`. Global, consecutive and dangling records and any +path/size override fail. These exceptions apply only after original-byte +authentication and exact name/version selection; they are not general +extension support. + +Registry archive SHA-256/exact-root and +Git revision/whole-tree validation remain available to Cargo preparation. Query +selected Git object identity/type/size before body acquisition: reject objects +over 32 MiB or cumulative bodies over 512 MiB, then rejoin exact body headers. +This reader assumes the sanitized trusted Git implementation and an immutable +object store, rather than claiming adversarial race-proof streaming. + +The Rust dependency archive inspector limits each authenticated compressed +original to 64 MiB, decoded TAR bytes to 512 MiB, each member to 128 MiB, +entries to 100,000, path depth to 64 and path text to 4,096 bytes. It accepts +ASCII regular-file and directory names under the exact locked root, rejects +case aliases and unsupported extension records (including unreviewed PAX, +GNU long-name and sparse records), and requires a single complete gzip member with complete +TAR zero terminators and no hidden trailing data. These are dependency archive +limits, separate from toolchain distributions and Git object limits. The +128 MiB member limit accommodates the current locked optional OpenBSD Rollup +binary expands to 110,275,248 bytes. Hashing and extraction remain streamed; +this does not increase the compressed-original or total decoded-byte limits. Extraction +owns a fresh private directory and preserves original compressed bytes. File +permissions become owner read/write plus the archive's executable bits; archive +ownership, timestamps and other permission bits do not propagate. This stage +does not establish an installed dependency, executed hook or completed JS build. + +Seed each private cache through selected npm's public `cache add` API with only +verified archives, offline and scripts disabled. Run fresh pinned npm 11.19.0 +`ci --offline --include=dev --no-audit --no-fund` with hooks in the foreground +under the qualified lifecycle denial boundary. Reject root install/prepare hooks +and implicit binding.gyp installs. Preserve the two exact reviewed dependency +hooks and actual starts/completions/failures/script hashes/changed-owned-files, +including no-output hooks and swallowed optional failures. Bind bare `node` to +selected Node 24.20.0 despite package-bin shadowing. Missing prepared native +Vite/esbuild tools MUST fail without fetching substitutes. Observed lifecycle +records cannot be inferred from lockfile flags or successful npm exit alone. + +The source-owned declaration policy in the Rust material reviewer permits only +these exact dependency install-hook tuples. It checks the name/version against +the approved lock, the manifest bytes against the authenticated extraction, and +the command bytes against this policy: + +| Package | Version | Event | Exact command | +| --------- | -------- | ------------- | --------------------------------------------------- | +| `core-js` | `3.48.0` | `postinstall` | `node -e "try{require('./postinstall')}catch(e){}"` | +| `esbuild` | `0.27.3` | `postinstall` | `node install.js` | + +Any added hook or command/version change requires a source policy review. +Root review rejects all ten `npm ci` lifecycle hooks: `preinstall`, `install`, +`postinstall`, `prepublish`, `preprepare`, `prepare`, `postprepare`, +`predependencies`, `dependencies` and `postdependencies`. The last three also run +through pinned npm's Arborist when installation changes the dependency tree. These +results describe declarations only. They grant no execution authority and supply +no starts, completions, failures or changed-file observations. Full installation +tree mutation checks and actual lifecycle observations remain separate gates. + +`observe-npm.mjs` adapts the selected npm 11.19.0 lifecycle entry before npm loads +its dependency graph. It records real starts, fulfilled/rejected execution, +exit status and changed owned-file facts in memory. This includes hooks with no +output and optional hook failures that npm would otherwise swallow. Root calls +for absent scripts remain no-ops; root commands and unreviewed dependency hooks +fail. Selected Node replaces the reviewed command's bare `node` and leads the +child PATH after npm prepends package bins. Lifecycle children receive no parent +stdin or additional inherited descriptors. + +The Linux supervisor loads the authenticated native non-dumpable-process guard +before giving the observer its private request key. The guard and the process +memory syscall denials prevent lifecycle children from reading or modifying the +trusted npm process. Observations and the checked preparation document are sealed +together with HMAC-SHA-256 over their exact original JSON bytes. The supervisor +joins the nonce, source, workspace, cache, guard and native esbuild expectation, +requires successful npm exit and complete successful events, and independently +checks the retained outputs. A child's printed prefix or writable JSON file does +not establish an observation. This adapter provides no macOS completion path and +does not itself authenticate Node/npm distributions or qualify a hosted runner. + +The reviewed esbuild 0.27.3 postinstall can hard-link its selected native package +binary to `node_modules/esbuild/bin/esbuild`. To retain the boundary's global +single-link policy, the observer detaches only this exact pair after execution. +Both paths must identify the same regular inode with exactly two links and match +the parent-derived authenticated native archive's digest, size and expected mode. +The observer copies the owned esbuild path into a fresh file and atomically +replaces it, then verifies both files have one link and unchanged executable +bytes. It records `esbuild-hardlink-copy-v1` separately in the sealed observations; +the changed owned executable remains a lifecycle output. Other hard links, +additional aliases, mismatched bytes/modes or unsafe paths fail. This +normalization changes inode ownership of the bytes, not executable content. + +Run collector and unconditional full Vitest/type checks under that same native +boundary, checking tracked source and unexpected files before/after each stage. +Preserve release Vitest's literal host and narrowly scoped native `node:` import +resolver; npm imports remain unmodified. `configFile: false` collector builds +remain separate from that test plugin. Existing ordinary development behavior +remains supported. Both actual A/B Vitest processes MUST unconditionally receive +`TRUSTED_SERVER__RELEASE_JS_TESTS=1` and run the full registered suite/type checks, +with no developer opt-out. Require all tracked core/integration final files +compared byte-for-byte. Ordinary development skips only the two slow Git +collector fixtures by default; CI enables the full suite. B inventory virtual-code hashes may differ because paths differ. + +After all actual preparation/lifecycle/test/build/control/comparison stages pass, +construct a private typed completion bound to approved source, authenticated tools, +settings, raw inventory digest and retained A module bytes. A parsed JSON boolean, +source label, arbitrary checked inventory or asserted path/hash cannot construct +that state. Atomically retain A's `bundles/` plus sibling `tsjs-inventory.json`, +with only JS files in `bundles/`; preserve A's original inventory encoding and +closed root/module shape, exact integer sizes and final digests. Rehash at handoff. +Use bounded child pipes/deadlines and kill/reap descendants on cancellation, +timeout or oversize output. Clean private profiles/resources and Docker containers +on success and failure with bounded stop/remove operations. + +Once Rust modules/tests exist, derive `HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p')` +and run `cargo test -p trusted-server-release --target "$HOST_TARGET" javascript::tests` +using the [primary plan's nonzero-selection guard](../../docs/superpowers/plans/2026-10-13-attested-release-implementation.md#focused-rust-test-selection-guard). +Private fixtures belong alongside their registered modules; public process tests +use `tests/commands.rs`. Full local two-installation/native-probe execution MUST be +selected explicitly through reviewed test paths and cannot waive hosted gates. + +The committed Node collector/composition fixtures are current local interface checks; utility fixtures do not construct producer completion. Task 7's real two-installation orchestration passed its full Linux execution test in ordinary PR CI; see the [two-build evidence report](qualification/2026-10-15-js-two-builds.md). That candidate run is not release qualification: Task 8's selected hosted/native qualification, authenticated bootstrap and tool pins remain pending. diff --git a/scripts/release/qualification/2026-10-15-consumer-real-fixtures.md b/scripts/release/qualification/2026-10-15-consumer-real-fixtures.md new file mode 100644 index 0000000000..31d7d0a09a --- /dev/null +++ b/scripts/release/qualification/2026-10-15-consumer-real-fixtures.md @@ -0,0 +1,150 @@ +# Consumer verifier: measured public-fixture qualification + +Measured on 2026-10-15 on native Apple Silicon macOS. This report records +actual public upstream evidence, rather than fictional examples. No release, +workflow, repository or deployment was created. Downloaded subject archives +were never executed. The retained evidence is outside the repository at +`/private/tmp/ts-real-fixture-qualification` and is temporary local evidence; +the digests below detect changes but do not authenticate a future download. + +## Trust and bootstrap + +The independently installed `/opt/homebrew/bin/gh` reported version 2.87.2. +That existing verifier, rather than the downloaded executable, successfully +verified the public attestation on `cli/cli`'s +`gh_2.97.0_macOS_arm64.zip` before its executable was run. The verified +certificate bound the archive to source/signer commit +`55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55`, workflow +`.github/workflows/deployment.yml`, `refs/heads/trunk`, and run +`30597407850/attempts/1`. The archive SHA-256 was +`a58b8fd77b417a38f47a0b54d1370c59b0fcdb324ccc9ca002b0998f7c4c999e`. +The extracted executable reported exactly 2.97.0. Version output alone was not +used as authentication. This relies on the pre-existing verifier installation +as a local trust prerequisite; this work did not independently rebuild or +qualify that installation's own historical bootstrap. + +Primary inputs: + +- [Pinned verifier release](https://github.com/cli/cli/releases/tag/v2.97.0). +- [Bootstrap signing workflow at its verified commit](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/.github/workflows/deployment.yml). +- [Measured fixture release](https://github.com/d2lang/d2/releases/tag/v0.9.0). +- [Fixture signing workflow at its verified commit](https://github.com/d2lang/d2/blob/d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda/.github/workflows/release-archives.yml). +- [Exact attest 4.2.2 action](https://github.com/actions/attest/tree/1e69f48acb82d1966a394da916b4c1698aa569d6). + +The bootstrap release used attest 4.2.0. It was used to authenticate the +verifier, not to stand in for the required 4.2.2 signing fixture. The D2 +workflow at the verified commit explicitly pins attest 4.2.2 for both +provenance and SPDX signing. + +## Successful measurements + +The authenticated gh 2.97.0 verified both provenance and SPDX signatures for +`d2-v0.9.0-linux-arm64.tar.gz`. Its real certificates reported: + +| Field | Observed value | +| ------------------------ | -------------------------------------------------------------------------------------- | +| Issuer | `https://token.actions.githubusercontent.com` | +| SAN and build signer | `https://github.com/d2lang/d2/.github/workflows/release-archives.yml@refs/tags/v0.9.0` | +| Source and signer digest | `d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda` | +| Source repository ID | `533087958` | +| Source owner ID | `242104691` | +| Trigger / runner | `push` / `github-hosted` | +| Invocation | `https://github.com/d2lang/d2/actions/runs/34165396038/attempts/1` | + +The actual output was an array of one processing result for each +`attestation verify` invocation. The real verification media type, +flattened certificate field names, SLSA v1 provenance fields, and SPDX +`https://spdx.dev/Document/v2.3` predicate matched the implemented consumer +contract. The separately downloaded `d2.spdx.json` compared equal as parsed +JSON to the signed SPDX predicate. + +Both bundles were replayed with the production-equivalent explicit issuer, +SAN, source ref, source digest, signer digest and hosted-runner restriction. +The process environment was cleared, with only private HOME/config and +noninteractive settings supplied; no token was present. SPDX verification +also succeeded under a macOS `sandbox-exec` profile denying `network*`, +using a local bundle and independently transferred trusted roots. The same +profile caused a separate curl DNS/network attempt to fail. This is an +actual consumer offline primitive check, not qualification of producer +process cleanup or the broader macOS build boundary. + +Pinned `gh release verify --format json` also successfully verified the D2 +and CLI public release manifests. Actual output was one processing-result +object rather than the attestation command's array; the release predicate +was `https://in-toto.io/attestation/release/v0.2`. + +## Retained evidence digests + +All digests are SHA-256 of exact retained bytes. + +| File | Digest | +| ----------------------------------- | ------------------------------------------------------------------ | +| `bootstrap-proof.json` | `b53f05f983872a6f5f2d9e95d54bf371f86b012a36fc324b80be5eebbf57fc66` | +| `d2-v0.9.0-linux-arm64.tar.gz` | `ac2c028697199479acb321db1e3d68caee9f2ba492ed73caa3cd13f3829bf913` | +| `d2.spdx.json` | `efaf407d3142ee52de4f3e90864f3023110dd9f430f8220143d6f3d9de86e689` | +| `d2.provenance.sigstore.json` | `d42456c264743abc09194a548e18019e4073eef4040abd182fd1c974616a9259` | +| `d2.spdx.sigstore.json` | `26cab9234beaa45c49510924ae7c8aa4cb058a3419aff673eb6c51c33bce3ad3` | +| `d2-isolated-provenance-proof.json` | `59045bc27b9d7bbf1f6747ca7939aa8ef2c77330e86d2229d7cee9daf3c7e2ca` | +| `d2-isolated-spdx-proof.json` | `bf43a603bb1a4f83cdcfeee68ae0b69b3d23e8fb1f46a20d083dafafc4220c8a` | +| `d2-offline-spdx-proof.json` | `bf43a603bb1a4f83cdcfeee68ae0b69b3d23e8fb1f46a20d083dafafc4220c8a` | +| `d2-release-proof.json` | `a1269c4c7da96bd0e5129b1d95030cf084d91fed2b8a085ad8fdcb633b979deb` | +| `gh-release-proof.json` | `dd934980d284943275aa2d9c574fdbcc5e3713e38a616ce49ca0ac682040f07c` | +| `trusted-root.jsonl` | `65ca537f6ed8a47fd0e560c421baa1f6c1efb8b25fc200d8c5c02c0e92eb2b9c` | + +## Replay commands + +First authenticate the pinned archive with the independently installed +verifier, then extract it. Fetch the D2 archive and standalone SPDX document +from the release linked above. Use the retained signed bundles, or obtain +and verify fresh ones from the fixed public repository; a hash here cannot +replace cryptographic verification. + +```sh +set -eu +EVIDENCE=/private/tmp/ts-real-fixture-qualification +PINNED_GH="$EVIDENCE/extracted/gh_2.97.0_macOS_arm64/bin/gh" +/opt/homebrew/bin/gh attestation verify \ + "$EVIDENCE/gh_2.97.0_macOS_arm64.zip" -R cli/cli --format json +/opt/homebrew/bin/gh attestation trusted-root --hostname github.com \ + > "$EVIDENCE/trusted-root.jsonl" + +# Run once per predicate, choosing the corresponding retained bundle. +PREDICATE=https://spdx.dev/Document/v2.3 +BUNDLE="$EVIDENCE/d2.spdx.sigstore.json" +env -i HOME="$EVIDENCE/isolated-home" \ + GH_CONFIG_DIR="$EVIDENCE/isolated-home" GH_PROMPT_DISABLED=1 \ + /usr/bin/sandbox-exec -p '(version 1)(allow default)(deny network*)' \ + "$PINNED_GH" attestation verify \ + "$EVIDENCE/d2-v0.9.0-linux-arm64.tar.gz" --bundle "$BUNDLE" \ + --custom-trusted-root "$EVIDENCE/trusted-root.jsonl" \ + -R d2lang/d2 --hostname github.com \ + --cert-identity https://github.com/d2lang/d2/.github/workflows/release-archives.yml@refs/tags/v0.9.0 \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref refs/tags/v0.9.0 \ + --source-digest d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda \ + --signer-digest d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda \ + --deny-self-hosted-runners --predicate-type "$PREDICATE" --format json +``` + +For provenance, use `https://slsa.dev/provenance/v1` and +`d2.provenance.sigstore.json`. Preserve the exact expected source identities; +never derive acceptance policy from an unverified proof's fields. Runtime +proofs and bundles remain outside the repository; checked-in compatibility +fixtures use fictional identities and contain no signatures or trusted proof +constructors. + +## Scope and remaining first-use work + +Task 18's real pinned-verifier/signed-public-fixture primitive requirement is +now measured. Its adversarial bounded-process tests remain the separate +source-built CLI verification evidence. + +Task 19's upstream format uncertainty is resolved for both predicate kinds +and release-manifest output. The public fixture has a different repository, +workflow and product graph and cannot satisfy Trusted Server policy. No +third-party result constructs official project authorization. Full official +first-use verification remains pending until the producer emits the canonical +signed index and all required companion assets, from independently approved +source, and an independently trusted/source-built verifier checks that actual +release. This report does not enable releases or qualify authenticated +producer bootstrap, hosted build recipes, or signing workflow execution. diff --git a/scripts/release/qualification/2026-10-15-js-two-builds.md b/scripts/release/qualification/2026-10-15-js-two-builds.md new file mode 100644 index 0000000000..6ef9467e9e --- /dev/null +++ b/scripts/release/qualification/2026-10-15-js-two-builds.md @@ -0,0 +1,170 @@ +# Task 7: real original materials and two fresh JS builds + +Status: the explicit original-material and Cargo Git tests passed locally on +native macOS, and the full Linux A/B execution test passed in +[ordinary PR CI](https://github.com/IABTechLab/trusted-server/actions/runs/38747798078) +on 2026-10-15. This is candidate execution evidence from an ordinary CI +runner. It does not qualify release tools, an OCI image, bootstrap, hosted +release environments or independently approved release source. Production +selectors remain closed. + +## Original-material validation + +The explicit native test +`materials::npm_bundle::tests::actual_current_lock_npm_originals` passed +locally on native macOS on 2026-10-15 in 283.74 seconds. It is ignored in +ordinary CI; there, original authentication runs only inside the A/B fixture's +npm original preparation. It authenticated all 651 locked installation +locations and 626 distinct compressed originals against the current npm +lock's SHA-512 identities, freshly extracted every original, reviewed every +manifest, retained the compressed originals, and rechecked all retained +archives and extracted trees. Its fictional source binding is a diagnostic +test input, not independent source approval or acquisition qualification. + +The exact npm lock SHA-256 is +`d5a1b74310b0455531c3133cacf86fa528682cbed91c40412fb3791ccd48c527`. +An independent local raw-header diagnostic, not retained in the repository, +inspected 30,723 entries. The largest +member is the locked optional OpenBSD Rollup binary, 110,275,248 bytes. The +reviewed member limit is 128 MiB; the compressed-original limit remains +64 MiB and total decoded limit remains 512 MiB per archive. Extraction and +rehashing stream bytes and honor cancellation/deadlines. + +Actual originals exposed the eight exact-version legacy type-package roots, +seven node-tar timestamp layouts, and local PAX metadata in `gulp-wrap` +0.15.0 documented in [JS composition](../js-composition.md). Only redundant +PAX path/size values matching the next raw regular member are accepted. +Unknown fields, duplicate keys, malformed framing, overrides, unsupported +record sequences, links, privileged modes, and hidden name bytes fail. +The only empty script entries were non-install separator labels in `ignore`; +empty lifecycle declarations remain rejected. The only actual dependency +install hooks were the reviewed `core-js` and `esbuild` tuples. + +The separate actual-current-Cargo-Git fixture also passed locally; it is +ignored in ordinary CI. The selected +closure contained 557 objects and 6,334,558 decoded object-content bytes, +excluding canonical object headers, with root tree +`411e16f40511f3f4867349d09784ec68beac022d`. This checks actual Git material +format and closure validation; it does not qualify Git acquisition, a native +toolchain, or a Cargo recipe. + +## Linux execution acceptance + +Orchestration commit: `2edd46ce6` +(`Build and compare two fresh offline JavaScript installations`); reviewed +archive layouts: `e2260d457b29b1202a39f8732e78d5d4bd9c323b`; CI runner configuration: +`cbd96047400099271534f110b5decdcf73b1a5c8`; collector fixture context and +failure diagnostics: `1a2c46075e18791b623f26ea73af8f833dc5f70b`. +The [Linux producer job](https://github.com/IABTechLab/trusted-server/actions/runs/38747798078/job/116465922121) +(`cargo test (release tooling and JS prebuilt, native) (ubuntu-24.04)`) +selects exactly one ignored full-execution test: +`preparation::tests::full::real_two_fresh_js_builds`. +It passed in 687.37 seconds (14:09:47Z to 14:21:15Z) after a two-minute +optimized test build: `1 passed; 0 failed; 0 ignored; 354 filtered out`. +Every job in that Run Tests workflow passed, as did the Run Format, CodeQL +and Integration Tests workflows for the same head. + +CI checked out the pull request merge commit +`15fcd34c64b0f726f42f4f4da5de5af7e3577aa2`, whose parents are `main` at +`a0b00c72705baf6b0f63c206d7721b68ee03e995` and branch head +`1a2c46075e18791b623f26ea73af8f833dc5f70b`. The fixture cloned that exact +checkout. The commit is a CI candidate, not independently approved release +source. The printed measurement was: + +```json +{ + "inventory_sha256": "2cdcb1da9c6318ce812cf7fd0b9cc0b1bdc9465c93d68953054db3e361b45042", + "kind": "ordinary-ci-two-fresh-js-builds", + "module_count": 13, + "private_roots_removed": 6, + "release_qualification": false, + "schema_version": 1, + "settings_sha256": "6bce0b31534321c4e36209caf09cad19a0ede1dbe7f6e39254b3f68657a8ea27", + "source_commit": "15fcd34c64b0f726f42f4f4da5de5af7e3577aa2", + "test_counts": [1275, 1275], + "tools_sha256": "4a461321d25eec779752960570ba574bae397102cb3b652b9b70cc24bb6431c6" +} +``` + +Both confined full suites passed all 1,275 registered tests including type +checks. All 13 final core/integration modules were byte-identical, six private +roots were removed and retained A bytes were rehashed at handoff. The test +writes this file only after every stage succeeds, so its presence records the +actual completed path; the file itself remains a diagnostic printout. + +After this run, the fixture gained explicit assertions that each side exposes +only its own private source, installation and metadata roots, shares only the +read-only originals and tool roots, and cannot reuse the other side's boundary. +Isolation was already enforced: exact `matches_roots` checks run before +execution and each installation, and every command path is translated only +through its own boundary; the next CI run on +this branch exercises the new assertions. A unit test also now rejects failed, +pending and unregistered Vitest report results. + +The fixture uses Node 24.12.0, npm 11.6.2, a source-built producer and native +observer guard, original compressed archives, and an ordinary local Linux +container image. It creates two independent tracked-source copies with no +`.git`, installed dependencies, generated bundles or shared writable cache. +Each side establishes an actual socket-denial proof in its own network/PID +boundary, seeds its private cache from authenticated originals, performs a +fresh offline installation with lifecycle observation, captures the original +collector inventory, exercises missing native tools and the actual esbuild +download fallback, and runs the full registered suite with release guards +and type checks. It compares every final core/integration bundle byte for +byte, closes all six private source/state/metadata roots, then rehashes +retained A bytes at handoff. No serialized measurement can construct private +completion authority. + +After success the test writes `js-execution-measurement.json` into its +external fixture root and CI prints it. This records actual source identity, +A/B test counts, module count, selected tool/settings hashes, original A +inventory hash and removed-root count. Its `release_qualification` field is +explicitly false; the file is diagnostic evidence, not an authorization +input or a canonical release companion. + +The full fixture uses test-profile optimization level 2 with debug assertions +and overflow checks explicitly enabled. Ordinary native tests retain their +existing debug profile. Cache seeding alone triggers at least 80 complete +original/tree/tool rechecks across A/B; optimization retains every check. +Local all-originals runtime fell from 283.74 seconds to 18.18 seconds after +a 48.44-second optimized compilation. A further run with both safety settings +explicitly enabled reused the same executable and passed in 17.93 seconds. +These are local performance measurements, not hosted execution proof. + +## Supporting checks and scope + +With the new unit test, the local producer suite passed 364 unit tests, four +boundary integration tests and 13 command tests on macOS. Two explicit +actual-material tests are ignored by that ordinary run and are exercised +separately. +All-target/all-feature producer Clippy, documentation, formatting and staged +domain lint passed. Independent spec and quality reviews found no remaining +Task 7 code blocker before the Linux run; that run has since passed. + +A separate local, unretained release-mode Vitest JSON run passed all 1,275 tests. Its +52 report entries cover exactly 51 distinct registered test/type-check files; +the type-check file appears twice. This confirms the Rust report parser's +real format/coverage join; it does not substitute for either confined A/B run. + +Two earlier full Linux attempts failed for concrete, since-fixed causes. The +[first](https://github.com/IABTechLab/trusted-server/actions/runs/38736177179/job/116425928695) +rejected the authentic 110,275,248-byte optional OpenBSD Rollup member under +the former 64 MiB member limit. The +[second](https://github.com/IABTechLab/trusted-server/actions/runs/38741529217/job/116444179601), +unoptimized, reached Vitest and failed one collector negative fixture after +2,027.48 seconds. That fixture passed parent +source context to its main call but omitted it from later rejection cases; +the duplicate-material case consequently failed at Git discovery in a fresh +source copy without `.git`. A separate local full no-`.git` reproduction confirmed +exactly that single failure. Copying only the corrected test file into the +same disposable tree made all 1,275 tests and type checks pass with zero +failed/pending tests. Every negative call now supplies the same source context, +preserving its intended semantic assertion. Failed Vitest runs also retain a +bounded sanitized list of registered failed test names in the process error +before checked cleanup; raw assertion messages and stacks are excluded. + +R-JS allows one shared Linux JS build for later Rust artifact jobs. Task 7 +does not qualify native Rust recipes or macOS lifecycle containment. The +macOS regression ports passed afterwards on [macOS 15 CI](https://github.com/IABTechLab/trusted-server/actions/runs/38765937295/job/116529009924), including the formerly residual scrubbed process-group escape, which is now killed and rejected. That boundary remains unqualified. Authenticated +bootstrap, selected hosted environments, actual Cargo recipes and official +release publication remain separate incomplete gates. diff --git a/scripts/release/qualification/2026-10-16-confined-cargo-candidate.md b/scripts/release/qualification/2026-10-16-confined-cargo-candidate.md new file mode 100644 index 0000000000..59c73bb74f --- /dev/null +++ b/scripts/release/qualification/2026-10-16-confined-cargo-candidate.md @@ -0,0 +1,102 @@ +# Task 9: first confined candidate Cargo build + +Status: on 2026-10-16 ordinary PR CI built `trusted-server-fastly.wasm` (role A) +through `PreparedRecipe::execute` inside the Linux Docker denial boundary. It +also proved that Cargo build scripts, a build-script grandchild and a +procedural macro inherit network denial. This is candidate execution evidence +from an ordinary CI runner and records `release_qualification: false`. It does +not qualify the toolchain, the image, the acquired crates, the vendored Git +manifests, bootstrap or any hosted release environment. Production tool +selections remain uninhabited, so production `execute` still returns +`Unqualified`. + +## Run + +The job +[`cargo test (confined release Cargo candidate, Linux)`](https://github.com/IABTechLab/trusted-server/actions/runs/38849941580/job/116808709738) +checked out the pull request merge commit +`309b9553657bd74c3e7dea03927284b68756c7e6` (branch head +`2d933b0e9debed3960f97f2cc3ebb5948da17471` merged into `main` at +`fa9837f2b95f817f58eadb16c929c38fcb581c9a`). Each step ran exactly one +selected ignored test, after a guard that the selection matched exactly one +test. + +The fixture acquired 706 registry originals (each checked against its locked +SHA-256), one Git object store for the locked `edgezero` revision, and eight +normalized Git manifests from `cargo vendor --offline`. Cargo 1.95.0 wrote no +`Cargo.toml.orig` for Git packages, so each original manifest was read from +the Git object database and paired with its normalized form. The acquisition +was unauthenticated ordinary CI. The tool root was a private single-link copy +of the host Rust 1.95.0 sysroot with `wasm32-wasip1`; the image was Ubuntu +24.04 with `gcc` and `libc6-dev`, identified only by its image ID. + +## Denial inheritance + +`recipe::tests::full::cargo_children_inherit_denial` passed in 25.17 seconds. +A build script, a build-script grandchild and a procedural macro each +attempted a loopback connection inside the boundary. All three reported errno +1 (`EPERM`), matching the boundary's own denial proof, not `ECONNREFUSED`. +The control crate built a real `denial-control.wasm` (2,013,985 bytes, SHA-256 +`60bd5bc1f0388a50a75dd3a0e87400282fb5c97499fc414a1e8dadd2343d8534`) through +the same `execute` path. + +## JS handoff into the Fastly build + +`recipe::tests::full::real_js_handoff_to_fastly_cargo_build` passed in 871.02 +seconds. It first repeated the full two-fresh-installation JS acceptance: both +sides passed 1,275 tests including type checks, all 13 final modules were +byte-identical and six private roots were removed. It then bound that +in-memory JS completion to a third fresh source copy, prepared the vendor +directory, ran `cargo build --release --frozen +--message-format=json-render-diagnostics` with umask 022 and four jobs, and +completed only after the JS handoff, tools and materials rehashed unchanged. +No Cargo registry or Git cache appeared in the private `CARGO_HOME`. The +confined build took 130 seconds. + +```json +{ + "artifact": { + "name": "trusted-server-fastly.wasm", + "sha256": "6a19dece46c11b40f352a0ac23a124a4f8d6af5742df56845d37a99dc2b713c5", + "size": 66885152 + }, + "asset": "trusted-server-fastly.wasm", + "boundary_fingerprint": "99af57442bb03eeaaa50c320d83dbe5bf8938de97d5eca1c9f62668c17170582", + "cargo_lock_sha256": "9d1ce5338f2511e9ae289ceb4e1679a2330cda8c43f205d80b6d01672589e4dc", + "derived_manifest_digests": 8, + "elapsed_seconds": 130, + "js_inventory_sha256": "f3645c7699165920ccc64bfc0f91c43479b01b771cfacf9b9bd4721bcaedf33e", + "js_settings_sha256": "5d341e8a8b58a638ddc71b663daa0dbada5d1970175f21391b7750d36ffdef02", + "js_tools_sha256": "ba26c9198ef338cf0afc94762d91cfaa3cdbd51987e65dfe0f8905980963da2d", + "kind": "ordinary-ci-confined-cargo-build", + "materials_digest": "da91ba65a309b9290460a2e3fb5fdf2dd6fee59f39225777264444932bf5ab17", + "plan_digest": "a743a63a68cd1ace19f434bae98cf4fc1fafcfe5913e141c5196e7302255afa8", + "release_qualification": false, + "role": "A", + "rust_tools_digest": "0a741d4f4c3b4f9087da3891120b3557925573867341cdf826cb0ce56f883879", + "schema_version": 1, + "source_commit": "309b9553657bd74c3e7dea03927284b68756c7e6", + "stderr_sha256": "ba7154da1ed3e07bfa14e0e6ab0c894684fbe7b4eeccc903262f7e1db0cfca1c", + "stdout_sha256": "9fb08fe022a5fccef541d756acc85df5a6480c9987b29275a7cdc86166953efc" +} +``` + +Eight derived manifest digests mark this build unqualified: the normalized +`edgezero` manifests came from `cargo vendor`, not from a reviewed producer +normalization. + +## Archive policy change + +Real registry crates required two narrowly reviewed compatibility encodings, +for registry originals only: a GNU `././@LongLink` record (regular mode, +exact join with the next header's 100-byte name, bounded, never consecutive, +dangling or mixed with PAX) and tar modes that carry the regular or directory +type bits. npm originals keep the stricter policy. Git packages are vendored +under their bare name so that intra-workspace relative paths resolve. + +## Not covered + +Role B and the A/B comparison, the three `ts` executables, path remapping, +final-byte Viceroy acceptance, producer-owned manifest normalization, +authenticated acquisition, qualified tool and image pins, and hosted +qualification remain open under Tasks 8 and 9. diff --git a/scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md b/scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md new file mode 100644 index 0000000000..afa9c00791 --- /dev/null +++ b/scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md @@ -0,0 +1,60 @@ +# Task 9: Fastly canonical and reproduction builds are byte-identical + +Status: on 2026-10-16 ordinary PR CI built `trusted-server-fastly.wasm` twice +through `PreparedRecipe::execute`, as the canonical build (`Role::Canonical`, +the spec's Build A) and as the reproduction build (`Role::Reproduction`, Build +B), from one JS handoff and in separate fresh roots and denial boundaries. The +final bytes were identical, and a scan of those bytes proved that the path +remapping took effect. This is candidate evidence from an ordinary CI runner +and records `release_qualification: false`. The reproduction profile comes from +a test-only alternate; the production reproduction profile, the toolchain, the +image, the acquired crates and the `cargo vendor` Git manifests remain +unqualified. + +## Runs + +| Run | Source (PR merge commit) | Final SHA-256 | Size (bytes) | Test duration | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------ | ------------ | ------------- | +| [Primary, after the role rename](https://github.com/IABTechLab/trusted-server/actions/runs/38856856376/job/116830159922) | `4243e3a1c16bf1212ab585eecce8592bd5137950` | `a17a77cc5119384d2fb5f74d83cb76cbf264c768e508b8545cf8537db481f178` | 66,833,859 | 1,119.36 s | +| [Earlier, before the rename](https://github.com/IABTechLab/trusted-server/actions/runs/38855948935/job/116827324356) | `373463048c22237ba6e045e7fd0706bb4a104568` | `e5390f47e025f34b70190c944134d6e2b7209e792e8a4ec2d64c65ead594ca4b` | 66,833,943 | 1,064.12 s | + +Each run built both roles from the same commit and matched. The two runs +differ from each other because the source commit is embedded in the binary. +The earlier run's measurement still labels the roles `A` and `B`. Test +durations include the full two-fresh-installation JS acceptance (1,275 tests +per side, 13 byte-identical modules) that produces the shared JS handoff. Both +runs also repeated the build-script, grandchild and procedural-macro denial +test. + +## What varied + +| Dimension | Canonical | Reproduction | +| ---------------- | ------------------------------- | ------------------------------- | +| umask | 022 | 027 | +| Cargo jobs | 4 | 2 | +| Host checkout | separate fresh temporary copy | separate fresh temporary copy | +| Start time | recorded per run | recorded per run | +| Locale, timezone | normalized to `C`, `UTC` | normalized to `C`, `UTC` | +| Execution user | same CI user (unsupported here) | same CI user (unsupported here) | + +In the primary run the confined builds took 137 and 171 seconds. Inside the +Linux container both roles see every root at the same `/boundary//` +path, so the checkout-root difference is a host-only variation there. + +## Remapping proof + +The build remaps the checkout, vendor tree, Cargo home, Rust tool root and +target directory to fixed prefixes through one quoted +`--config target."".rustflags` entry, which Cargo appends to tracked +target rustflags. Because container paths match across roles on Linux, byte +equality alone would not prove the remapping. The test therefore scanned the +compared final bytes: they contained none of `/boundary/`, the host build +roots, the fixture root or `RUNNER_TEMP`, and they did contain the remapped +`/trusted-server` and `/cargo-vendor` prefixes. + +## Not covered + +The three `ts` executables, Viceroy final-byte acceptance, producer-owned Git +manifest normalization, the production reproduction profile, authenticated +acquisition, qualified tool and image pins, and hosted qualification remain +open under Tasks 8 and 9. diff --git a/scripts/release/qualification/2026-10-16-fastly-runtime-gate.md b/scripts/release/qualification/2026-10-16-fastly-runtime-gate.md new file mode 100644 index 0000000000..a9fe68a6aa --- /dev/null +++ b/scripts/release/qualification/2026-10-16-fastly-runtime-gate.md @@ -0,0 +1,60 @@ +# Task 9: Fastly final-byte runtime gate + +Status: on 2026-10-16 ordinary PR CI ran the Fastly final-byte gate on both +the canonical and the reproduction `trusted-server-fastly.wasm` outputs, and +both were accepted. The same run rejected a closed-origin control as a +health-only success. This is candidate evidence from an ordinary CI runner and +records `release_qualification: false`. Viceroy is pinned by version text and +recorded by digest, not by an independently reviewed digest; that pin, the +toolchain, the image and the acquired inputs remain unqualified. + +## Run + +The job +[`cargo test (confined release Cargo candidate, Linux)`](https://github.com/IABTechLab/trusted-server/actions/runs/38875569290/job/116889681434) +checked out the pull request merge commit +`ab6cba207192969c55547876f9adf7d625a64ea1` (branch head +`4cc5700e834c3238f99a81b3a567edb634fecfe1`). The ignored test +`recipe::tests::full::fastly_canonical_and_reproduction_builds_are_byte_identical` +passed in 1,113.82 seconds. It built both roles from one JS handoff (confined +builds of 132 and 166 seconds), found them byte-identical (66,833,956 bytes, +SHA-256 `0c21ec136d2ffabf2d3440142d741e96b2aef18d5a52c82d4cfc8022dc0866c9`), +then ran the gate on each output without rebuilding. The denial-inheritance +test passed again in the same job. + +## What the gate checks + +1. The exact final bytes are re-read against the recorded size and digest and + fully validated as a core Wasm command with the pinned wasmparser: no + component, only Fastly import modules, a no-argument `_start`, an exported + memory, the HTTP request and response modules, and debug sections. +2. The verified bytes are copied read-only into a private directory and served + by the explicitly selected Viceroy, whose `--version` output must be exactly + `viceroy 0.17.0` and whose binary is rehashed around every use. Unknown + imports fail to link. The configuration comes from the integration + harness's fixtures, which must match the built source. +3. `/health` only gates readiness. Acceptance requires a 200 HTML publisher + page that carries this run's unique origin marker once and exactly one + injected tsjs script, an origin request observed by the gate's loopback + origin, no guest failure in Viceroy's output, and a stop initiated by the + gate. The artifact rehashes unchanged afterwards. Responses are parsed + strictly over loopback only. + +## Results + +| Output | Health | Publisher | Injected script | Origin request | Viceroy stderr | Startup | +| ------------ | ------ | --------- | ----------------------------------------------------------------------------------------------------- | ---------------- | -------------- | ------- | +| Canonical | 200 | 200 | `/static/tsjs=tsjs-unified.min.js?v=662e376b054ac1fccc82a9c8f0d2a086bfa11541343cb0af8c046ef09ac5aa75` | `GET / HTTP/1.1` | empty | 4.26 s | +| Reproduction | 200 | 200 | same | `GET / HTTP/1.1` | empty | 4.26 s | + +Viceroy 0.17.0 binary SHA-256: +`05da4843fd8ae870409211e9fbc168aafbb1609445fbd0eefa3902e5a151cce1`. The +closed-origin control was rejected with "publisher page status is not 200" and +tagged as a health-only success. + +## Not covered + +The three `ts` executables, producer-owned Git manifest normalization, the +production reproduction profile, an independently reviewed Viceroy digest, +authenticated acquisition, qualified pins and hosted qualification remain open +under Tasks 8 and 9. Live deployment is separate. diff --git a/scripts/release/qualification/2026-10-16-linux-cli-candidate.md b/scripts/release/qualification/2026-10-16-linux-cli-candidate.md new file mode 100644 index 0000000000..ae9b265d3c --- /dev/null +++ b/scripts/release/qualification/2026-10-16-linux-cli-candidate.md @@ -0,0 +1,153 @@ +# Task 9: Linux CLI candidate builds, inspects and starts cleanly + +Status: ordinary PR CI passed the Linux `ts` candidate recipe on 2026-10-16. +The canonical and reproduction builds produced byte-identical executables. +The exact accepted canonical bytes passed final-path and ELF inspection and +started outside the checkout in all three reviewed Linux images. This is +candidate evidence with `release_qualification: false`; it does not qualify +production tools, image pins, acquisition or release enablement. + +## Subsequent reproduction scheduling + +This report retains its original equality observation. `3e3e30996` later +defers optional native reproduction on ordinary push/PR runs, measuring it on +scheduled/dispatched or appropriately labelled PR runs instead. Canonical +build/inspection/startup remain required. The [dated schedule report](2026-10-21-native-reproduction-schedule.md) +records the triggers, cache limits and subsequent 45-minute ordinary-CI per-build +wall. Future equality results must cite the measured run/job and built source; +a deferred run does not repeat this report's equality proof. + +## Run and artifact + +- [Successful candidate job](https://github.com/IABTechLab/trusted-server/actions/runs/38921701566/job/117042987142). +- Branch head: `0299c3a17812ce78def6b8df479f091a27120ce8`. +- Built source (PR merge commit): `4a6404b072a3bc4bd36a12b33e4201c5f5797522`. +- Asset: `ts-x86_64-unknown-linux-gnu`, variant `default`, package `0.1.0`. +- SHA-256, both builds: `a3a64d15ee1663ff747a21e0edc089703183a0a8685740ec65592d9fdd3a34b1`. +- Size, both builds: 285,604,680 bytes. +- Complete candidate test: 1,352.55 seconds; canonical Cargo execution: 293 seconds. +- Lock SHA-256: `c37beb7694512de99ac99c8ac214fa01f56f32226810e814e7cc26a717c8eedd`. +- Shared JS inventory SHA-256: `b0632a21ad06c0bec46be365aba30b62f89e8153a33e5517ce4109eca30793eb`. + +The job logs retain `linux-cli-candidate-measurement.json` and +`builder-filesystem-controls.json`, including tool/material/plan digests, +image IDs, debug-section sizes, startup output and reproduction comparison. +The source commit above, rather than the later documentation commit, is the +identity embedded in and measured for this executable. + +## Actual build and boundary + +The producer/probe itself was rebuilt inside the GNU baseline from the +tracked source and copied pinned Rust toolchain using cached offline Cargo +inputs. Both CLI roles then used `PreparedRecipe::execute` in the selected +UBI builder with glibc 2.34. The candidate acquired inputs through ordinary +unauthenticated CI preparation, including eight producer-normalized Git +package manifests and zero caller-derived manifests. + +Builder base: `registry.access.redhat.com/ubi9/ubi:9.6`. +Observed builder ID: +`sha256:c92872ae3dc77cff493a6d50ba5835961c03ba7f50aac94b5c3e643ddc8e7e71`. +The immutable image configuration was checked for absent/empty declared +volumes. Cargo, compiler and build children ran under the established +socket-denial boundary with no network and a read-only container root. + +Actual filesystem controls exited zero after observing: + +| Location | Observed control | +| ------------------------------------------------- | -------------------------------------------------------- | +| `/tmp` | Write denied: read-only filesystem | +| `/dev/shm` | Write denied: directory absent with IPC disabled | +| `/dev` | Write denied: permission denied | +| `/etc/hosts`, `/etc/hostname`, `/etc/resolv.conf` | Append-open denied: permission denied | +| Bound `TMPDIR` | Write, read and removal succeeded; no test file remained | + +These checks precede the real fresh JS and native builds. Global `/tmp` is +not writable scratch storage. The JS containment test uses a real symlink +between owned sibling directories relative to its configured source root; +it no longer clears the supplied `TMPDIR` to reach global temporary storage. + +The separate real timeout regression passed in 5.05 seconds. It starts only +after Docker acknowledges a full container ID, times out an attached child, +and verifies removal of that owned container. Build and startup cleanup use +the same acknowledged-create lifecycle; a failed/unresolved creation remains +fatal rather than being cleared by an early absence check. + +## Final bytes and startup + +Inspection of the copied final executable found: + +| Check | Measured result | +| ------------------------------ | ------------------------------------------------------------------------------- | +| Architecture | x86-64 ELF | +| Interpreter | `/lib64/ld-linux-x86-64.so.2` | +| Highest required GLIBC version | `2.34` across 27 version requirements | +| Needed libraries | `libgcc_s.so.1`, `libm.so.6`, `libc.so.6`, `ld-linux-x86-64.so.2` | +| `.debug_info` | 87,973,465 file-backed bytes | +| `.debug_line` | 33,681,096 file-backed bytes | +| Remapped prefixes retained | `/trusted-server`, `/cargo-vendor`, `/cargo-target` | +| Build paths | No bound host/execution roots, `/boundary/`, fixture roots or `RUNNER_TEMP` | +| Operator values | Three planted fictional variables removed by preflight; zero final-byte matches | + +Bound-root rejection and remap evidence are mandatory inside recipe +completion, before an accepted artifact can be returned. The candidate also +scans fixture/runner roots. The GNU-only generic `/tmp/` policy permits the +measured Chromiumoxide runtime Chrome lookup constant `/tmp/aws/lib`; it does +not permit the actual build roots. The closed scratch-storage controls above +support that distinction. macOS retains its separate temporary-path policy. + +The accepted executable was copied read-only, hashed before and after each +startup, and executed as `ts --version` without a checkout or operator config. +All starts exited zero with empty stderr and identical expected output: + +```text +ts package=0.1.0 source=4a6404b072a3bc4bd36a12b33e4201c5f5797522 target=x86_64-unknown-linux-gnu variant=default +``` + +| Startup image | Observed local image ID | +| ------------------------------------------------- | ------------------------------------------------------------------------- | +| `registry.access.redhat.com/ubi9/ubi-minimal:9.6` | `sha256:a2c5a85865a585c3bc8b10f6c269358fdf89fe32be4232885166889d85c76421` | +| `docker.io/library/ubuntu:22.04` | `sha256:e8df6c74f650ad385efe2e20103eca9b0ad50690fb3c63d49fa00762412bbf55` | +| `docker.io/library/debian:12` | `sha256:94926f0fd968cd68ac486502185a5dd78687874a9ec7727f64fe2af696e28311` | + +These are observed IDs from ordinary CI tag acquisition, not authenticated +production digest selections. Startup uses a non-root user, no network, +read-only mounts/root, dropped capabilities and bounded execution/cleanup. +This exercises executable loading and version reporting, not every CLI +command or a browser installation. + +## Reproduction and regression coverage + +The reproduction used separate fresh checkout, vendor, Cargo-state, target +and output roots from the shared JS handoff. The checked pairing required +identical source, lock, tools, material closure and JS inputs, with the +supported ambient differences recorded honestly: canonical umask 022/jobs 4 +versus reproduction umask 027/jobs 2, distinct host roots and start times. +Container paths remain fixed; locale/timezone are normalized and execution +user variation is unsupported here. Final-byte path inspection supplies +evidence beyond equality under those fixed container paths. + +The measured native result was `identical`, with no differing offset/section. +Native reproduction remains a measurement, rather than a release equality +gate; this successful pair does not qualify a general reproducibility claim. + +The [Linux release-tooling job](https://github.com/IABTechLab/trusted-server/actions/runs/38921701566/job/117042986647) +also passed its actual two-fresh-installation JS proof in 702.81 seconds: +1,275 tests per side, 13 matching bundles and six private roots removed. Its +registered native producer tests passed; prebuilt-JS tests recorded 37 passed +and one ignored. The [companion Fastly job](https://github.com/IABTechLab/trusted-server/actions/runs/38921701566/job/117042986628) +passed Cargo build-script, grandchild and procedural-macro +network-denial attempts, including a successfully compiled control retaining +an actual compiler-remapped source filename. It also passed complete Fastly +canonical/reproduction equality and both final-byte Viceroy acceptance gates. +Tests, formatting, integration tests and CodeQL all passed on branch head +`0299c3a17812ce78def6b8df479f091a27120ce8`; this report is a later +documentation-only update. + +## Remaining scope + +Task 9 and milestone M4 remain open. The two Apple CLI recipes and native +startup acceptance, qualified production ambient profiles, independently +authenticated acquisition/tool/image selections, Task 8 hosted qualification +and subsequent executable composition/evidence work are still required. +Production wrappers and qualified selection remain closed. This candidate +does not sign, publish, deploy or enable an official release. diff --git a/scripts/release/qualification/2026-10-16-producer-normalized-manifests.md b/scripts/release/qualification/2026-10-16-producer-normalized-manifests.md new file mode 100644 index 0000000000..36644f90d6 --- /dev/null +++ b/scripts/release/qualification/2026-10-16-producer-normalized-manifests.md @@ -0,0 +1,49 @@ +# Task 9: producer-normalized Git manifests + +Status: on 2026-10-16 ordinary PR CI built the Fastly canonical and +reproduction outputs using Git package manifests that the producer normalized +itself from the verified Git tree, instead of manifests supplied by +`cargo vendor`. Both builds report zero caller-derived manifests. This is +candidate evidence from an ordinary CI runner and records +`release_qualification: false`; the toolchain, image, acquisition and the +production reproduction profile remain unqualified. + +## Run + +The job +[`cargo test (confined release Cargo candidate, Linux)`](https://github.com/IABTechLab/trusted-server/actions/runs/38884296635/job/116918199285) +checked out the pull request merge commit +`5e2c68a5e30c6b4da7fa8ae83faf30c96c9511f3` for branch head +`bf3057d60f99a92693c0836305129ba68962afd5`. The +ignored test +`recipe::tests::full::fastly_canonical_and_reproduction_builds_are_byte_identical` +passed in 1,103.54 seconds and the denial-inheritance test passed again. + +## Results + +- The producer resolved workspace inheritance for all eight locked `edgezero` + packages: package fields, workspace dependencies with merged features and + relocated paths, and workspace lints. The originals are kept as + `Cargo.toml.orig`. +- Before each build, an evidence-only differential compared every producer + manifest with the one `cargo vendor` writes, on package identity, `links`, + features, every dependency's source, version, rename, optionality, default + features and features, and the build-script and procedural-macro settings: + 8 compared, 0 differing, for both roles. +- Each build recorded 8 producer-normalized manifest digests and 0 + caller-derived manifest digests, so no build is marked unqualified for its + manifests. +- The canonical (133 s) and reproduction (166 s) builds were byte-identical: + 66,833,914 bytes, SHA-256 + `8df10dfc2373dbec38fe5a21bdf6153dbb49bec51368034c475350c223368d88`. +- The Viceroy final-byte gate accepted both outputs and rejected the + closed-origin control as a health-only success. + +`cargo vendor` now supplies only comparison fixtures; nothing it writes is +trusted by the build. + +## Not covered + +The three `ts` executables, the production reproduction profile, an +independently reviewed Viceroy digest, authenticated acquisition, qualified +pins and hosted qualification remain open under Tasks 8 and 9. diff --git a/scripts/release/qualification/2026-10-19-apple-sdk-inputs.md b/scripts/release/qualification/2026-10-19-apple-sdk-inputs.md new file mode 100644 index 0000000000..dc389cf374 --- /dev/null +++ b/scripts/release/qualification/2026-10-19-apple-sdk-inputs.md @@ -0,0 +1,98 @@ +# Apple SDK input audit + +Status: local candidate-input investigation, not hosted qualification or +authenticated vendor acquisition. + +The selected local Xcode 16.4 installation exposes macOS SDK 15.5. An +independent read-only traversal confirmed the following layout on 2026-10-19: + +| Observation | Count | +| ------------------------------------ | ----------- | +| Entries below the selected SDK root | 46,233 | +| Directories | 8,781 | +| Regular files | 30,727 | +| Regular-file bytes across all paths | 656,877,420 | +| Largest regular file, bytes | 8,899,657 | +| Relative internal symlinks | 6,725 | +| Regular files with multiple links | 8,605 | +| Special files | 0 | +| Absolute, dangling or escaping links | 0 | + +These observations describe this installation only. They do not establish +either hosted runner's SDK layout, original distribution integrity or +release qualification. + +## Selection consequence + +The existing owned tool-tree policy rejects multiply linked regular files. +The installed SDK therefore cannot pass that policy directly. Hardlinked +headers are part of the input set; excluding documentation cannot solve the +problem. Hashing the original SDK before and after execution would also +leave an alias outside the selected tree able to modify its content. + +The selected implementation preserves the existing owned-tree policy: + +1. Copy the selected SDK into a fresh owned directory, reading vendor files + through held descriptors and creating independent destination files. +2. Preserve permission bits and relative internal symlinks. Reject special + files, unsafe aliases and exceeded bounds; do not preserve hardlinks or + silently omit inputs. +3. Validate the detached tree with the existing 512 MiB per-file, 2 GiB + total-byte and 100,000-entry limits. +4. Bind its content digest and lifetime to the selected Apple tools, select + that exact SDK in the recipe, grant it a read-only boundary root and + remap its temporary path to a fixed prefix. +5. Recheck it through the existing tool-verification hooks, using the + caller's deadline and cancellation. Record the tree digest as structured + installed-content evidence, not a raw archive checksum. + +This closes selected SDK-content coverage only when the implementation and +its actual native ARM/Intel recipe executions pass. Compiler resources, +helper executables, toolchain libraries such as libLTO, operating-system +inputs, authenticated acquisition and full native/tool closure remain +separate requirements. Overall composition coverage must remain incomplete. + +## Local detached-copy smoke + +The real installed-SDK smoke passed in 168.94 seconds. It copied and +measured all 46,233 entries and 656,877,420 regular-file bytes, reverified the +selected copy while retaining a cloned tool selection, and confirmed that +the copy disappeared after the last owner was dropped. The observed +structured-tree SHA-256 is +`72007de53280923111d03a4b0277e2d3cc986ec9594cfcc3c9807f2e97d02fc8`. +This is installed-tree evidence, not a distribution checksum or approved +production pin. + +The local machine lacks the selection symlink used by the CI constructor, +so this ignored smoke test used an explicit canonical developer directory +and the same private measurement/staging implementation. CI still requires +its selection symlink; no fallback or qualified constructor was added. + +Pure regressions cover modified headers/stubs/frameworks, unsafe aliases, +hardlinks in the destination, exhausted bounds, cancellation, stable +path-independent identities, final-byte path leaks and cleanup of restrictive +directory modes on error and final-clone drop. A review finding caught the +read-only-directory cleanup leak before the full smoke; its regression was +observed failing before the cleanup guard was added. + +## Hosted native recipe follow-up — 2026-10-20 + +[Run 39290139247](https://github.com/IABTechLab/trusted-server/actions/runs/39290139247) +finalized success for head `df4a6e5c30fe8c93df68748ead77a3bc559d16b2`. +Its actual merge source was `506d8ead69757556e1bd8ed29b578d0257d05169`. +Both Apple candidate recipes compiled, inspected and started their canonical +CLI artifacts using detached `/apple-sdk`, Xcode 16.4 and SDK 15.5.0. +Both emitted the same installed SDK tree identity: +`1482e88484d3ab4b430306607ed07113068eadd7581b232dedb20ea80842bce0`, +46,233 entries and 656,877,420 regular-file bytes. Their SDK-settings file +SHA-256 was `58a133735f0a55a624a1703067059f6e78925e51725ec6e1f966072e142c9c42`. +This hosted tree digest differs from the local smoke's installed tree; +neither is an authenticated distribution identity or production pin. + +ARM's second build was byte-identical. Intel's canonical artifact and +retained receipt exports passed; its optional second build failed on its +time bound. No Intel reproducibility result is inferred from the green job. +The successful fixtures additionally joined verified Rust packages through +the typed SPDX projection and checked retained export integrity after +dropping the original build storage. Complete native/tool coverage and +release qualification remain false. diff --git a/scripts/release/qualification/2026-10-20-apple-cli-candidates.md b/scripts/release/qualification/2026-10-20-apple-cli-candidates.md new file mode 100644 index 0000000000..b528625959 --- /dev/null +++ b/scripts/release/qualification/2026-10-20-apple-cli-candidates.md @@ -0,0 +1,157 @@ +# Task 9: Apple CLI candidates build, inspect and start on native macOS runners + +Status: ordinary PR CI built both Apple `ts` candidate recipes on native macOS +15 runners and started each canonical executable. Two earlier runs +(2026-10-19) and two later runs (2026-10-20) are recorded below. Every record +carries `release_qualification: false`. This is candidate evidence only: it +does not qualify production tools, the Xcode/SDK selection, acquisition or +release enablement, and macOS 15 qualification (Task 8) remains open. + +## Runs and jobs + +All four runs were `Run Tests` workflows on branch `spec/1235-release-attestation` +and concluded `success`. The Intel optional reproduction is a bounded +measurement; its failure does not fail the job (see below). + +| Run | Branch head | Built source (PR merge commit) | ARM job | Intel job | +| ------------------------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| [39273777887](https://github.com/IABTechLab/trusted-server/actions/runs/39273777887) | `d8fdb265748181f3d419b7902b3b0590a67c2ad9` | `cace4c508159bc7502d359e0cce49fef47d61f8b` | [118142122193](https://github.com/IABTechLab/trusted-server/actions/runs/39273777887/job/118142122193) | [118142122336](https://github.com/IABTechLab/trusted-server/actions/runs/39273777887/job/118142122336) | +| [39290139247](https://github.com/IABTechLab/trusted-server/actions/runs/39290139247) | `df4a6e5c30fe8c93df68748ead77a3bc559d16b2` | `506d8ead69757556e1bd8ed29b578d0257d05169` | [118196413038](https://github.com/IABTechLab/trusted-server/actions/runs/39290139247/job/118196413038) | [118196413106](https://github.com/IABTechLab/trusted-server/actions/runs/39290139247/job/118196413106) | +| [39417433680](https://github.com/IABTechLab/trusted-server/actions/runs/39417433680) | `bfab8ef2009fa6f28b1258135e2445ee22775dc8` | `b05bd8a18f137b43f7542c797f0a5d7856363d23` | [118617017385](https://github.com/IABTechLab/trusted-server/actions/runs/39417433680/job/118617017385) | [118617017568](https://github.com/IABTechLab/trusted-server/actions/runs/39417433680/job/118617017568) | +| [39420011152](https://github.com/IABTechLab/trusted-server/actions/runs/39420011152) | `1719cf15fd58fcf029f6b4ae5003215802bef405` | `b2273db76b8a2070f3c0d20d22788eed434d0029` | [118625917697](https://github.com/IABTechLab/trusted-server/actions/runs/39420011152/job/118625917697) | [118625917703](https://github.com/IABTechLab/trusted-server/actions/runs/39420011152/job/118625917703) | + +The job logs retain `macos-cli-candidate-measurement.json` (printed by the +workflow step) and the Cargo denial measurement for each host. The source +commit embedded in and measured for each executable is the merge commit above, +which is why digests differ between runs even though the build is +deterministic for a given source. + +## Hosts and tools + +Both hosts reported macOS product version 15.7.9 (ARM: `aarch64`, +`hw_optional_arm64: 1`, `proc_translated: 0`; Intel: `x86_64`). The selected +developer tools were Xcode 16.4 with SDK 15.5.0 and the boundary was +`macos-sandbox-exec`. + +| Measurement | Value | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `clang` SHA-256 (ARM and Intel) | `4c458256bcdf913774de1bb4a37768244d3b41f32ad55afb2dfec3432f46f4fe` | +| ARM `ar` SHA-256 | `f362372395c1dd8ef349b6ceaad3b7958f1207a32e95d285721eba4c84ca55ae` | +| ARM `ld` SHA-256 | `648a8aac7f6f829a42305d24dddd7a3cfa48ee0bd9d4e7ec1ded5c51edea372b` | +| SDK settings SHA-256 | `58a133735f0a55a624a1703067059f6e78925e51725ec6e1f966072e142c9c42` | +| SDK tree, runs 39290139247 and later | 46,233 entries, 656,877,420 regular bytes, tree SHA-256 `1482e88484d3ab4b430306607ed07113068eadd7581b232dedb20ea80842bce0` on both ARM and Intel | +| SDK tree, run 39273777887 | ARM `e26d617a1870b3ecaf9bb0245da20662b0b8b6b66042ae15b2023a5fda37403f`, Intel `147b1549c1c3e34ca34a36ece451d474edb67e4a5343e8bf9f814b465bead527` | +| Native Cargo denial, control size/ARM | `denial-control-aarch64-apple-darwin`, 465,928 bytes, SHA-256 `c80e84f01196e51d420a0dffa0a4472db19ac956d51c67e0f66b104280abb8a8` | +| Native Cargo denial, control size/x86 | 451,072 bytes, SHA-256 `dadab281f3a2ec0542933a5b32ed4b0bb11a3d9add2d3499be477a1e47dff61d` | + +Run 39273777887 predates `df4a6e5c3` (detached SDK contents), so its SDK tree +digests differ between the two hosts. Runs from `df4a6e5c3` onward recorded +the same detached-tree digest on both. The Intel `clang` digest equals the ARM one in runs 39290139247 and +39417433680; other Intel tool digests are retained in the logs. + +Each host also passed the real native Cargo denial test +(`full_macos::cargo_children_inherit_denial_macos`): build-direct, +build-grandchild and procedural-macro attempts each failed with `errno` 1 +(`proof_errno` 1) under the boundary. The measurement reports +`descendant_cleanup: "post-run-reaped"`: after each run the sandbox instance's +processes are found, killed and the run is rejected if any survived. That is +weaker than a container owning its descendants, and only the `cfg(test)` +ordinary-CI tool selection accepts it (approved 2026-10-19). A qualified or +production selection still rejects the macOS boundary at the execute gate. + +## Executables and Mach-O facts + +Canonical builds used the same observed package, unit and build-script +counts in every run for a given host: ARM 486 packages, 649 units and 62 +build-script executions; Intel 487, 650 and 62. `material_native_coverage_complete` +stayed `false`. + +| Run | Host | Asset | SHA-256 | Size (bytes) | Canonical build (s) | +| ----------- | ----- | ------------------------- | ------------------------------------------------------------------ | ------------ | ------------------- | +| 39273777887 | ARM | `ts-aarch64-apple-darwin` | `65c68e611e3803cc9b80a0a48c218f629f3fa5752d787276a2b385a2f01874b9` | 43,260,288 | 663 | +| 39273777887 | Intel | `ts-x86_64-apple-darwin` | `c9da6682c713ecf597e5c85a2c01992af26294493b6249c6a9bb4a6fa5c6f54f` | 46,114,496 | 1,101 | +| 39290139247 | ARM | `ts-aarch64-apple-darwin` | `071ba0375ade6195552efaf363a384c8dd8684a9cfd8033c8c207a2b34def59d` | 43,260,288 | 560 | +| 39290139247 | Intel | `ts-x86_64-apple-darwin` | `3bfa14727f66915b76e95ce5445d5e19b68d11be78ba8caf69be6c1e3011ac42` | 46,114,496 | 1,140 | +| 39417433680 | ARM | `ts-aarch64-apple-darwin` | `923e5f7bddbfdb67596e45cdfe3adfcd5aaab353af94fb4f697d05ab4a32cd45` | 43,260,288 | 612 | +| 39417433680 | Intel | `ts-x86_64-apple-darwin` | `b892eb2a08c66c6ccbf945224a5e42ca1e59388ed0ae8b48d203c0a3c40ec367` | 46,114,496 | 879 | +| 39420011152 | ARM | `ts-aarch64-apple-darwin` | `1c28da620cb7654c5b376a7e09eff889fabc62cdc6e100a12b2c4d913c17e187` | 43,260,288 | 602 | +| 39420011152 | Intel | `ts-x86_64-apple-darwin` | `1fdceeae363ed7afe97f28056170240194eebcf1f3b216f7880add0d0fdf744d` | 46,114,496 | 914 | + +Mach-O inspection of the accepted canonical executable (identical across runs +except where noted): + +| Check | ARM | Intel | +| ---------------------- | ------------------------------------------------------------------------------- | ---------- | +| CPU | `arm64` | `x86_64` | +| Minimum OS (`minos`) | `13.0.0` | `13.0.0` | +| SDK | `15.5.0` | `15.5.0` | +| Signature | `linker-signed-ad-hoc` | `unsigned` | +| Dynamic libraries | Security, CoreFoundation, `libiconv.2`, `libSystem.B` | same four | +| Symbol count | 305,558 | 306,211 | +| Debug-map object files | 1,426 | 1,422 | +| Operator values | Three planted fictional variables removed by preflight; zero final-byte matches | same | + +Mach-O UUIDs differ per run because the embedded source commit differs (for +example ARM `ea89f38109c83fbea6bed4ac7de8f98b` and Intel +`47678b08fa0a38bcba792ffc65173f1f` in run 39290139247). The final-byte scan +rejected host build roots, the fixture root and `RUNNER_TEMP`, and found only +the remapped prefixes `/trusted-server` and `/cargo-vendor`. + +## Startup + +The accepted canonical executable was run as `ts --version` outside the +checkout with a scrubbed environment (`HOME`, `LC_ALL`, `PATH`, `TMPDIR` +only). Every start, in every run on both hosts, reported `status: accepted` +with exit code 0 and `ts package=0.1.0 source= target= variant=default`, for example (run 39290139247, ARM): + +```text +ts package=0.1.0 source=506d8ead69757556e1bd8ed29b578d0257d05169 target=aarch64-apple-darwin variant=default +``` + +The Intel output names `target=x86_64-apple-darwin`. The measurements record the host CPU and, on ARM, translation state +(`hw_optional_arm64: 1`, `proc_translated: 0`). This exercises loading and +version reporting on macOS 15.7.9 only. It does not exercise the macOS 13 +loader floor on a real macOS 13 host or every CLI command. + +## Reproduction measurement + +The reproduction (role B) uses separate fresh roots and the checked ambient +profile (umask 027, two jobs, different roots and start time). It is a +bounded measurement, never a release equality gate. + +| Run | ARM reproduction | Intel reproduction | +| ----------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| 39273777887 | `identical` (same SHA-256 and size as canonical) | `build-failed`: the confined Cargo build exceeded its wall-clock deadline (`sandbox-exec exceeded its wall-clock deadline`) while compiling | +| 39290139247 | `identical` | `build-failed`: `Release source is not authenticated` caused by `source preparation cancelled or timed out` | +| 39417433680 | `identical` | `identical` | +| 39420011152 | `identical` | `identical` | + +No Intel equality result exists for the two earlier runs and none is inferred. +The two later Intel results were identical, but the whole Intel candidate test +took 3,552 seconds (run 39417433680) and 3,305 seconds (run 39420011152) +against a 60-minute test deadline, after 3,844 seconds in run 39290139247 +where it was exceeded. No code change was made to widen that margin, so this +is a timing observation: it does not establish that Intel reproduction is now +reliable. Intel remains a bounded optional measurement. + +## Subsequent reproduction scheduling + +`3e3e30996` defers optional native reproduction on ordinary push/PR runs; +scheduled/dispatched runs and PRs carrying the reproduction label before a +triggering event measure it. Canonical Apple build/inspection/startup remain +mandatory. Run 39540057202 subsequently passed both canonical candidates, with +ARM reproduction identical and Intel reproduction failing at its 25-minute +confined-build wall. `b4c8e0c0f` raises that ordinary-CI wall to 45 minutes; +it does not establish Intel equality. See the [dated schedule report](2026-10-21-native-reproduction-schedule.md) +for exact jobs, built source and trigger limitations. Future equality statements +must cite the measured run/job and built source; a deferred pass supplies none. + +## Limits + +This evidence does not establish authenticated acquisition of Xcode, the SDK +or the Rust tree; original vendor identity; compiler resource, `libLTO` and +helper-tool coverage; production reproduction profiles; macOS 13 loader +behavior; or Task 8 qualification. The JS handoff came from a Linux ordinary-CI +artifact marked `authenticated: false`. Production wrappers and qualified +selection remain closed, and nothing here signs, publishes or enables an +official release. diff --git a/scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md b/scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md new file mode 100644 index 0000000000..b046213e07 --- /dev/null +++ b/scripts/release/qualification/2026-10-20-linux-cli-gnu-follow-up.md @@ -0,0 +1,153 @@ +# Task 9: Linux CLI candidate after the GNU snapshot and measurement fixes + +Status: ordinary PR CI passed the Linux `ts` candidate recipe again on +2026-10-20 after two hosted failures were fixed. The canonical and +reproduction builds were byte-identical and the exact accepted bytes started +in all three reviewed Linux images. This is candidate evidence with +`release_qualification: false`. It extends the +[2026-10-16 Linux CLI candidate report](2026-10-16-linux-cli-candidate.md) and +does not qualify production tools, image pins, acquisition or release +enablement. + +## Why the earlier candidate stopped passing + +After the 2026-10-16 pass, the candidate recipe gained source-owned GNU tool +selectors and a staged helper that records a bounded installed-image +inventory before and after Cargo. Two hosted runs then failed in the Linux CLI +candidate job; each failure was fixed by one commit. + +| Run | Head | Failed job | Recorded failure | Fix | +| ------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| [39371724671](https://github.com/IABTechLab/trusted-server/actions/runs/39371724671) | `7be86d935` | [118460869596](https://github.com/IABTechLab/trusted-server/actions/runs/39371724671/job/118460869596) | `cannot open image file: Permission denied (os error 13)` after 557.93 seconds | `e5a780d5c`: record three reviewed root-only helpers by metadata only | +| [39382624785](https://github.com/IABTechLab/trusted-server/actions/runs/39382624785) | `e5a780d5c` | [118494982595](https://github.com/IABTechLab/trusted-server/actions/runs/39382624785/job/118494982595) | `file .../release/build/prettyplease-.../build-script-build has 2 hard links; a link outside the root would bypass write denial` after 1,273.09 seconds | `bfab8ef20`: measure the image in a sibling sandbox without the target root | + +Both failures were in the real candidate test +`recipe::tests::cli::linux_cli_candidate_builds_inspects_and_starts_cleanly`; +other jobs in those runs are not summarised here. Run 39371724671 also had a +cancelled Intel macOS job, which is outside this report. + +### Reviewed root-only helpers (`e5a780d5c`) + +The confined GNU helper runs unprivileged and hashes every regular file under +the measured image roots. The UBI 9 builder ships three privileged helpers it +cannot open: `/usr/libexec/utempter/utempter` (setgid, mode 2711), +`/usr/sbin/userhelper` (setuid, 4711) and `/usr/sbin/unix_update` (0700), +observed identically on amd64 and arm64 by a local Podman build of the same +builder Dockerfile, listing files unreadable to a non-root user under the +measured roots (local diagnostic, not CI output). The snapshot now accepts +a permission-denied file only at those exact paths, owned by root, with no +group or other write bit, no other-read permission and unchanged since first +inspection. Each is recorded by mode, owner and size instead of content +(inode is excluded because it varies with host storage) and listed as +`content_unmeasured_files`, which the orchestrator requires to be a sorted +subset of the reviewed paths. Every other unreadable file, including an +execute-only compiler helper, still fails closed, and selected build tools +must remain readable and hashed. These three files' content is therefore +unmeasured; the SPDX candidate states the count as a coverage gap. + +### Sibling measurement sandbox (`bfab8ef20`) + +The second GNU image measurement ran in the build boundary, whose unchanged +pre-run writable scan rejects Cargo's in-root hard links in the target +directory. Both GNU measurements now run in a sibling Linux container +boundary derived from the build boundary: same image, settings, read-only and +tool roots, and every writable root except the last (the target directory). +The sibling has a fresh fingerprint, records the boundary it derived from and +proves denial with the staged probe. Cargo queries and the build keep the +build boundary and proof, receipts record both fingerprints, and completion +still binds the build boundary and plan. + +## Passing runs and jobs + +| Run | Head | Built source (PR merge commit) | Linux CLI job | Test duration | +| ------------------------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------- | +| [39417433680](https://github.com/IABTechLab/trusted-server/actions/runs/39417433680) | `bfab8ef2009fa6f28b1258135e2445ee22775dc8` | `b05bd8a18f137b43f7542c797f0a5d7856363d23` | [118609761876](https://github.com/IABTechLab/trusted-server/actions/runs/39417433680/job/118609761876) | 2,124.97 s | +| [39420011152](https://github.com/IABTechLab/trusted-server/actions/runs/39420011152) | `1719cf15fd58fcf029f6b4ae5003215802bef405` | `b2273db76b8a2070f3c0d20d22788eed434d0029` | [118618316864](https://github.com/IABTechLab/trusted-server/actions/runs/39420011152/job/118618316864) | 2,089.82 s | + +Both runs concluded `success` for every job, including the Fastly confined +Cargo job, the native producer jobs on Ubuntu 24.04 and macOS 15, and the +Apple candidate jobs (see the [Apple CLI candidates report](2026-10-20-apple-cli-candidates.md)). +The run for the later documentation-and-SPDX head `073704cc7` (Run Tests 39452090186) was still in progress when this report was written and is not +recorded. + +## Measured results + +| Measurement | Run 39417433680 (`bfab8ef20`) | Run 39420011152 (`1719cf15f`) | +| ------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Asset | `ts-x86_64-unknown-linux-gnu`, variant `default`, package `0.1.0` | same | +| SHA-256, canonical and reproduction | `066000e58902ad9e8b2077498383c27f466fd45337320082eab20da83a28df7d` | `99991c0ae4a34b0db1630925df5e0ca1ef4aa0c3ca17f30d9a162ed746923245` | +| Size, both builds | 282,956,784 bytes | 282,956,920 bytes | +| Reproduction status | `identical`, no differing offset | `identical`, no differing offset | +| Canonical Cargo execution | 473 seconds | 454 seconds | +| Observed packages/units/build scripts | 486 / 649 / 62 | 486 / 649 / 62 | +| Lock SHA-256 | `82ef943339970e6399fe56f434beee484a05e7a0fe0db40383a794651ab19214` | `82ef943339970e6399fe56f434beee484a05e7a0fe0db40383a794651ab19214` | +| Descendant cleanup | `owned-by-container` | not tabulated; same boundary | +| Builder image | `registry.access.redhat.com/ubi9/ubi:9.6`, glibc 2.34 | same reference, glibc 2.34 | +| Observed builder image ID | `sha256:417c122225da0b6990c9f50c32a7b3908022e30df6d5480b572b45be9dd4d03c` | `sha256:b4eae457404e63633888ffaff3cd5020c6ffb4cc4fb495657a8945698ebdd5a0` | + +The builder image ID differs between the two runs for the same mutable tag +and both differ from the 2026-10-16 report, which is further reason these are +observations from ordinary tag acquisition and not authenticated production +digests. The executable bytes differ between runs because the embedded source +commit differs, and the size and digest of this binary also differ from the +2026-10-16 report because the source and lock changed. + +Final-byte ELF inspection of the canonical executable (run 39417433680; run +39420011152 reported the same ELF facts): x86-64, interpreter +`/lib64/ld-linux-x86-64.so.2`, highest required GLIBC version `2.34` across 27 +version requirements, needed libraries `libgcc_s.so.1`, `libm.so.6`, +`libc.so.6` and `ld-linux-x86-64.so.2`, `.debug_info` 87,947,690 bytes and +`.debug_line` 33,663,154 bytes. The path scan found only the remapped +prefixes `/trusted-server`, `/cargo-vendor` and `/cargo-target` and none of +`/boundary/`, host build roots, fixture roots or `RUNNER_TEMP`; the three +planted fictional operator variables were removed by preflight with zero +final-byte matches. `material_native_coverage_complete` stayed `false`. + +The builder filesystem controls again exited zero: `/tmp`, `/dev/shm`, `/dev`, +`/etc/hosts`, `/etc/hostname` and `/etc/resolv.conf` writes denied, and the +bound `TMPDIR` accepted write, read and cleanup. + +## Startup + +The copied read-only executable ran as `ts --version` without a checkout or +operator configuration in each reviewed image. All starts exited zero with an +empty stderr (SHA-256 of empty input) and, in run 39420011152: + +```text +ts package=0.1.0 source=b2273db76b8a2070f3c0d20d22788eed434d0029 target=x86_64-unknown-linux-gnu variant=default +``` + +| Startup image | Observed local image ID (both runs) | +| ------------------------------------------------- | ------------------------------------------------------------------------- | +| `registry.access.redhat.com/ubi9/ubi-minimal:9.6` | `sha256:a2c5a85865a585c3bc8b10f6c269358fdf89fe32be4232885166889d85c76421` | +| `docker.io/library/ubuntu:22.04` | `sha256:cee13550f97b8a595506e8e4ca74263ba44924248779a4ba4f942e197652acff` | +| `docker.io/library/debian:12` | `sha256:94926f0fd968cd68ac486502185a5dd78687874a9ec7727f64fe2af696e28311` | + +The `ubuntu:22.04` image ID differs from the 2026-10-16 report +(`sha256:e8df6c74...`), showing the tag moved between runs. Run +39417433680 recorded the same three IDs. The real startup timeout regression +(`startup_timeout_removes_only_its_owned_container`) also passed in run 39417433680. + +## Subsequent reproduction scheduling + +The equality observations above remain tied to their original runs. Since +`3e3e30996`, ordinary push/PR runs defer the optional native reproduction; +scheduled/dispatched or appropriately labelled PR runs measure it. Linux +canonical build, inspection and all three startup checks remain mandatory. +See the [dated schedule report](2026-10-21-native-reproduction-schedule.md) +for the selector, exact baseline jobs and the subsequent 45-minute ordinary-CI +per-build wall. Later equality claims need a measured run/job and built source, +not a successful deferred job. GNU helper coverage and qualification gaps remain. + +## Limits + +The GNU installed-file snapshot and the sibling measurement record which files +were present and, except for three root-only helpers recorded by metadata, their +content hashes. They do not establish which helpers executed, authenticated +acquisition of the UBI image or tools, or a complete native closure. The +candidate SPDX for this build must list the privileged-helper gap. Native +reproduction remains a measurement rather than a release gate. Task 9 and +milestone M4 remain open: qualified production ambient profiles, the closed +`build.sh` interface, independently authenticated acquisition/tool/image +selections and Task 8 hosted qualification are still required. This candidate +does not sign, publish, deploy or enable an official release. diff --git a/scripts/release/qualification/2026-10-21-candidate-release-index.md b/scripts/release/qualification/2026-10-21-candidate-release-index.md new file mode 100644 index 0000000000..85398500a5 --- /dev/null +++ b/scripts/release/qualification/2026-10-21-candidate-release-index.md @@ -0,0 +1,90 @@ +# Task 13 candidate release index evidence + +Status: partial candidate implementation, without signing, publication or +release authority. The production Task 13 exit remains open. + +## Implemented boundary + +`crates/trusted-server-release/src/distribution.rs` and its `entry`, `joins` +and test-only `summary` modules retain candidate entries and exact-cohort index +assembly. `candidate_entry_from_completed` takes a canonical completed owner +with its matching actual runtime/startup acceptance; Fastly additionally requires +owning canonical/reproduction equality. It renders candidate SPDX, build-evidence, +build-recipe and rebuild-check companions. The candidate entries bind final-byte +digests and the retained original JS inventory. + +The index assembler requires exactly the four fixed canonical assets with one +source commit, policy identity and JS inventory, and checks the retained v1 +schema and mirrored consumer joins. It uses tag `v0.0.0-unqualified-candidate` +and name `candidate-release-index.json`. Signature slots contain size zero and +the actual empty-file SHA-256 sentinel; `BundleSlot::Signed` requires the +uninhabited `SignedCompanions` type. No signature file is fabricated. Companions +retain `release_qualification: false`, explicit gaps and null workflow/transfer +identities. The CLI rejects zero-size signature bundles, so these fixtures cannot +pass full release verification. Consumer rejection of sentinel/unqualified +documents after future signing remains a separate tracked requirement. + +Golden fixtures in `tests/fixtures/index/candidate/` and CLI +`release/evidence/candidate_fixture_tests.rs` exercise unsigned structural joins +and rejection boundaries. `recipe/tests/candidate_index.rs` covers pure owner +wiring rejections. `candidate_entry.rs` checks each real canonical entry +independently, with reporting in `distribution/summary.rs`. Collecting and +assembling the four real entries across CI jobs is now implemented. Each +candidate job uploads its rechecked entry and final executable. The +`test-release-candidate-index` job rebuilds the four entries from the exact +uploads (`distribution::transfer` and `distribution::ordinary`, test-only) and +assembles them. It then runs the CLI consumer's unsigned joins over the +result. The first hosted all-four assembly passed in run 39577910126; see the +[four-asset candidate join report](2026-10-21-four-asset-candidate-join.md). + +## Real canonical-entry observations + +The initial real-entry wiring in `4483e8256dc52c89e615f1388daa873324a871f5` +has [run 39529922087](https://github.com/IABTechLab/trusted-server/actions/runs/39529922087). +REST job conclusions show Fastly +[118993209872](https://github.com/IABTechLab/trusted-server/actions/runs/39529922087/job/118993209872), +Linux CLI [118993210395](https://github.com/IABTechLab/trusted-server/actions/runs/39529922087/job/118993210395) +and Apple ARM [118998766555](https://github.com/IABTechLab/trusted-server/actions/runs/39529922087/job/118998766555) +succeeded; Intel [118998766733](https://github.com/IABTechLab/trusted-server/actions/runs/39529922087/job/118998766733) +and the overall run were cancelled. These conclusions establish three completed +jobs, not an all-four result. Their measurements are not transcribed here; +the completed baseline below supplies the audited entry identities and outcomes. + +[Run 39540057202](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202) +concluded success for head `a9994b4c2b50265de4bbe5b8bb42cd8cab4e43ae`, building +source `d0cd53343fcbe1fb6f06eafc009e306e24b1245d`. Its logs retain: + +| Asset | Job | Candidate entry SHA-256 | Entry bytes | Entry rebuild outcome | +| --------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ----------- | --------------------- | +| Fastly Wasm | [119025502524](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119025502524) | `45ba885e8a210d6c1b6db92ff67fff9f4d3574370a118b7aa41be359831d5631` | 1,488 | `matched` | +| Linux GNU CLI | [119025502758](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119025502758) | `668c22fd33c959c269289192671630275939a6f0c324de36e13547b1db701c47` | 1,492 | `unavailable` | +| Apple ARM CLI | [119029080050](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119029080050) | `23d1b2beaeaeeae048b573afaaecd10be8c20d4b9f068cad6fb8f54d07d7a49e` | 1,450 | `unavailable` | +| Apple Intel CLI | [119029080038](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119029080038) | `336b4b3876205dde71cfd99f1726455953e950636544a67d6193fa4f9633fa63` | 1,441 | `unavailable` | + +These are entry-document digests, not executable or SBOM digests. The native +entry outcome is deliberately conservative: separate fixture measurements show +Linux and ARM reproduction `identical`, while Intel reproduction exhausted its +wall budget. Those measurements do not become owning rebuild proofs in the +candidate native entry. A passing Intel canonical entry does not imply equality. + +All four jobs recorded JS inventory SHA-256 +`24efd45b403e955eb4cb98c0b5e9f31e7f878d0be2637d2f89447660383511f6` +for that same built source. This supports inventory compatibility for a future +join of these observations; it does not establish that a real four-job join ran. +An inventory digest includes source-specific metadata, so values from different +built commits are not interchangeable. Other runs need their own verified values. + +## Remaining exits + +Task 12's trusted workflow dependency and transfer binding is the next +prerequisite to a trusted cross-job candidate join. It is not the only remaining +release-attestation work. The logs explicitly retain missing authenticated +bootstrap, incomplete native material coverage, unresolved Fastly host tools, +three GNU privileged helpers with unmeasured content, incomplete Apple tool +closure, absent signatures and absent trusted workflow/run identities. + +Complete qualified SPDX generation, authentic provenance subjects, actual +signature bundles, SHA256SUMS, immutable publication and final qualification +remain required. Builder/startup images still acquired by mutable tags do not +become authenticated because their installed IDs were recorded. This report +does not implement the transfer adapter or change any production gate. diff --git a/scripts/release/qualification/2026-10-21-four-asset-candidate-join.md b/scripts/release/qualification/2026-10-21-four-asset-candidate-join.md new file mode 100644 index 0000000000..a65ab5fe6b --- /dev/null +++ b/scripts/release/qualification/2026-10-21-four-asset-candidate-join.md @@ -0,0 +1,122 @@ +# Tasks 12 and 13: first cross-job four-asset candidate index + +Status: ordinary PR CI assembled one unqualified candidate release index from +the four real candidate jobs on 2026-10-21. This is candidate integration +evidence with `release_qualification: false`. It is not a signed release, +trusted-workflow binding, authenticated acquisition or qualification, and it +extends the [candidate release index report](2026-10-21-candidate-release-index.md). + +## Run + +| Field | Value | +| ------------------------ | ------------------------------------------------------------------------------------ | +| Run | [39577910126](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126) | +| Event | `pull_request` | +| PR head (REST head) | `d774ee47c274257fa0a088280f7df37ed530020c` | +| Built source (PR merge) | `fb42d8268af93c011959d032c81bfa581306c7c2` | +| Native reproduction mode | `defer` (recorded as `deferred-to-scheduled-run`) | +| Index evidence artifact | `unqualified-candidate-release-index-evidence-39577910126-1`, ID 12340882671, 7 days | + +The previous run, 39567710071 at `6ffebbd08`, completed all four producer jobs +and both macOS matrix outputs, but its collector rejected the first download: +it passed one 20-minute deadline while the bounded transport caps an attempt at +15 minutes. `d774ee47c` gives each download its own 10-minute attempt and +leaves the transport bound unchanged. + +## Jobs + +| Job | Name | Duration | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | -------- | +| [119147931992](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119147931992) | Confined release Cargo candidate (Fastly), Linux | 33 min | +| [119147931961](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119147931961) | Linux `ts` CLI release candidate | 31 min | +| [119148070099](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119148070099) | Linux JS handoff for macOS candidates | 14 min | +| [119154065068](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119154065068) | macOS `ts` CLI release candidate, `macos-15` | 38 min | +| [119154065062](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119154065062) | macOS `ts` CLI release candidate, `macos-15-intel` | 54 min | +| [119176658773](https://github.com/IABTechLab/trusted-server/actions/runs/39577910126/job/119176658773) | Assemble the unqualified four-asset candidate index | 15 min | + +## What the collector checked + +Each candidate job exported its rechecked entry companions, the shared JS +inventory and its final executable, uploaded them with the pinned upload +action and passed the exact artifact ID, name and digest through job outputs. +The collector, which runs only when all three candidate jobs succeed on a +same-repository event, then: + +- parsed four descriptors strictly and required distinct upload IDs, each + name recomputed from the fixed asset slot, run ID and producer attempt; +- downloaded each upload by exact ID through the bounded release-tool backend + and corroborated run, head, repository, name, digest and expiry in raw REST + metadata, then read the exact six flat ZIP members; +- authenticated a clean clone at the built source and read the consumer + policy from it, not from any producer; +- rebuilt each entry through the test-only `from_ordinary_ci_transfer`, which + reruns the inventory, build-evidence, SPDX, recipe and rebuild joins and + rehashes the executable against the recorded size and digest; +- required each rebuilt entry digest to equal the digest its producer + reported, and assembled the index with the unchanged assembler; +- reread the written index and its 17 sibling files and checked their + digests, the sentinel tag and the empty signature-bundle sentinels. + +The CLI consumer test then accepted the assembled bytes under its unsigned +joins and policy and rejected the size-zero signature bundles. + +## Entries + +| Asset | Producer entry SHA-256 | Executable SHA-256 | Executable bytes | +| ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------- | +| `trusted-server-fastly.wasm` | `93468d7d8aa4dcd066fe3b42c38943f09a732d3bc858d4ae9ed3c584c2482dc3` | `d43c4412c9b01daf6c74add4cbde01a6a6699b7b1959f9c15f0426c84e08ba63` | 66,992,633 | +| `ts-x86_64-unknown-linux-gnu` | `46676743d02c252b8a373a9242a4baf4dec3344003af44c6c314240533319db2` | `21c55e583569a84f353181022efdfd43d8310caf5fdf3fc58eba18317f8acc61` | 283,190,976 | +| `ts-aarch64-apple-darwin` | `d6984e55e659999c9c5c0575ac1f95c89bf70b25c85157d98638edd49ed1d4d2` | `f715aa69b467db23b846efbede73d5acc3313fa605a431115ba75c854a88f088` | 43,244,624 | +| `ts-x86_64-apple-darwin` | `b9cf2e517615876d8417a8c01fa1891504bb4ea9154dda97c1fb1405f282e03a` | `6826bed7761f62c84587f45a08f4493bab67a587b9346570cdd73ecf48220959` | 46,159,016 | + +## Assembled index + +| Field | Value | +| ------------------------------ | --------------------------------------------------------------------------------- | +| `candidate-release-index.json` | `4872ef96086e93f720187f363e7262ae2921e663fbc9a531f2a42cc5d803b14d` | +| Tag | `v0.0.0-unqualified-candidate` | +| Cohort | `phase1-fastly-cli` | +| Source, recipe and cohort | `fb42d8268af93c011959d032c81bfa581306c7c2` | +| Policy | version 1, `f08ce10cd16ff2d1acc482db2b85eb53b667ec2b4753792f277d12f41d1f636c` | +| `tsjs-inventory.json` | `90c79b953ca112310707d52100ebaefe6c97718466242c34ade46b188a54b729`, 246,440 bytes | + +All four candidate jobs built the same JS inventory bytes for this source. The +inventory digest differs from the one in the earlier candidate index report +because the source differs. + +The collector printed these unqualified candidate gaps, carried unchanged in +the companions: + +```text +apple-coverage-excluded:toolchain_libraries +apple-tool-closure-unmeasured +authenticated-bootstrap-not-established +candidate-tag-sentinel +gnu-original-acquisition-unverified +gnu-privileged-helper-content-unmeasured +gnu-tool-closure-unmeasured +native-material-coverage-incomplete +native-reproduction-unavailable +native-tool-host-path-unresolved:archiver +native-tool-host-path-unresolved:assembler +native-tool-host-path-unresolved:build_tools +native-tool-host-path-unresolved:compiler +native-tool-host-path-unresolved:linker +signature-bundles-awaiting-signature +trusted-workflow-transfer-not-bound +workflow-run-identity-unavailable +``` + +## Limits + +The exact-ID transfer and REST corroboration prove which uploads of this run +were read, not which job or attempt produced them: the artifact API does not +identify the producing job, so trusted workflow dependency binding (Task 15) +remains open and companions keep null workflow and transfer IDs. The +collector's `GH_TOKEN` is read-only and visible to the same-repository test +binary. The index is unsigned, uses the sentinel tag and cannot pass full +release verification. Native reproduction was deferred for this run, so the +three native entries record rebuild outcome `unavailable`; Fastly records +`matched`. Tasks 12 and 13 remain partial; signing, publication, authenticated +acquisition, complete native coverage and qualification are separate open +exits. diff --git a/scripts/release/qualification/2026-10-21-native-reproduction-schedule.md b/scripts/release/qualification/2026-10-21-native-reproduction-schedule.md new file mode 100644 index 0000000000..c4f5efc214 --- /dev/null +++ b/scripts/release/qualification/2026-10-21-native-reproduction-schedule.md @@ -0,0 +1,77 @@ +# Ordinary CI native reproduction scheduling and build cache + +Status: candidate CI optimization, not release qualification. Commit +`3e3e30996` changes when native reproduction is measured; `b4c8e0c0f` +increases the ordinary-CI confined Cargo build wall from 25 to 45 minutes. +Neither change establishes Intel equality or authenticates tools or images. + +## Selection and triggers + +The Linux and Apple CLI fixtures require `TS_RELEASE_NATIVE_REPRODUCTION` +to be `measure` or `defer`. The workflow selects `measure` for `schedule`, +`workflow_dispatch`, or a pull request carrying `release-native-reproduction`; +other push and pull-request runs select `defer`. Deferred runs record +`status: deferred-to-scheduled-run` and `gate: measurement-only`. The fixtures +reject missing/unknown modes and reject `defer` on scheduled/dispatched runs. +Canonical native build, inspection, startup and evidence checks remain required. +Fastly canonical/reproduction equality and final-byte runtime checks are unchanged. + +The cron is `23 4 * * *` (04:23 UTC daily). GitHub runs scheduled workflows +from the default branch; dispatch also requires the workflow file to exist on +that branch, although a dispatch can select another ref. These triggers do not +by themselves validate this unmerged branch. The PR label must be present +before the next push or another configured PR event: this workflow does not +subscribe to the `labeled` event. See GitHub's +[schedule](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) +and [dispatch](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_dispatch) +contracts. This report does not dispatch a run or change labels. + +## Cache scope and limits + +The pinned `actions/cache` v5.1.0 restore/save steps cache `target`, keyed by +runner OS, host target, pinned Rust version, lock/tool/manifest hashes and test +profile. The Linux Fastly producer job and Apple candidate jobs save only on +`refs/heads/main` and an exact-key miss, before ignored execution fixtures; +Linux CLI and JS handoff jobs restore without saving. Incremental directories +are removed before saving. This cache does not include `~/.cargo/registry` +and does not replace fresh confined application build roots. + +Measurements retain `ci_build_provenance`: producer/probe and test-binary +SHA-256 values plus `build_cache` (matched key, `miss`, or `unreported`). These +are observed identities, not independently authenticated provenance. Main-only +writes in this workflow and GitHub's branch-scoped cache access reduce sharing; +they do not qualify cached code or make a digest proof of trusted compilation. +See GitHub's [cache access restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache). +All measurements remain `release_qualification: false`. + +## Observed baseline and pending validation + +[Run 39540057202](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202) +concluded success for head `a9994b4c2b50265de4bbe5b8bb42cd8cab4e43ae`, building +PR merge source `d0cd53343fcbe1fb6f06eafc009e306e24b1245d`. It predates both +optimizations and supplies the timing baseline, not evidence that they work. + +| Candidate | Job | Observed result | +| --------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly | [119025502524](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119025502524) | Candidate entry rebuild outcome `matched` | +| Linux CLI | [119025502758](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119025502758) | Native reproduction `identical` | +| Apple ARM CLI | [119029080050](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119029080050) | Native reproduction `identical`; complete fixture 3,144.22 seconds | +| Apple Intel CLI | [119029080038](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119029080038) | Canonical Cargo execution 1,193 seconds; optional reproduction `build-failed` at its wall deadline; complete fixture 4,093.14 seconds | +| JS handoff | [119025605842](https://github.com/IABTechLab/trusted-server/actions/runs/39540057202/job/119025605842) | Fresh offline JS fixture passed in 474.23 seconds; this is fixture time, not total job time | + +The Intel reproduction timeout previously occurred in run 39273777887 and +recurred here, despite `identical` measurements in intervening historical runs +39417433680 and 39420011152 (see the [Apple report](2026-10-20-apple-cli-candidates.md)). +`ToolSelection::build_wall` now supplies 45 minutes for ordinary +CI instead of the fixed 25-minute wall. This is a per-confined-build budget, +not the complete fixture or job budget, and production selection remains closed. + +At this report's evidence check on 2026-10-21, the first run for +`b4c8e0c0fded27f02930edc6de990f17a63f5820`, +[39551207134](https://github.com/IABTechLab/trusted-server/actions/runs/39551207134), +was still in progress. Deferred reproduction, cache behavior, recorded producer +identities and unchanged mandatory Fastly equality require that run's completed +logs. A PR cache miss is expected when no eligible entry exists, not guaranteed; +main-only save steps must remain skipped on the PR. Intel equality under the +45-minute wall requires a later measured run; a deferred pass cannot establish it. +Every later equality claim must identify the measured run, job and built source. From be81ca871fbde4c50d1fda8d6b14b2c640aa84f0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 15:50:59 +0530 Subject: [PATCH 002/104] Map binary attestation phases to review boundaries --- .../2026-10-13-attested-release-implementation.md | 2 +- .../2026-10-09-1235-release-attestation-design.md | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 96e98f12ce..e3c16f85af 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -8,7 +8,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-serve **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. -For requirement headings below, R-CONFIG, R-INPUTS, R-SIGN and R-PUBLISH apply only to the Phase 1 subset described above; all additional clauses and every other R-ID belong to Phase 2+. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. +The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. ### Phase 1 implementation checklist diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 725aba2717..b50809318d 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -6,9 +6,22 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-serve **Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +The phase boundaries follow [aram356's round-1 architecture review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857): + +| Reviewer boundary | Phase ownership | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Controlled build and composition evidence | Phase 1: standard pinned, locked builds and conservative Cargo/npm SBOMs. Phase 2.1–2.7 and 2.9: reviewed controlled recipes, qualification, composition and candidate evidence from #1262. | +| 2. Signing and immutable publication | Phase 1: actions/attest and an immutable GitHub Release. Phase 2.8: the reviewed publication owner. | +| 3. Trusted Server verifier library, trusted local policy and source approval | Phase 1: users invoke GitHub's gh attestation verify. Phase 2.10: the reviewed custom consumer and policy from #1262. | +| 4. Opaque VerifiedArtifact with retained bytes | Phase 2.10: the reviewed consumer retains verified bytes; no custom verified value is required for Phase 1. | +| 5. EdgeZero prepared payload and resolved context | Later phase, not started; does not block #1235. | +| 6. Observed operation state and recovery record | Later phase, not started; does not block #1235. | + +Native reproduction is deferred to Phase 2+, where it is measured, not a release gate. Fastly-first byte-for-byte reproducibility is a later phase; Phase 1 makes no reproducibility claim. One artifact's reproduction therefore cannot hold up the first attested release. + **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. -For requirement headings below, R-CONFIG, R-INPUTS, R-SIGN and R-PUBLISH apply only to the Phase 1 subset described above; all additional clauses and every other R-ID belong to Phase 2+. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. +The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. **Issue:** [#1235 — Publish attested release binaries with build provenance and SBOMs](https://github.com/IABTechLab/trusted-server/issues/1235) From ca062174c8732c6256d0a29c6086968309bb6859 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 16:03:17 +0530 Subject: [PATCH 003/104] Build and attest phased release binaries --- .github/workflows/release.yml | 181 ++++++++++++++++++++++++++ scripts/release/phase1-build.sh | 81 ++++++++++++ scripts/release/phase1.mjs | 157 ++++++++++++++++++++++ scripts/release/phase1.test.mjs | 222 ++++++++++++++++++++++++++++++++ 4 files changed, 641 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/release/phase1-build.sh create mode 100644 scripts/release/phase1.mjs create mode 100644 scripts/release/phase1.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..71b3b129d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,181 @@ +name: Binary attestations + +on: + push: + tags: ['v*'] + pull_request: + paths: + - '.github/workflows/release.yml' + - 'scripts/release/phase1*' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.tool-versions' + - '.cargo/**' + - 'edgezero.toml' + - 'crates/**' + +permissions: + contents: read + +concurrency: + group: binary-attestations-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.asset }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + package: trusted-server-adapter-fastly + target: wasm32-wasip1 + asset: trusted-server-fastly.wasm + - os: ubuntu-24.04 + package: trusted-server-cli + target: x86_64-unknown-linux-gnu + asset: ts-x86_64-unknown-linux-gnu + - os: macos-15 + package: trusted-server-cli + target: aarch64-apple-darwin + asset: ts-aarch64-apple-darwin + - os: macos-15-intel + package: trusted-server-cli + target: x86_64-apple-darwin + asset: ts-x86_64-apple-darwin + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Read pinned toolchains + id: tools + shell: bash + run: | + set -euo pipefail + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned Rust toolchain without build caches + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + BUILD_TARGET: ${{ matrix.target }} + run: rustup toolchain install "$RUST_VERSION" --profile minimal --target "$BUILD_TARGET" + - name: Test local release checks + run: node --test scripts/release/phase1.test.mjs + - name: Build and generate the build inventory in a clean environment + shell: bash + env: + BUILD_PACKAGE: ${{ matrix.package }} + BUILD_TARGET: ${{ matrix.target }} + RELEASE_ASSET: ${{ matrix.asset }} + SOURCE_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + env -i PATH="$PATH" HOME="$HOME" \ + CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" \ + RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ + TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ + SOURCE_COMMIT="$SOURCE_COMMIT" \ + bash scripts/release/phase1-build.sh "$BUILD_PACKAGE" "$BUILD_TARGET" "$RELEASE_ASSET" + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: release-${{ matrix.asset }} + path: release-assets/* + if-no-files-found: error + retention-days: 7 + + sign: + name: Attest ${{ matrix.asset }} + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + asset: + - trusted-server-fastly.wasm + - ts-x86_64-unknown-linux-gnu + - ts-aarch64-apple-darwin + - ts-x86_64-apple-darwin + steps: + # No checkout, build scripts or executables from the artifacts run here. + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-${{ matrix.asset }} + path: release-assets + - name: Attest SLSA build provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: release-assets/${{ matrix.asset }} + - name: Attest the binary's SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: release-assets/${{ matrix.asset }} + sbom-path: release-assets/${{ matrix.asset }}.cdx.json + + release: + name: Publish the immutable release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: sign + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: release-* + merge-multiple: true + path: release-assets + - name: Draft, upload, publish and verify immutability + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Treat all API errors except an explicit 404 as fatal. + if gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" > existing.json 2> lookup-error.txt; then + echo '::error::A release already exists for this tag; refusing to overwrite it.' + exit 1 + elif ! grep -q 'HTTP 404' lookup-error.txt; then + cat lookup-error.txt >&2 + exit 1 + fi + cd release-assets + expected=(trusted-server-fastly.wasm ts-x86_64-unknown-linux-gnu ts-aarch64-apple-darwin ts-x86_64-apple-darwin) + files=() + for asset in "${expected[@]}"; do + test -s "$asset" + test -s "$asset.cdx.json" + files+=("$asset" "$asset.cdx.json") + done + test "$(find . -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 8 + sha256sum "${files[@]}" > SHA256SUMS + gh release create "$RELEASE_TAG" --repo "$GH_REPO" --draft --verify-tag \ + --title "$RELEASE_TAG" --notes 'Attested release binaries. Verify provenance, SBOM and immutable asset membership before use; see the release verification guide at this tag.' + gh release upload "$RELEASE_TAG" "${files[@]}" SHA256SUMS --repo "$GH_REPO" + gh release edit "$RELEASE_TAG" --draft=false --repo "$GH_REPO" + immutable=$(gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" --jq '.immutable') + if [ "$immutable" != true ]; then + echo '::error::Published release is NOT immutable. Maintainer action required; do not use these assets.' + exit 1 + fi + gh release verify "$RELEASE_TAG" --repo "$GH_REPO" + for asset in "${files[@]}" SHA256SUMS; do + gh release verify-asset "$RELEASE_TAG" "$asset" --repo "$GH_REPO" + done diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh new file mode 100644 index 0000000000..fdfd7364da --- /dev/null +++ b/scripts/release/phase1-build.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Phase 1 ordinary hosted build; no reproducibility or complete composition claim. +set -euo pipefail + +build_package=${1:?package required} +build_target=${2:?target required} +release_asset=${3:?asset required} +case "$build_package/$build_target/$release_asset" in + trusted-server-adapter-fastly/wasm32-wasip1/trusted-server-fastly.wasm) executable=trusted-server-adapter-fastly.wasm ;; + trusted-server-cli/x86_64-unknown-linux-gnu/ts-x86_64-unknown-linux-gnu|trusted-server-cli/aarch64-apple-darwin/ts-aarch64-apple-darwin|trusted-server-cli/x86_64-apple-darwin/ts-x86_64-apple-darwin) executable=ts ;; + *) echo 'Unsupported release build selection' >&2; exit 1 ;; +esac + +: "${RUNNER_TEMP:?}" +: "${SOURCE_COMMIT:?}" +[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] +[[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] +for variable in TSJS_SKIP_BUILD TSJS_PREBUILT_DIR TSJS_TEST; do + if printenv "$variable" >/dev/null; then + echo "Refusing $variable in the release build" >&2 + exit 1 + fi +done +node scripts/release/phase1.mjs audit + +# No operator values enter through inherited environment; these fictional canaries +# additionally catch known config embedding regressions in the final executable. +export TRUSTED_SERVER__GIT_VERSION="$SOURCE_COMMIT" +export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://phase1-origin-canary.example.com +export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=phase1-secret-canary-32-characters +export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=phase1-physical-store-canary +export FASTLY_SERVICE_ID=phase1-service-account-canary +export MACOSX_DEPLOYMENT_TARGET=15.0 +[[ ! -e trusted-server.toml ]] +cat > trusted-server.toml <<'CONFIG' +# Fictional config must never become a build input. +[publisher] +origin_url = "https://phase1-origin-canary.example.com" +proxy_secret = "phase1-secret-canary-32-characters" +[permissions] +policy = "phase1-permission-policy-canary" +CONFIG +trap 'rm -f trusted-server.toml' EXIT + +export CARGO_TARGET_DIR="$RUNNER_TEMP/phase1-target" +[[ ! -e "$CARGO_TARGET_DIR" ]] +mkdir -p release-assets +[[ -z "$(ls -A release-assets)" ]] + +# The pinned Node distribution includes npm. npm verifies lockfile integrity for +# fetched packages; no generator dependencies are installed with unpinned npx. +npm --prefix crates/trusted-server-js/lib ci +cargo build --package "$build_package" --release --target "$build_target" --locked +cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset" +node scripts/release/phase1.mjs check "release-assets/$release_asset" + +# Pin and checksum the generator source before extraction or execution. Cargo's +# --locked installation authenticates its transitive crates via its shipped lock. +tool_root="$RUNNER_TEMP/phase1-cyclonedx" +mkdir "$tool_root" +curl --fail --silent --show-error --location --retry 3 \ + https://crates.io/api/v1/crates/cargo-cyclonedx/0.5.9/download \ + --output "$tool_root/generator.crate" +printf '%s %s\n' 5d162f67705f0f5038759d73bf546a083bf30e8677c2e944b416bca48d9d69a8 "$tool_root/generator.crate" | shasum -a 256 --check + +tar -xf "$tool_root/generator.crate" -C "$tool_root" +CARGO_TARGET_DIR="$tool_root/target" cargo install \ + --path "$tool_root/cargo-cyclonedx-0.5.9" --locked --root "$tool_root/install" +# The generator resolves the whole workspace, including unbuilt members. Acquire +# their selected sources under the lock before denying network resolution. +cargo metadata --locked --format-version 1 --filter-platform "$build_target" > "$tool_root/cargo-metadata.json" +# Pinned generator has no --locked flag. Resolve offline after the locked build +# with identical target/default features, then require the lock to be unchanged. +CARGO_NET_OFFLINE=true "$tool_root/install/bin/cargo-cyclonedx" cyclonedx \ + --manifest-path "crates/$build_package/Cargo.toml" --target "$build_target" \ + --format json --spec-version 1.5 --override-filename phase1 +npm --prefix crates/trusted-server-js/lib sbom --sbom-format cyclonedx > "$tool_root/npm.cdx.json" +node scripts/release/phase1.mjs merge \ + "crates/$build_package/phase1.json" "$tool_root/npm.cdx.json" \ + "release-assets/$release_asset" "release-assets/$release_asset.cdx.json" +git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs new file mode 100644 index 0000000000..e4499545ef --- /dev/null +++ b/scripts/release/phase1.mjs @@ -0,0 +1,157 @@ +/** Small Phase 1 build-inventory and configuration checks, without release authority. */ +import { createHash } from 'node:crypto' +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +// Adapted from #1262's native recipe scanner at 10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807. +export const sentinels = [ + 'phase1-origin-canary.example.com', + 'phase1-physical-store-canary', + 'phase1-service-account-canary', + 'phase1-secret-canary-32-characters', + 'phase1-permission-policy-canary', +] + +export function checkBinary(bytes) { + if (bytes.length === 0) throw new Error('Release binary is empty') + for (const marker of sentinels) { + if (bytes.includes(Buffer.from(marker))) + throw new Error('Release binary contains planted operator configuration') + } +} + +export function checkSource(file, source) { + const macros = source.matchAll(/\b(?:option_env|env)!\s*\(\s*"([^"]+)"/g) + for (const [, variable] of macros) { + if ( + /^(?:TRUSTED_SERVER__|EDGEZERO__)/.test(variable) && + variable !== 'TRUSTED_SERVER__GIT_VERSION' + ) { + throw new Error(`${file}: deployment compile-time environment input`) + } + } + if ( + /\binclude_(?:str|bytes)!\s*\([^)]*"[^"\n]*(?:trusted-server\.toml|\.env(?:[."]))/s.test( + source + ) || + (file.endsWith('build.rs') && + /["'][^"'\n]*trusted-server\.toml["']/.test(source)) + ) { + throw new Error(`${file}: operator compile-time file input`) + } +} + +function namespace(bom, prefix) { + if ( + bom.bomFormat !== 'CycloneDX' || + bom.specVersion !== '1.5' || + !bom.metadata?.component?.['bom-ref'] + ) { + throw new Error('Expected CycloneDX 1.5 with a root component') + } + const rename = (value, key) => { + if (Array.isArray(value)) return value.map((item) => rename(item, key)) + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, rename(v, k)]) + ) + return typeof value === 'string' && + ['bom-ref', 'ref', 'dependsOn', 'provides'].includes(key) + ? `${prefix}:${value}` + : value + } + return rename(bom) +} + +export function mergeSboms(cargo, npm, binary, name) { + checkBinary(binary) + const rust = namespace(cargo, 'cargo') + const javascript = namespace(npm, 'npm') + const root = rust.metadata.component + const npmRoot = javascript.metadata.component + const dependencies = [ + ...(rust.dependencies ?? []), + ...(javascript.dependencies ?? []), + ] + let edge = dependencies.find((entry) => entry.ref === root['bom-ref']) + if (!edge) dependencies.push((edge = { ref: root['bom-ref'], dependsOn: [] })) + edge.dependsOn = [...(edge.dependsOn ?? []), npmRoot['bom-ref']] + return { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + ...rust.metadata, + component: { + ...root, + name, + hashes: [ + { + alg: 'SHA-256', + content: createHash('sha256').update(binary).digest('hex'), + }, + ], + }, + properties: [ + { + name: 'trusted-server:coverage', + value: + 'Selected Cargo dependency graph plus the installed npm build tree used for this release build. Conservative inventory: components may be removed by linking, tree-shaking or minification. External Prebid is not bundled; its installed package is a build-tree entry only. Native C/assembly, system libraries, Rust standard library and compiler runtimes are not completely inventoried. Complete composition is Phase 2.', + }, + ], + }, + components: [ + ...(rust.components ?? []), + npmRoot, + ...(javascript.components ?? []), + ], + dependencies, + } +} + +function audit(root) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (['node_modules', 'target', 'dist', '.git'].includes(entry.name)) + continue + const file = join(root, entry.name) + if (entry.isDirectory()) audit(file) + else if (entry.isFile() && file.endsWith('.rs')) + checkSource(file, readFileSync(file, 'utf8')) + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + const [command, ...args] = process.argv.slice(2) + if (command === 'audit' && args.length === 0) { + audit('crates') + } else if (command === 'check' && args.length > 0) { + for (const file of args) { + if (!statSync(file).isFile()) + throw new Error('Expected regular release binary') + checkBinary(readFileSync(file)) + } + } else if (command === 'merge' && args.length === 4) { + const [cargo, npm, binary, output] = args + writeFileSync( + output, + JSON.stringify( + mergeSboms( + JSON.parse(readFileSync(cargo)), + JSON.parse(readFileSync(npm)), + readFileSync(binary), + binary.split('/').at(-1) + ), + null, + 2 + ) + '\n' + ) + } else { + throw new Error( + 'Expected audit, check , or merge ' + ) + } +} diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs new file mode 100644 index 0000000000..99ec2cc267 --- /dev/null +++ b/scripts/release/phase1.test.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + readFileSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { spawnSync } from 'node:child_process' +import { checkBinary, checkSource, mergeSboms, sentinels } from './phase1.mjs' + +test('rejects every planted operator value, including inside binary data', () => { + for (const marker of sentinels) { + assert.throws( + () => + checkBinary( + Buffer.concat([ + Buffer.from([0]), + Buffer.from(marker), + Buffer.from([255]), + ]) + ), + /operator configuration/ + ) + } + assert.doesNotThrow(() => + checkBinary(Buffer.from('fictional clean executable')) + ) + assert.throws(() => checkBinary(Buffer.alloc(0)), /empty/) +}) + +test('rejects deployment compile inputs while permitting product inputs', () => { + for (const source of [ + 'env!("TRUSTED_SERVER__EC__PASSPHRASE")', + 'option_env!("EDGEZERO__STORES__CONFIG__NAME")', + 'include_str!("../../trusted-server.toml")', + 'include_bytes!(".env.production")', + ]) { + assert.throws(() => checkSource('src/lib.rs', source), /compile-time/) + } + assert.throws( + () => checkSource('build.rs', 'fs::read("trusted-server.toml")'), + /compile-time/ + ) + assert.doesNotThrow(() => + checkSource( + 'src/lib.rs', + 'option_env!("TRUSTED_SERVER__GIT_VERSION"); include_str!("trusted-server.example.toml")' + ) + ) +}) + +test('merges independent graphs without colliding references and binds binary bytes', () => { + const bom = (name) => ({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { component: { type: 'application', name, 'bom-ref': 'root' } }, + components: [ + { type: 'library', name: `${name}-dependency`, 'bom-ref': 'dependency' }, + ], + dependencies: [ + { ref: 'root', dependsOn: ['dependency'] }, + { ref: 'dependency', dependsOn: [] }, + ], + }) + const output = mergeSboms( + bom('cargo'), + bom('npm'), + Buffer.from('binary'), + 'example.wasm' + ) + assert.equal(output.metadata.component.name, 'example.wasm') + assert.equal(output.metadata.component.hashes[0].alg, 'SHA-256') + assert.equal(output.metadata.component.hashes[0].content.length, 64) + assert.deepEqual( + output.dependencies.find((d) => d.ref === 'cargo:root').dependsOn, + ['cargo:dependency', 'npm:root'] + ) + assert.equal( + new Set(output.components.map((c) => c['bom-ref'])).size, + output.components.length + ) + assert.throws( + () => mergeSboms({}, bom('npm'), Buffer.from('binary'), 'example'), + /CycloneDX/ + ) +}) + +test('workflow signs only tags, separates privileged jobs, and never checks out in signing', () => { + const workflow = readFileSync( + new URL('../../.github/workflows/release.yml', import.meta.url), + 'utf8' + ) + const sign = workflow.split('\n sign:')[1]?.split('\n release:')[0] + assert.ok(sign, 'should define the isolated signing job') + assert.match(sign, /github.event_name == 'push'/) + assert.doesNotMatch( + sign, + /checkout@|cargo |npm |contents: write|artifact-metadata:/ + ) + assert.equal((sign.match(/uses: actions\/attest@/g) ?? []).length, 2) + assert.match(workflow, /cancel-in-progress: false/) + assert.doesNotMatch(workflow, /workflow_dispatch|--clobber/) + for (const line of workflow + .split('\n') + .filter((line) => /uses: .*@/.test(line))) { + assert.match(line, /@[a-f0-9]{40} # v/) + } +}) + +test('release publication refuses existing releases and API errors, and does not publish after upload failure', () => { + const workflow = readFileSync( + new URL('../../.github/workflows/release.yml', import.meta.url), + 'utf8' + ) + const script = workflow + .slice(workflow.lastIndexOf(' run: |\n') + ' run: |\n'.length) + .split('\n') + .map((line) => line.slice(10)) + .join('\n') + const root = mkdtempSync(join(tmpdir(), 'phase1-publication-')) + try { + const bin = join(root, 'bin') + mkdirSync(bin) + writeFileSync( + join(bin, 'gh'), + `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.GH_LOG, JSON.stringify(args) + '\\n'); +const mode = process.env.FIXTURE_MODE; +if (args[0] === 'api' && args.length === 2) { + if (mode === 'existing') { process.stdout.write('{}'); process.exit(0); } + process.stderr.write(mode === 'api-error' ? 'HTTP 403' : 'HTTP 404'); process.exit(1); +} +if (args[0] === 'api') process.stdout.write(mode === 'mutable' ? 'false' : 'true'); +if (args[1] === 'upload' && mode === 'upload-error') process.exit(1); +`, + { mode: 0o755 } + ) + writeFileSync( + join(bin, 'sha256sum'), + '#!/bin/sh\nexec shasum -a 256 "$@"\n', + { mode: 0o755 } + ) + for (const mode of [ + 'existing', + 'api-error', + 'upload-error', + 'mutable', + 'success', + ]) { + const working = join(root, mode) + mkdirSync(join(working, 'release-assets'), { recursive: true }) + for (const asset of [ + 'trusted-server-fastly.wasm', + 'ts-x86_64-unknown-linux-gnu', + 'ts-aarch64-apple-darwin', + 'ts-x86_64-apple-darwin', + ]) { + for (const suffix of ['', '.cdx.json']) + writeFileSync( + join(working, 'release-assets', asset + suffix), + 'fictional fixture' + ) + } + const log = join(working, 'gh.log') + const result = spawnSync('bash', ['-c', script], { + cwd: working, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + GH_REPO: 'example/project', + RELEASE_TAG: 'v0.0.0-example', + GH_LOG: log, + FIXTURE_MODE: mode, + }, + }) + const calls = readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) + assert.equal( + result.status, + mode === 'success' ? 0 : 1, + `${mode}: ${result.stderr}` + ) + if (['existing', 'api-error'].includes(mode)) + assert.equal(calls.length, 1) + if (mode === 'upload-error') + assert.ok(!calls.some((args) => args[1] === 'edit')) + if (mode === 'mutable') assert.match(result.stdout, /NOT immutable/) + if (mode === 'success') + assert.equal( + calls.filter((args) => args[1] === 'verify-asset').length, + 9 + ) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('acquires the locked workspace graph before offline SBOM resolution', () => { + const script = readFileSync( + new URL('./phase1-build.sh', import.meta.url), + 'utf8' + ) + const acquisition = script.indexOf( + 'cargo metadata --locked --format-version 1 --filter-platform' + ) + assert.ok( + acquisition >= 0, + 'should populate the locked workspace dependency sources' + ) + assert.ok( + acquisition < script.indexOf('CARGO_NET_OFFLINE=true'), + 'should acquire before offline generation' + ) +}) From 4dd217125c6e38055e4bcb2cba32c0b95e739914 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 16:23:27 +0530 Subject: [PATCH 004/104] Document binary verification and release deployment --- CHANGELOG.md | 1 + .../src/commands/dev/lint/domains.rs | 32 +++ docs/.vitepress/config.mts | 4 + docs/guide/cli.md | 2 + docs/guide/release-verification.md | 204 ++++++++++++++++++ ...6-10-13-attested-release-implementation.md | 12 +- .../plans/binary-attestations-slice-map.json | 15 +- ...6-10-09-1235-release-attestation-design.md | 4 +- 8 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 docs/guide/release-verification.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f691c6a005..b3cd49d9bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the Phase 1 binary-attestation workflow: tag-triggered Fastly wasm and Linux/macOS CLI builds, provenance and conservative Cargo/npm CycloneDX SBOM attestations, immutable release assets and operator verification/deployment instructions. Release enablement still requires maintainer settings and a sandbox signing/publication rehearsal. - Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. - `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary. - Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. 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 8978af61c0..9a141aafa0 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -111,9 +111,17 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "iabtechlab.com", "iabtechlab.github.io", "iabeurope.github.io", + // GitHub CLI: canonical verification command documentation. + "cli.github.com", // Specs (supply chain) "in-toto.io", "rslstandard.org", + // SLSA: canonical provenance predicate and build specification references. + "slsa.dev", + // SPDX: canonical document predicate in the retained reviewed contract. + "spdx.dev", + // CycloneDX: canonical SBOM predicate used by Phase 1 verification. + "cyclonedx.org", // Specs (other) "webassembly.org", // Fastly docs @@ -311,6 +319,30 @@ mod allow_check_tests { fn reference_hosts_allowed_everywhere() { assert!(is_allowed("github.com", ¬hing_suppressed())); assert!(is_allowed("docs.rs", ¬hing_suppressed())); + assert!( + is_allowed("slsa.dev", ¬hing_suppressed()), + "should allow the canonical slsa.dev reference" + ); + assert!( + is_allowed("spdx.dev", ¬hing_suppressed()), + "should allow the canonical spdx.dev reference" + ); + assert!( + is_allowed("cyclonedx.org", ¬hing_suppressed()), + "should allow the canonical cyclonedx.org reference" + ); + assert!( + is_allowed("cli.github.com", ¬hing_suppressed()), + "should allow the canonical cli.github.com reference" + ); + assert!( + !is_allowed("unapproved.slsa.dev", ¬hing_suppressed()), + "should reject unapproved slsa.dev subdomains" + ); + assert!( + !is_allowed("unapproved.cyclonedx.org", ¬hing_suppressed()), + "should reject unapproved cyclonedx.org subdomains" + ); // But NOT subdomains of REFERENCE_HOSTS (exact-match). assert!(!is_allowed("other.github.com", ¬hing_suppressed())); } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4220851797..b5959253bc 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -101,6 +101,10 @@ export default withMermaid( { text: 'EdgeZero Lifecycle', link: '/guide/edgezero' }, { text: 'Configuration', link: '/guide/configuration' }, { text: 'CLI', link: '/guide/cli' }, + { + text: 'Release Verification', + link: '/guide/release-verification', + }, { text: 'Dev Proxy', link: '/guide/ts-dev-proxy' }, { text: 'Testing', link: '/guide/testing' }, { text: 'Auction Testing', link: '/guide/auction-testing' }, diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 9466e1f3df..7333eef7a5 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -1,5 +1,7 @@ # Trusted Server CLI +Prebuilt release binaries must pass the [release verification procedure](/guide/release-verification) before execution or deployment. That guide lists the Linux/macOS baselines and the manual Fastly route that preserves verified wasm bytes. + The Trusted Server CLI binary is `ts`. It is a host-target operator tool for configuration, page audits, and EdgeZero-backed lifecycle commands. diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md new file mode 100644 index 0000000000..b2bdaff368 --- /dev/null +++ b/docs/guide/release-verification.md @@ -0,0 +1,204 @@ +# Verify release binaries + +Phase 1 publishes `trusted-server-fastly.wasm` and three `ts` executables with +SLSA build provenance, a CycloneDX SBOM for each binary, and immutable GitHub +Release membership. Verify downloads before executing or deploying them. +Reproducible builds and the custom Trusted Server verifier are later phases; +Phase 1 operators must use the downloaded, verified bytes. + +## Platforms and coverage + +| Asset | Hosted build baseline | +| ----------------------------- | ------------------------------------------------- | +| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features | +| `ts-x86_64-unknown-linux-gnu` | Ubuntu 24.04 x86-64, glibc 2.39 baseline | +| `ts-aarch64-apple-darwin` | macOS 15 or newer, Apple Silicon | +| `ts-x86_64-apple-darwin` | macOS 15 or newer, Intel | + +The Linux binary is built against the Ubuntu 24.04 environment; compatibility +with older glibc releases is not established. Native libraries and tools needed +by individual `ts` commands retain their existing prerequisites. The macOS +builds set `MACOSX_DEPLOYMENT_TARGET=15.0`; these executables are not notarized. +Cloudflare, Spin, Axum, Windows and Linux ARM64 assets follow separately. + +Each `.cdx.json` is a **conservative build dependency inventory**: +`cargo-cyclonedx` 0.5.9 resolves the selected Cargo target after its locked release +build, and the npm bundled with pinned Node generates an SBOM of the installed +JS build tree from that run. The merger namespaces both dependency graphs and +records the final binary's SHA-256. The generator source archive is pinned by +SHA-256 before installation; Cargo's shipped lock and npm's committed lock +check downloaded package integrity. + +This covers the npm dependencies used to build the embedded JS, including build +tools. It can include packages removed by linking, tree-shaking or minification. +The installed `prebid.js` package is a build-tree entry; the external Prebid +bundle is not embedded in tsjs. Native C/assembly, system libraries, Rust's +standard library and compiler runtimes do not have complete coverage here. +Complete composition evidence and artifact-specific retained-code attribution +remain Phase 2 work. An SBOM or provenance result is not a vulnerability scan. + +## Download and verify + +Use an independently trusted GitHub CLI installation with `gh attestation verify`, +`gh release verify`, and `gh release verify-asset`. Authenticate if GitHub requires +it. Verification needs GitHub network access; Phase 1 does not provide an offline +release-membership decision. Obtain the approved tag and its **peeled commit +SHA** through your own source-review process. A tag name, checksum file or +commit supplied only alongside an untrusted download is not source approval. + +Run this in a new private directory, using Bash. Replace the fictional tag and +commit below. For a rehearsal, change `REPO` to the approved public sandbox and +use the individually approved, never-reused rehearsal tag. + +```bash +set -euo pipefail +REPO=IABTechLab/trusted-server +TAG=v1.2.3 +COMMIT=0123456789abcdef0123456789abcdef01234567 +ASSETS=(trusted-server-fastly.wasm ts-x86_64-unknown-linux-gnu \ + ts-aarch64-apple-darwin ts-x86_64-apple-darwin) + +gh release download "$TAG" --repo "$REPO" --dir . +test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.immutable')" = true +gh release verify "$TAG" --repo "$REPO" +for asset in "${ASSETS[@]}"; do + gh release verify-asset "$TAG" "$asset" --repo "$REPO" + gh release verify-asset "$TAG" "$asset.cdx.json" --repo "$REPO" +done +gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" +shasum -a 256 --check SHA256SUMS +``` + +A checksum detects corruption; immutable-release verification establishes +membership. Neither replaces provenance verification. Verify **each binary** +against both the SLSA provenance predicate and the SBOM predicate: + +```bash +WORKFLOW="$REPO/.github/workflows/release.yml" +for asset in "${ASSETS[@]}"; do + gh attestation verify "$asset" --repo "$REPO" \ + --signer-workflow "$WORKFLOW" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 + gh attestation verify "$asset" --repo "$REPO" \ + --signer-workflow "$WORKFLOW" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --deny-self-hosted-runners \ + --predicate-type https://cyclonedx.org/bom +done +``` + +The SBOM attestation's subject is the **binary**, and its predicate is the SBOM. +Do not pass the JSON asset as the binary subject. The JSON asset's own immutable +membership was checked above. + +`--signer-workflow` matches a workflow prefix. Also enforce the exact certificate +identity, including the tag ref, with these separate checks. GitHub CLI does not +allow `--signer-workflow` together with `--cert-identity` in one invocation. + +```bash +IDENTITY="https://github.com/$WORKFLOW@refs/tags/$TAG" +for asset in "${ASSETS[@]}"; do + for predicate in https://slsa.dev/provenance/v1 https://cyclonedx.org/bom; do + gh attestation verify "$asset" --repo "$REPO" \ + --cert-identity "$IDENTITY" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type "$predicate" + done +done +``` + +Stop if any check fails. Keep the command outputs, reviewed source identity and +checksums with your deployment record. These checks establish the workflow, +source and published bytes; they do not establish publisher authorization, +runtime configuration or remote execution. See the +[GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify) +and [immutable asset verification reference](https://cli.github.com/manual/gh_release_verify-asset). + +## Use the verified CLI + +Choose the executable matching your platform, verify it as above, then copy it +into a directory on your `PATH` as `ts` and make it executable. Verify before the +first execution. A source checkout and provider tools may still be required by +individual commands; see [CLI](/guide/cli). + +## Deploy the verified Fastly wasm + +Keep the verified download unchanged. The ordinary source-build route does not +establish equality with this release, and Phase 1 adds no `ts deploy --release` +command. Use a prebuilt package route for the verified bytes. + +For a manual deployment to an **existing** Fastly service, use the Fastly CLI +version pinned in the release tag's `.tool-versions`. Prepare a fresh private +packaging directory containing only the verified wasm and a minimal manifest: + +```toml +manifest_version = 3 +name = "example-app" +``` + +Do not copy operator config, secrets, local fixtures or build hooks into that +directory. Approve the destination service and base version, and inspect their +Config, KV and Secret Store links beforehand. The logical config-store ID comes +from the release tag's tracked `edgezero.toml`; physical store names and actual +configuration are runtime resources. This manual route does not create links, +push configuration, implement staging, or supply recovery guarantees. + +From the private directory: + +```bash +fastly compute pack --wasm-binary ./trusted-server-fastly.wasm +tar -tzf ./pkg/example-app.tar.gz +tar -xOzf ./pkg/example-app.tar.gz example-app/bin/main.wasm > packaged.wasm +cmp trusted-server-fastly.wasm packaged.wasm +``` + +Inspect the archive inventory: it must contain only the expected named package, +its directories, `fastly.toml`, and `bin/main.wasm`. Check the extracted manifest +against the approved minimal manifest. Select the named archive explicitly; +packing can also produce a generic `package.tar.gz`. Do not guess an archive +name or use a previously populated `pkg/` directory. + +After verifying the package member is byte-identical, use your reviewed service +ID and integer base version in place of these fictional values: + +```bash +fastly compute deploy --package ./pkg/example-app.tar.gz \ + --service-id '99999' --version '7' --no-default-domain +``` + +This command can activate production and change an active/locked base version +by cloning it; confirm its behavior for the pinned CLI before use. Retain the +verified wasm, manifest, package digest, previous active version and observed +result. No build step may replace or annotate the verified wasm. Automated +EdgeZero prepared-payload deployment and operation recovery are later phases. + +## Build inputs and release administration + +Release builds use the tracked source tree and manifests, `Cargo.lock`, the +Rust toolchain from `rust-toolchain.toml`, Node from `.tool-versions`, the JS +lockfile and fresh JS bundles from that build. The tracked `edgezero.toml` +supplies the logical config-store ID and participates in the template build +digest. Build facts include the source commit. The CLI also carries the +fictional example-config scaffold; it does not carry an operator config. + +CI audits compile-time deployment inputs, builds with a scrubbed inherited +environment, and scans every final executable for planted fictional config +values. These are regression checks, not full hermetic-build qualification. +Operator `trusted-server.toml`, credentials, physical resource bindings and +variable permission policy must remain runtime inputs. + +Before any real release, a maintainer must enable immutable releases, protect +`v*` tags, and decide required checks and tag-push authority. Immutability is a +human precondition: the workflow checks the published release and fails loudly +if it is false, but a violation can already have exposed a mutable release. +The job has no repository-administration token. Existing releases, including +drafts from a failed attempt, are never overwritten; inspect failures and use +a fresh approved tag rather than rerunning publication against existing assets. + +First rehearse the same approved commit in the user's public sandbox with +Actions and immutable releases enabled. The user must approve **each** new +`v0.0.0-rehearsal.N` tag before it is pushed. Run the commands above against that +sandbox and record the outputs in the draft PR. Never use IAB's repository for +rehearsal tags or releases. The first real tag requires separate authorization. diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index e3c16f85af..136be701f6 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -6,6 +6,8 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-serve **Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +Phase 1 uses CycloneDX 1.5 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. The complete SPDX/composition contract below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. + **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. @@ -13,11 +15,11 @@ The phase labels on requirement headings below apply only to the reduced Phase 1 ### Phase 1 implementation checklist - [x] Copy reviewed spec, plan, feasibility and schemas from `122b78664b1235c717e9208fd200f9a9b0c335df`; preserve linked historical evidence. -- [ ] Add `.github/workflows/release.yml`: four tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. -- [ ] Generate CycloneDX 1.5 from the selected Cargo target after its locked release build and npm's installed tree from that same build; merge without reference collisions and bind the final binary hash. Record conservative build-dependency coverage, including npm build tools; do not claim exact retained-byte or native-source closure. -- [ ] Enforce fresh npm install, prohibit skip/prebuilt overrides, audit compile-time config inputs and reject planted operator values in every binary. -- [ ] Document exact GitHub CLI provenance/SBOM and immutable-asset verification, native baselines and deployment of verified Fastly bytes; update CLI guide, navigation and changelog. -- [ ] Run pure local tests, actionlint, format/domain checks, touched build gates, independent and security review; push this branch and open only the requested draft PR. +- [x] Add `.github/workflows/release.yml`: four tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. +- [x] Generate CycloneDX 1.5 from the selected Cargo target after its locked release build and npm's installed tree from that same build; merge without reference collisions and bind the final binary hash. Record conservative build-dependency coverage, including npm build tools; do not claim exact retained-byte or native-source closure. +- [x] Enforce fresh npm install, prohibit skip/prebuilt overrides, audit compile-time config inputs and reject planted operator values in every binary. +- [x] Document exact GitHub CLI provenance/SBOM and immutable-asset verification, native baselines and deployment of verified Fastly bytes; update CLI guide, navigation and changelog. +- [x] Run pure local tests, actionlint, format/domain checks, touched build gates, independent and security review; push this branch and open only the requested draft PR. | #1235 acceptance item | Phase 1 evidence | | ----------------------------------------------------- | ---------------------------------------------------------------------- | diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json index 68c91723d1..b26c825208 100644 --- a/docs/superpowers/plans/binary-attestations-slice-map.json +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -590,5 +590,18 @@ "phase": "2.11", "state": "Closed; no triggers or production selection" } - } + }, + "phase1_code_reuse": [ + { + "path": "crates/trusted-server-cli/src/commands/dev/lint/domains.rs", + "source_commit": "6d0efda7210983021ab3c9efe8a0a3190e9eb81b", + "subset": "SLSA and SPDX exact reference-host entries. Other source scanner changes and tests stay in the consumer slice." + }, + { + "source_path": "crates/trusted-server-release/src/recipe/native.rs", + "source_commit": "10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807", + "destination": "scripts/release/phase1.mjs", + "subset": "Translate the small planted-operator-value byte scanner; no producer crate or qualification machinery is imported." + } + ] } diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index b50809318d..8ecd442b66 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -19,6 +19,8 @@ The phase boundaries follow [aram356's round-1 architecture review](https://gith Native reproduction is deferred to Phase 2+, where it is measured, not a release gate. Fastly-first byte-for-byte reproducibility is a later phase; Phase 1 makes no reproducibility claim. One artifact's reproduction therefore cannot hold up the first attested release. +Phase 1 uses CycloneDX 1.5 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. The complete SPDX/composition contract below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. + **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. @@ -351,7 +353,7 @@ The initial verified tool contract is GitHub CLI **2.97.0** and `actions/attest` | Certificate issuer | `https://token.actions.githubusercontent.com` | | Source ref / source digest | Exact `refs/tags/` / independently approved 40-character commit | | Build signer digest | Same source commit for this same-revision local reusable workflow | -| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | +| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | | Caller / external parameters | Exactly `workflow: {ref: "refs/tags/", repository: "https://github.com/IABTechLab/trusted-server", path: ".github/workflows/release.yml"}`; no arbitrary inputs/commands | | Source resolved dependency | `git+https://github.com/IABTechLab/trusted-server@refs/tags/` with `gitCommit` equal to approved commit | | Event / runner | `push` / `github-hosted` | From 3c41de592502e32545c8f8cf11e98ef1b14cd5f2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 17:18:28 +0530 Subject: [PATCH 005/104] Align Phase 1 signing and publication with the reviewed contract --- .github/workflows/release-build.yml | 150 +++++++++++ .github/workflows/release.yml | 251 ++++++++----------- scripts/release/phase1-build.sh | 32 ++- scripts/release/phase1.mjs | 128 +++++++++- scripts/release/phase1.test.mjs | 376 +++++++++++++++++++++------- 5 files changed, 698 insertions(+), 239 deletions(-) create mode 100644 .github/workflows/release-build.yml diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000000..63edc2f560 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,150 @@ +name: Release build and signing +on: + workflow_call: +permissions: + contents: read +jobs: + checks: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - run: node --test scripts/release/phase1.test.mjs + build: + needs: checks + name: Build ${{ matrix.asset }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + package: trusted-server-adapter-fastly + target: wasm32-wasip1 + asset: trusted-server-fastly.wasm + - os: ubuntu-24.04 + package: trusted-server-cli + target: x86_64-unknown-linux-gnu + asset: ts-x86_64-unknown-linux-gnu + - os: macos-15 + package: trusted-server-cli + target: aarch64-apple-darwin + asset: ts-aarch64-apple-darwin + - os: macos-15-intel + package: trusted-server-cli + target: x86_64-apple-darwin + asset: ts-x86_64-apple-darwin + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Read pinned toolchains + id: tools + shell: bash + run: | + set -euo pipefail + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned Rust toolchain without build caches + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + BUILD_TARGET: ${{ matrix.target }} + run: rustup toolchain install "$RUST_VERSION" --profile minimal --target "$BUILD_TARGET" + - name: Build and generate the build inventory in a clean environment + shell: bash + env: + BUILD_PACKAGE: ${{ matrix.package }} + BUILD_TARGET: ${{ matrix.target }} + RELEASE_ASSET: ${{ matrix.asset }} + SOURCE_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + env -i PATH="$PATH" HOME="$HOME" \ + CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" \ + RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ + TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ + SOURCE_COMMIT="$SOURCE_COMMIT" \ + bash scripts/release/phase1-build.sh "$BUILD_PACKAGE" "$BUILD_TARGET" "$RELEASE_ASSET" + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: release-${{ matrix.asset }} + path: release-assets/* + if-no-files-found: error + retention-days: 7 + + sign: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + asset: + [ + trusted-server-fastly.wasm, + ts-aarch64-apple-darwin, + ts-x86_64-apple-darwin, + ts-x86_64-unknown-linux-gnu, + ] + steps: + # Only fixed inline checks execute here; never checkout or execute build inputs. + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-${{ matrix.asset }} + path: release-assets + - name: Validate signing inputs and retain their digests + env: + ASSET: ${{ matrix.asset }} + run: | + set -euo pipefail + cd release-assets + test "$(find . -mindepth 1 -maxdepth 1 | wc -l)" -eq 2 + for file in "$ASSET" "$ASSET.spdx.json"; do test -f "$file" && test ! -L "$file" && test -s "$file"; done + sha256sum "$ASSET" "$ASSET.spdx.json" > ../inputs.sha256 + - name: Attest SLSA build provenance + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + release-assets/${{ matrix.asset }} + release-assets/${{ matrix.asset }}.spdx.json + - name: Attest the binary's SPDX SBOM + id: sbom + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: release-assets/${{ matrix.asset }} + sbom-path: release-assets/${{ matrix.asset }}.spdx.json + - name: Retain only this run's real bundles and signed-input digests + env: + ASSET: ${{ matrix.asset }} + PROVENANCE: ${{ steps.provenance.outputs.bundle-path }} + SBOM: ${{ steps.sbom.outputs.bundle-path }} + run: | + set -euo pipefail + (cd release-assets && sha256sum --check ../inputs.sha256) + test -s "$PROVENANCE" && test -s "$SBOM" + mkdir signed-assets + cp "$PROVENANCE" "signed-assets/$ASSET.provenance.sigstore.json" + cp "$SBOM" "signed-assets/$ASSET.sbom.sigstore.json" + cp inputs.sha256 signed-assets/SHA256SUMS + (cd signed-assets && sha256sum "$ASSET.provenance.sigstore.json" "$ASSET.sbom.sigstore.json" >> SHA256SUMS) + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: signed-${{ matrix.asset }} + path: signed-assets/* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71b3b129d0..28f54004f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,10 @@ name: Binary attestations - on: push: tags: ['v*'] pull_request: paths: - - '.github/workflows/release.yml' + - '.github/workflows/release*.yml' - 'scripts/release/phase1*' - 'Cargo.toml' - 'Cargo.lock' @@ -13,169 +12,139 @@ on: - '.tool-versions' - '.cargo/**' - 'edgezero.toml' - - 'crates/**' - + - 'crates/trusted-server-core/**' + - 'crates/trusted-server-cli/**' + - 'crates/trusted-server-js/**' + - 'crates/trusted-server-adapter-fastly/**' + - 'crates/trusted-server-openrtb*/**' permissions: contents: read - concurrency: group: binary-attestations-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: - name: Build ${{ matrix.asset }} - runs-on: ${{ matrix.os }} - timeout-minutes: 90 + uses: ./.github/workflows/release-build.yml permissions: contents: read - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04 - package: trusted-server-adapter-fastly - target: wasm32-wasip1 - asset: trusted-server-fastly.wasm - - os: ubuntu-24.04 - package: trusted-server-cli - target: x86_64-unknown-linux-gnu - asset: ts-x86_64-unknown-linux-gnu - - os: macos-15 - package: trusted-server-cli - target: aarch64-apple-darwin - asset: ts-aarch64-apple-darwin - - os: macos-15-intel - package: trusted-server-cli - target: x86_64-apple-darwin - asset: ts-x86_64-apple-darwin - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - name: Read pinned toolchains - id: tools - shell: bash - run: | - set -euo pipefail - echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" - echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 - with: - node-version: ${{ steps.tools.outputs.node }} - - name: Install the pinned Rust toolchain without build caches - env: - RUST_VERSION: ${{ steps.tools.outputs.rust }} - BUILD_TARGET: ${{ matrix.target }} - run: rustup toolchain install "$RUST_VERSION" --profile minimal --target "$BUILD_TARGET" - - name: Test local release checks - run: node --test scripts/release/phase1.test.mjs - - name: Build and generate the build inventory in a clean environment - shell: bash - env: - BUILD_PACKAGE: ${{ matrix.package }} - BUILD_TARGET: ${{ matrix.target }} - RELEASE_ASSET: ${{ matrix.asset }} - SOURCE_COMMIT: ${{ github.sha }} - run: | - set -euo pipefail - env -i PATH="$PATH" HOME="$HOME" \ - CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" \ - RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ - TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ - SOURCE_COMMIT="$SOURCE_COMMIT" \ - bash scripts/release/phase1-build.sh "$BUILD_PACKAGE" "$BUILD_TARGET" "$RELEASE_ASSET" - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: release-${{ matrix.asset }} - path: release-assets/* - if-no-files-found: error - retention-days: 7 - - sign: - name: Attest ${{ matrix.asset }} + id-token: write + attestations: write + release: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') needs: build runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: - contents: read - id-token: write - attestations: write - strategy: - fail-fast: false - matrix: - asset: - - trusted-server-fastly.wasm - - ts-x86_64-unknown-linux-gnu - - ts-aarch64-apple-darwin - - ts-x86_64-apple-darwin + contents: write steps: - # No checkout, build scripts or executables from the artifacts run here. - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - name: release-${{ matrix.asset }} - path: release-assets - - name: Attest SLSA build provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + name: release-trusted-server-fastly.wasm + path: build/trusted-server-fastly.wasm + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - subject-path: release-assets/${{ matrix.asset }} - - name: Attest the binary's SBOM - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + name: signed-trusted-server-fastly.wasm + path: signed/trusted-server-fastly.wasm + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - subject-path: release-assets/${{ matrix.asset }} - sbom-path: release-assets/${{ matrix.asset }}.cdx.json - - release: - name: Publish the immutable release - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - needs: sign - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: write - steps: + name: release-ts-aarch64-apple-darwin + path: build/ts-aarch64-apple-darwin + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: signed-ts-aarch64-apple-darwin + path: signed/ts-aarch64-apple-darwin + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-ts-x86_64-apple-darwin + path: build/ts-x86_64-apple-darwin - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - pattern: release-* - merge-multiple: true - path: release-assets - - name: Draft, upload, publish and verify immutability - shell: bash + name: signed-ts-x86_64-apple-darwin + path: signed/ts-x86_64-apple-darwin + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-ts-x86_64-unknown-linux-gnu + path: build/ts-x86_64-unknown-linux-gnu + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: signed-ts-x86_64-unknown-linux-gnu + path: signed/ts-x86_64-unknown-linux-gnu + - name: Verify handoff, create once, publish by release ID and verify env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} run: | set -euo pipefail - # Treat all API errors except an explicit 404 as fatal. - if gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" > existing.json 2> lookup-error.txt; then - echo '::error::A release already exists for this tag; refusing to overwrite it.' - exit 1 - elif ! grep -q 'HTTP 404' lookup-error.txt; then - cat lookup-error.txt >&2 - exit 1 - fi - cd release-assets - expected=(trusted-server-fastly.wasm ts-x86_64-unknown-linux-gnu ts-aarch64-apple-darwin ts-x86_64-apple-darwin) - files=() - for asset in "${expected[@]}"; do - test -s "$asset" - test -s "$asset.cdx.json" - files+=("$asset" "$asset.cdx.json") - done - test "$(find . -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 8 - sha256sum "${files[@]}" > SHA256SUMS - gh release create "$RELEASE_TAG" --repo "$GH_REPO" --draft --verify-tag \ - --title "$RELEASE_TAG" --notes 'Attested release binaries. Verify provenance, SBOM and immutable asset membership before use; see the release verification guide at this tag.' - gh release upload "$RELEASE_TAG" "${files[@]}" SHA256SUMS --repo "$GH_REPO" - gh release edit "$RELEASE_TAG" --draft=false --repo "$GH_REPO" - immutable=$(gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" --jq '.immutable') - if [ "$immutable" != true ]; then - echo '::error::Published release is NOT immutable. Maintainer action required; do not use these assets.' - exit 1 - fi - gh release verify "$RELEASE_TAG" --repo "$GH_REPO" - for asset in "${files[@]}" SHA256SUMS; do - gh release verify-asset "$RELEASE_TAG" "$asset" --repo "$GH_REPO" - done + gh --version + node <<'NODE' + ;(async () => { + // BEGIN PUBLICATION + const fs = require('node:fs'); + const { createHash } = require('node:crypto'); + const { execFileSync } = require('node:child_process'); + const { GH_REPO: repo, RELEASE_TAG: tag, GH_TOKEN: token, SOURCE_COMMIT: source } = process.env; + if (!/^[\w-]+\/[\w.-]+$/.test(repo) || !/^v[\w.+-]+$/.test(tag)) throw Error('Invalid repository or tag'); + const names = ['trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', 'ts-x86_64-unknown-linux-gnu']; + const hash = bytes => createHash('sha256').update(bytes).digest('hex'); + const regular = path => { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.size === 0) throw Error(`Invalid file: ${path}`); return fs.readFileSync(path); }; + const exact = (dir, expected) => { if (JSON.stringify(fs.readdirSync(dir).sort()) !== JSON.stringify([...expected].sort())) throw Error(`Unexpected artifact contents: ${dir}`); }; + fs.mkdirSync('release-assets'); + const files = []; + for (const name of names) { + const build = `build/${name}`, signed = `signed/${name}`; + const inputs = [name, `${name}.spdx.json`], bundles = [`${name}.provenance.sigstore.json`, `${name}.sbom.sigstore.json`]; + exact(build, inputs); exact(signed, [...bundles, 'SHA256SUMS']); + const receipt = [...inputs.map(file => [build, file]), ...bundles.map(file => [signed, file])].map(([dir, file]) => { + const bytes = regular(`${dir}/${file}`); + fs.writeFileSync(`release-assets/${file}`, bytes); files.push(file); + return `${hash(bytes)} ${file}\n`; + }).join(''); + if (regular(`${signed}/SHA256SUMS`).toString() !== receipt) throw Error('Signing handoff digest mismatch'); + } + fs.writeFileSync('release-assets/SHA256SUMS', files.map(file => `${hash(regular(`release-assets/${file}`))} ${file}\n`).join('')); + files.push('SHA256SUMS'); + const api = async (path, method = 'GET', body) => { + const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com + if (!response.ok) throw Error(`GitHub API failed: ${method} ${path} ${response.status}`); + return response.json(); + }; + // List releases, including drafts. Never resume, overwrite or delete a matching tag. + for (let page = 1; ; page++) { + if (page > 1000) throw Error('Release listing exceeded bound'); + const releases = await api(`releases?per_page=100&page=${page}`); + if (!Array.isArray(releases)) throw Error('Invalid release listing'); + if (releases.some(release => release.tag_name === tag)) throw Error('Release or draft already exists'); + if (releases.length < 100) break; + } + const created = await api('releases', 'POST', {tag_name:tag, target_commitish:source, name:tag, draft:true, body:'Verify binary provenance, SPDX SBOM and immutable release membership using the release verification guide at this tag.'}); + if (!Number.isSafeInteger(created.id) || created.id <= 0 || created.tag_name !== tag || created.draft !== true) throw Error('Invalid created release identity'); + const id = created.id; + console.log(`Created release ID: ${id}`); + for (const file of files) { + const bytes = regular(`release-assets/${file}`); + const response = await fetch(`https://uploads.github.com/repos/${repo}/releases/${id}/assets?name=${encodeURIComponent(file)}`, {method:'POST', headers:{Authorization:`Bearer ${token}`, 'Content-Type':'application/octet-stream'}, body:bytes}); // allow-domain: uploads.github.com + if (!response.ok) throw Error(`Asset upload failed: ${file}`); + const asset = await response.json(); + if (asset.name !== file || asset.state !== 'uploaded' || asset.size !== bytes.length || asset.digest !== `sha256:${hash(bytes)}`) throw Error(`Upload mismatch: ${file}`); + } + const published = await api(`releases/${id}`, 'PATCH', {draft:false}); + const verifyIdentity = async () => { + const release = await api(`releases/${id}`), byTag = await api(`releases/tags/${encodeURIComponent(tag)}`); + if (published.id !== id || release.id !== id || byTag.id !== id || release.tag_name !== tag || release.draft !== false || release.immutable !== true) throw Error('Published release identity or immutability mismatch'); + if (!Array.isArray(release.assets) || release.assets.length !== files.length || new Set(release.assets.map(asset => asset.name)).size !== files.length) throw Error('Published asset set mismatch'); + for (const asset of release.assets) { + if (!files.includes(asset.name)) throw Error('Unexpected published asset'); + const bytes = regular(`release-assets/${asset.name}`); + if (asset.size !== bytes.length || asset.digest !== `sha256:${hash(bytes)}`) throw Error('Published asset digest mismatch'); + } + }; + await verifyIdentity(); + execFileSync('gh', ['release','verify',tag,'--repo',repo], {stdio:'inherit'}); + for (const file of files) execFileSync('gh', ['release','verify-asset',tag,`release-assets/${file}`,'--repo',repo], {stdio:'inherit'}); + await verifyIdentity(); + // END PUBLICATION + })().catch(error => { console.error(error); process.exitCode = 1; }); + NODE diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index fdfd7364da..1e7ca88cf0 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash # Phase 1 ordinary hosted build; no reproducibility or complete composition claim. set -euo pipefail - build_package=${1:?package required} build_target=${2:?target required} release_asset=${3:?asset required} @@ -10,7 +9,6 @@ case "$build_package/$build_target/$release_asset" in trusted-server-cli/x86_64-unknown-linux-gnu/ts-x86_64-unknown-linux-gnu|trusted-server-cli/aarch64-apple-darwin/ts-aarch64-apple-darwin|trusted-server-cli/x86_64-apple-darwin/ts-x86_64-apple-darwin) executable=ts ;; *) echo 'Unsupported release build selection' >&2; exit 1 ;; esac - : "${RUNNER_TEMP:?}" : "${SOURCE_COMMIT:?}" [[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] @@ -22,7 +20,6 @@ for variable in TSJS_SKIP_BUILD TSJS_PREBUILT_DIR TSJS_TEST; do fi done node scripts/release/phase1.mjs audit - # No operator values enter through inherited environment; these fictional canaries # additionally catch known config embedding regressions in the final executable. export TRUSTED_SERVER__GIT_VERSION="$SOURCE_COMMIT" @@ -41,19 +38,16 @@ proxy_secret = "phase1-secret-canary-32-characters" policy = "phase1-permission-policy-canary" CONFIG trap 'rm -f trusted-server.toml' EXIT - export CARGO_TARGET_DIR="$RUNNER_TEMP/phase1-target" [[ ! -e "$CARGO_TARGET_DIR" ]] mkdir -p release-assets [[ -z "$(ls -A release-assets)" ]] - # The pinned Node distribution includes npm. npm verifies lockfile integrity for # fetched packages; no generator dependencies are installed with unpinned npx. npm --prefix crates/trusted-server-js/lib ci cargo build --package "$build_package" --release --target "$build_target" --locked cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset" node scripts/release/phase1.mjs check "release-assets/$release_asset" - # Pin and checksum the generator source before extraction or execution. Cargo's # --locked installation authenticates its transitive crates via its shipped lock. tool_root="$RUNNER_TEMP/phase1-cyclonedx" @@ -62,7 +56,6 @@ curl --fail --silent --show-error --location --retry 3 \ https://crates.io/api/v1/crates/cargo-cyclonedx/0.5.9/download \ --output "$tool_root/generator.crate" printf '%s %s\n' 5d162f67705f0f5038759d73bf546a083bf30e8677c2e944b416bca48d9d69a8 "$tool_root/generator.crate" | shasum -a 256 --check - tar -xf "$tool_root/generator.crate" -C "$tool_root" CARGO_TARGET_DIR="$tool_root/target" cargo install \ --path "$tool_root/cargo-cyclonedx-0.5.9" --locked --root "$tool_root/install" @@ -77,5 +70,28 @@ CARGO_NET_OFFLINE=true "$tool_root/install/bin/cargo-cyclonedx" cyclonedx \ npm --prefix crates/trusted-server-js/lib sbom --sbom-format cyclonedx > "$tool_root/npm.cdx.json" node scripts/release/phase1.mjs merge \ "crates/$build_package/phase1.json" "$tool_root/npm.cdx.json" \ - "release-assets/$release_asset" "release-assets/$release_asset.cdx.json" + "release-assets/$release_asset" "$tool_root/combined.cdx.json" +# Convert the internal dependency graph into real SPDX 2.3 before publication. +case "$build_target" in + wasm32-wasip1|x86_64-unknown-linux-gnu) + converter_asset=cyclonedx-linux-x64 + converter_sha=bfc8b2538da86fe239bc53658bbb63c1c8c510a293c1e6891aa5bea5d3c58746 ;; + aarch64-apple-darwin) + converter_asset=cyclonedx-osx-arm64 + converter_sha=750c148780154833f6401f9067d08c5a4c31567b6ee3c26c062c3a95c62d741c ;; + x86_64-apple-darwin) + converter_asset=cyclonedx-osx-x64 + converter_sha=0ea92306fc7d7c30ed112a7463781a200585a3bf1cdaf77229181c5aabd1d4b6 ;; +esac +curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.33.1/$converter_asset" \ + --output "$tool_root/converter" +printf '%s %s\n' "$converter_sha" "$tool_root/converter" | shasum -a 256 --check +chmod +x "$tool_root/converter" +"$tool_root/converter" convert --input-file "$tool_root/combined.cdx.json" \ + --input-format json --output-format spdxjson \ + --output-file "release-assets/$release_asset.spdx.json" +node scripts/release/phase1.mjs validate-spdx \ + "release-assets/$release_asset.spdx.json" "release-assets/$release_asset" \ + "$tool_root/combined.cdx.json" git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs index e4499545ef..2abd2587ce 100644 --- a/scripts/release/phase1.mjs +++ b/scripts/release/phase1.mjs @@ -4,6 +4,113 @@ import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +/** Complete converter output with the actual binary's SPDX package. */ +export function completeSpdx(document, binary, original) { + if ( + document.documentDescribes?.length || + document.relationships?.length || + document.packages.some((entry) => entry.SPDXID === 'SPDXRef-Artifact') + ) + throw new Error('Unexpected converter root or relationships') + const root = original.metadata.component + document.packages.push({ + SPDXID: 'SPDXRef-Artifact', + name: root.name, + versionInfo: root.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'NOASSERTION', + licenseDeclared: 'NOASSERTION', + copyrightText: 'NOASSERTION', + externalRefs: root.purl + ? [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: root.purl, + }, + ] + : [], + checksums: [ + { + algorithm: 'SHA256', + checksumValue: createHash('sha256').update(binary).digest('hex'), + }, + ], + }) + document.documentDescribes = ['SPDXRef-Artifact'] + document.relationships = [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: 'SPDXRef-Artifact', + }, + ] + return validateSpdx(document, binary, original) +} + +/** Check the frozen SPDX root/hash join and preserved package identities. */ +export function validateSpdx(document, binary, original) { + checkBinary(binary) + if ( + document.spdxVersion !== 'SPDX-2.3' || + document.SPDXID !== 'SPDXRef-DOCUMENT' + ) + throw new Error('Expected an SPDX 2.3 document') + const packages = document.packages ?? [] + const ids = new Set(packages.map((entry) => entry.SPDXID)) + if (ids.size !== packages.length) + throw new Error('Duplicate SPDX package identity') + if ([...ids].some((id) => !/^SPDXRef-[A-Za-z0-9.-]+$/.test(id))) + throw new Error('Invalid SPDX package identifier') + const described = new Set(document.documentDescribes ?? []) + for (const edge of document.relationships ?? []) { + if ( + edge.spdxElementId === document.SPDXID && + edge.relationshipType === 'DESCRIBES' + ) + described.add(edge.relatedSpdxElement) + } + const root = packages.find((entry) => entry.SPDXID === [...described][0]) + if (described.size !== 1 || !root) + throw new Error('SPDX must describe exactly one root package') + const hashes = (root.checksums ?? []).filter( + (hash) => hash.algorithm === 'SHA256' + ) + if ( + hashes.length !== 1 || + hashes[0].checksumValue !== + createHash('sha256').update(binary).digest('hex') + ) + throw new Error('SPDX root SHA256 differs from binary') + const flatten = (items) => + items.flatMap((item) => [item, ...flatten(item.components ?? [])]) + const components = [ + original.metadata.component, + ...flatten(original.components ?? []), + ] + const used = new Set() + for (const component of components) { + const match = packages.find( + (entry) => + !used.has(entry.SPDXID) && + entry.name === component.name && + (!component.version || entry.versionInfo === component.version) && + (!component.purl || + entry.externalRefs?.some( + (ref) => + ref.referenceType === 'purl' && + ref.referenceLocator === component.purl + )) + ) + if (!match) throw new Error('SPDX lost component identity or PURL') + if (component === original.metadata.component && match !== root) + throw new Error('SPDX describes a different inventory root') + used.add(match.SPDXID) + } + return document +} + // Adapted from #1262's native recipe scanner at 10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807. export const sentinels = [ 'phase1-origin-canary.example.com', @@ -58,7 +165,7 @@ function namespace(bom, prefix) { ) return typeof value === 'string' && ['bom-ref', 'ref', 'dependsOn', 'provides'].includes(key) - ? `${prefix}:${value}` + ? `${prefix}-${createHash('sha256').update(value).digest('hex')}` : value } return rename(bom) @@ -97,12 +204,13 @@ export function mergeSboms(cargo, npm, binary, name) { { name: 'trusted-server:coverage', value: - 'Selected Cargo dependency graph plus the installed npm build tree used for this release build. Conservative inventory: components may be removed by linking, tree-shaking or minification. External Prebid is not bundled; its installed package is a build-tree entry only. Native C/assembly, system libraries, Rust standard library and compiler runtimes are not completely inventoried. Complete composition is Phase 2.', + 'Selected Cargo packages plus the installed npm build tree used for this release build. Conservative inventory: components may be removed by linking, tree-shaking or minification. External Prebid is not bundled; its installed package is a build-tree entry only. SPDX conversion does not retain dependency relationships. Native C/assembly, system libraries, Rust standard library and compiler runtimes are not completely inventoried. Complete composition is Phase 2.', }, ], }, components: [ ...(rust.components ?? []), + ...(root.components ?? []), npmRoot, ...(javascript.components ?? []), ], @@ -149,9 +257,23 @@ if ( 2 ) + '\n' ) + } else if (command === 'validate-spdx' && args.length === 3) { + const [file, binary, original] = args + const document = JSON.parse(readFileSync(file)) + const inventory = JSON.parse(readFileSync(original)) + completeSpdx(document, readFileSync(binary), inventory) + const coverage = inventory.metadata.properties?.find( + (entry) => entry.name === 'trusted-server:coverage' + )?.value + if (typeof coverage !== 'string' || coverage.length === 0) + throw new Error('Missing conservative inventory coverage') + document.creationInfo.comment = [document.creationInfo.comment, coverage] + .filter(Boolean) + .join('\n') + writeFileSync(file, JSON.stringify(document, null, 2) + '\n') } else { throw new Error( - 'Expected audit, check , or merge ' + 'Expected audit, check , merge , or validate-spdx ' ) } } diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index 99ec2cc267..a354956de3 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { completeSpdx, validateSpdx } from './phase1.mjs' import { test } from 'node:test' import { readFileSync, @@ -9,9 +11,9 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import vm from 'node:vm' import { checkBinary, checkSource, mergeSboms, sentinels } from './phase1.mjs' - test('rejects every planted operator value, including inside binary data', () => { for (const marker of sentinels) { assert.throws( @@ -31,7 +33,6 @@ test('rejects every planted operator value, including inside binary data', () => ) assert.throws(() => checkBinary(Buffer.alloc(0)), /empty/) }) - test('rejects deployment compile inputs while permitting product inputs', () => { for (const source of [ 'env!("TRUSTED_SERVER__EC__PASSPHRASE")', @@ -52,7 +53,6 @@ test('rejects deployment compile inputs while permitting product inputs', () => ) ) }) - test('merges independent graphs without colliding references and binds binary bytes', () => { const bom = (name) => ({ bomFormat: 'CycloneDX', @@ -77,22 +77,28 @@ test('merges independent graphs without colliding references and binds binary by assert.equal(output.metadata.component.hashes[0].alg, 'SHA-256') assert.equal(output.metadata.component.hashes[0].content.length, 64) assert.deepEqual( - output.dependencies.find((d) => d.ref === 'cargo:root').dependsOn, - ['cargo:dependency', 'npm:root'] + output.dependencies.find( + (d) => d.ref === output.metadata.component['bom-ref'] + ).dependsOn, + [output.components[0]['bom-ref'], output.components[1]['bom-ref']] ) assert.equal( new Set(output.components.map((c) => c['bom-ref'])).size, output.components.length ) + assert.ok( + output.components.every((entry) => + /^[A-Za-z0-9.-]+$/.test(entry['bom-ref']) + ) + ) assert.throws( () => mergeSboms({}, bom('npm'), Buffer.from('binary'), 'example'), /CycloneDX/ ) }) - test('workflow signs only tags, separates privileged jobs, and never checks out in signing', () => { const workflow = readFileSync( - new URL('../../.github/workflows/release.yml', import.meta.url), + new URL('../../.github/workflows/release-build.yml', import.meta.url), 'utf8' ) const sign = workflow.split('\n sign:')[1]?.split('\n release:')[0] @@ -103,7 +109,10 @@ test('workflow signs only tags, separates privileged jobs, and never checks out /checkout@|cargo |npm |contents: write|artifact-metadata:/ ) assert.equal((sign.match(/uses: actions\/attest@/g) ?? []).length, 2) - assert.match(workflow, /cancel-in-progress: false/) + assert.match( + readFileSync('.github/workflows/release.yml', 'utf8'), + /cancel-in-progress:.*pull_request/ + ) assert.doesNotMatch(workflow, /workflow_dispatch|--clobber/) for (const line of workflow .split('\n') @@ -111,95 +120,189 @@ test('workflow signs only tags, separates privileged jobs, and never checks out assert.match(line, /@[a-f0-9]{40} # v/) } }) - -test('release publication refuses existing releases and API errors, and does not publish after upload failure', () => { - const workflow = readFileSync( - new URL('../../.github/workflows/release.yml', import.meta.url), - 'utf8' +const assets = [ + 'trusted-server-fastly.wasm', + 'ts-aarch64-apple-darwin', + 'ts-x86_64-apple-darwin', + 'ts-x86_64-unknown-linux-gnu', +] +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') +function publicationScript() { + const workflow = readFileSync('.github/workflows/release.yml', 'utf8') + const match = workflow.match( + / \/\/ BEGIN PUBLICATION\n([\s\S]*?) \/\/ END PUBLICATION/ ) - const script = workflow - .slice(workflow.lastIndexOf(' run: |\n') + ' run: |\n'.length) + assert.ok( + match, + 'should expose the actual inline publication program for failure tests' + ) + return match[1] .split('\n') .map((line) => line.slice(10)) .join('\n') - const root = mkdtempSync(join(tmpdir(), 'phase1-publication-')) - try { - const bin = join(root, 'bin') - mkdirSync(bin) - writeFileSync( - join(bin, 'gh'), - `#!/usr/bin/env node -const fs = require('node:fs'); -const args = process.argv.slice(2); -fs.appendFileSync(process.env.GH_LOG, JSON.stringify(args) + '\\n'); -const mode = process.env.FIXTURE_MODE; -if (args[0] === 'api' && args.length === 2) { - if (mode === 'existing') { process.stdout.write('{}'); process.exit(0); } - process.stderr.write(mode === 'api-error' ? 'HTTP 403' : 'HTTP 404'); process.exit(1); } -if (args[0] === 'api') process.stdout.write(mode === 'mutable' ? 'false' : 'true'); -if (args[1] === 'upload' && mode === 'upload-error') process.exit(1); -`, - { mode: 0o755 } - ) - writeFileSync( - join(bin, 'sha256sum'), - '#!/bin/sh\nexec shasum -a 256 "$@"\n', - { mode: 0o755 } - ) - for (const mode of [ - 'existing', - 'api-error', - 'upload-error', - 'mutable', - 'success', - ]) { - const working = join(root, mode) - mkdirSync(join(working, 'release-assets'), { recursive: true }) - for (const asset of [ - 'trusted-server-fastly.wasm', - 'ts-x86_64-unknown-linux-gnu', - 'ts-aarch64-apple-darwin', - 'ts-x86_64-apple-darwin', - ]) { - for (const suffix of ['', '.cdx.json']) +test('publication binds exact signing handoffs and refuses drafts, corruption and partial uploads', async () => { + const script = publicationScript() + for (const mode of [ + 'success', + 'draft', + 'api-error', + 'missing', + 'extra', + 'changed', + 'empty-bundle', + 'upload-error', + 'mutable', + 'wrong-id', + ]) { + const root = mkdtempSync(join(tmpdir(), 'phase1-publish-')) + const calls = [] + const uploaded = [] + try { + for (const asset of assets) { + const build = join(root, 'build', asset) + const signed = join(root, 'signed', asset) + mkdirSync(build, { recursive: true }) + mkdirSync(signed, { recursive: true }) + const entries = [] + for (const suffix of [ + '', + '.spdx.json', + '.provenance.sigstore.json', + '.sbom.sigstore.json', + ]) { + const bytes = Buffer.from(`fixture ${asset}${suffix}`) writeFileSync( - join(working, 'release-assets', asset + suffix), - 'fictional fixture' + join(suffix.includes('sigstore') ? signed : build, asset + suffix), + bytes ) + entries.push(`${hash(bytes)} ${asset}${suffix}\n`) + } + writeFileSync(join(signed, 'SHA256SUMS'), entries.join('')) } - const log = join(working, 'gh.log') - const result = spawnSync('bash', ['-c', script], { - cwd: working, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - GH_REPO: 'example/project', - RELEASE_TAG: 'v0.0.0-example', - GH_LOG: log, - FIXTURE_MODE: mode, - }, + if (mode === 'missing') + fs.unlinkSync(join(root, 'build', assets[0], assets[0])) + if (mode === 'extra') + writeFileSync(join(root, 'build', assets[0], 'unexpected'), 'extra') + if (mode === 'changed') + writeFileSync(join(root, 'build', assets[0], assets[0]), 'different') + if (mode === 'empty-bundle') + writeFileSync( + join(root, 'signed', assets[0], assets[0] + '.sbom.sigstore.json'), + '' + ) + const release = (draft) => ({ + id: 42, + tag_name: 'v0.0.0-example', + draft, + immutable: mode !== 'mutable', + assets: uploaded, }) - const calls = readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) - assert.equal( - result.status, - mode === 'success' ? 0 : 1, - `${mode}: ${result.stderr}` + const fetch = async (url, options = {}) => { + const u = new URL(url) + const method = options.method ?? 'GET' + calls.push([method, u.pathname]) + const response = (body, status = 200) => + new Response( + typeof body === 'string' || Buffer.isBuffer(body) + ? body + : JSON.stringify(body), + { status } + ) + if (u.pathname.endsWith('/releases') && method === 'GET') + return mode === 'api-error' + ? response('denied', 403) + : response(mode === 'draft' ? [release(true)] : []) + if (u.pathname.endsWith('/releases') && method === 'POST') + return response(release(true), 201) + if (u.pathname.endsWith('/assets') && method === 'POST') { + if (mode === 'upload-error') return response('upload failed', 500) + const bytes = Buffer.from(options.body) + const name = u.searchParams.get('name') + const item = { + id: 100 + uploaded.length, + name, + size: bytes.length, + state: 'uploaded', + digest: `sha256:${hash(bytes)}`, + bytes, + } + uploaded.push(item) + return response(item, 201) + } + if (/\/releases\/assets\/\d+$/.test(u.pathname)) { + const asset = uploaded.find( + (a) => a.id === Number(u.pathname.split('/').at(-1)) + ) + return response(mode === 'readback-error' ? 'corrupt' : asset.bytes) + } + if (u.pathname.includes('/releases/tags/')) + return response({ + ...release(false), + id: mode === 'wrong-id' ? 43 : 42, + }) + if (u.pathname.endsWith('/releases/42')) + return response(release(method !== 'PATCH' && uploaded.length === 0)) + throw Error(`Unexpected ${method} ${url}`) + } + const context = { + Buffer, + URL, + console, + fetch, + process: { + env: { + GH_REPO: 'example/project', + RELEASE_TAG: 'v0.0.0-example', + SOURCE_COMMIT: 'a'.repeat(40), + }, + }, + require(name) { + if (name === 'node:fs') + return { + ...fs, + readdirSync: (path, ...rest) => + fs.readdirSync(join(root, path), ...rest), + lstatSync: (path) => fs.lstatSync(join(root, path)), + readFileSync: (path, ...rest) => + fs.readFileSync(join(root, path), ...rest), + mkdirSync: (path, ...rest) => + fs.mkdirSync(join(root, path), ...rest), + writeFileSync: (path, ...rest) => + fs.writeFileSync(join(root, path), ...rest), + copyFileSync: (a, b) => + fs.copyFileSync(join(root, a), join(root, b)), + } + if (name === 'node:crypto') return { createHash } + if (name === 'node:child_process') + return { execFileSync: (...args) => calls.push(['gh', args]) } + throw Error(name) + }, + } + const execute = () => + vm.runInNewContext(`(async()=>{${script}\n})()`, context) + if (mode === 'success') await execute() + else await assert.rejects(execute, undefined, mode) + if ( + [ + 'draft', + 'api-error', + 'missing', + 'extra', + 'changed', + 'empty-bundle', + ].includes(mode) ) - if (['existing', 'api-error'].includes(mode)) - assert.equal(calls.length, 1) - if (mode === 'upload-error') - assert.ok(!calls.some((args) => args[1] === 'edit')) - if (mode === 'mutable') assert.match(result.stdout, /NOT immutable/) - if (mode === 'success') - assert.equal( - calls.filter((args) => args[1] === 'verify-asset').length, - 9 - ) + assert.ok(!calls.some(([method]) => method === 'POST'), mode) + if (['upload-error'].includes(mode)) + assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) + if (mode === 'success') { + assert.equal(uploaded.length, 17) + assert.equal(calls.filter(([method]) => method === 'gh').length, 18) + } + } finally { + rmSync(root, { recursive: true, force: true }) } - } finally { - rmSync(root, { recursive: true, force: true }) } }) @@ -220,3 +323,102 @@ test('acquires the locked workspace graph before offline SBOM resolution', () => 'should acquire before offline generation' ) }) + +// SPDX conversion retains the binary root and conservative package inventory. +const binary = Buffer.from('fictional binary') +function spdxFixture() { + const root = { + SPDXID: 'SPDXRef-Root', + name: 'example.wasm', + checksums: [ + { + algorithm: 'SHA256', + checksumValue: createHash('sha256').update(binary).digest('hex'), + }, + ], + } + const component = { + name: 'example-package', + version: '1.0.0', + purl: 'pkg:npm/example-package@1.0.0', + } + const original = { + metadata: { component: { name: root.name } }, + components: [component], + } + const document = { + spdxVersion: 'SPDX-2.3', + SPDXID: 'SPDXRef-DOCUMENT', + documentDescribes: [root.SPDXID], + packages: [ + root, + { + SPDXID: 'SPDXRef-Package', + name: component.name, + versionInfo: component.version, + externalRefs: [ + { referenceType: 'purl', referenceLocator: component.purl }, + ], + }, + ], + relationships: [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: root.SPDXID, + }, + ], + } + return { original, document } +} + +test('SPDX preserves a binary-bound root and package versions/PURLs', () => { + const { original, document } = spdxFixture() + assert.equal(validateSpdx(document, binary, original), document) + delete document.documentDescribes + assert.doesNotThrow(() => validateSpdx(document, binary, original)) +}) + +test('SPDX rejects root/hash ambiguity and lost package identities', () => { + for (const mutate of [ + (d) => { + d.spdxVersion = 'SPDX-2.2' + }, + (d) => { + d.documentDescribes.push('SPDXRef-Package') + }, + (d) => { + d.packages[0].checksums[0].checksumValue = '0'.repeat(64) + }, + (d) => { + d.packages[0].checksums.push(d.packages[0].checksums[0]) + }, + (d) => { + d.packages.push(d.packages[0]) + }, + (d) => { + d.packages.pop() + }, + (d) => { + d.packages[1].versionInfo = '2.0.0' + }, + (d) => { + d.packages[1].externalRefs = [] + }, + ]) { + const { document, original } = spdxFixture() + mutate(document) + assert.throws(() => validateSpdx(document, binary, original)) + } +}) + +test('completes real converter package output with a binary-bound root', () => { + const { original, document } = spdxFixture() + document.packages.shift() + delete document.documentDescribes + delete document.relationships + completeSpdx(document, binary, original) + assert.deepEqual(document.documentDescribes, ['SPDXRef-Artifact']) + assert.equal(document.packages.at(-1).filesAnalyzed, false) + assert.throws(() => completeSpdx(document, binary, original), /Unexpected/) +}) From cb82e4bd9615da1564f9a67c54f9bb148632bd44 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 17:21:02 +0530 Subject: [PATCH 006/104] Allow the exact GitHub Actions build type reference host --- .../src/commands/dev/lint/domains.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 9a141aafa0..186688e5ad 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -94,6 +94,9 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "docs.github.com", "help.github.com", "token.actions.githubusercontent.com", + // GitHub Actions SLSA buildType URI used in the carried release policy: + // https://actions.github.io/buildtypes/workflow/v1 + "actions.github.io", // Git commit conventions "chris.beams.io", // Rust @@ -120,7 +123,7 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "slsa.dev", // SPDX: canonical document predicate in the retained reviewed contract. "spdx.dev", - // CycloneDX: canonical SBOM predicate used by Phase 1 verification. + // CycloneDX: canonical format reference for the build inventory generator. "cyclonedx.org", // Specs (other) "webassembly.org", @@ -347,6 +350,23 @@ mod allow_check_tests { assert!(!is_allowed("other.github.com", ¬hing_suppressed())); } + #[test] + fn actions_build_type_host_is_exact() { + assert!( + is_allowed("actions.github.io", ¬hing_suppressed()), + "should allow the GitHub Actions build type reference" + ); + for host in [ + "unapproved.actions.github.io", + "actions.github.io.invalid-host.com", + ] { + assert!( + !is_allowed(host, ¬hing_suppressed()), + "should reject Actions host subdomains and lookalikes" + ); + } + } + #[test] fn suppression_set_allows() { let mut suppressed = HashSet::new(); From 41896cb0798f98e759c020965ed675fe1e8808dc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 17:21:35 +0530 Subject: [PATCH 007/104] Document Phase 1 compatibility and verification boundaries --- CHANGELOG.md | 4 + crates/trusted-server-cli/release-policy.json | 140 +++++ docs/guide/release-verification.md | 244 +++++---- ...inary-attestations-phase1-compatibility.md | 133 +++++ scripts/release/release-cohort.json | 79 +++ scripts/release/release-tools.json | 490 ++++++++++++++++++ 6 files changed, 993 insertions(+), 97 deletions(-) create mode 100644 crates/trusted-server-cli/release-policy.json create mode 100644 docs/superpowers/plans/binary-attestations-phase1-compatibility.md create mode 100644 scripts/release/release-cohort.json create mode 100644 scripts/release/release-tools.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cd49d9bc..1fa0d1a5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Phase 1 attested Fastly wasm and Linux/macOS CLI releases through a reusable signer workflow, with conservative SPDX 2.3 inventories, published provenance/SBOM bundles, immutable release verification and a manual verified-byte Fastly route. Platform qualification, reproducibility and the custom verifier remain later work; publication requires maintainer settings and an authorized sandbox rehearsal. + ### Changed - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. diff --git a/crates/trusted-server-cli/release-policy.json b/crates/trusted-server-cli/release-policy.json new file mode 100644 index 0000000000..4aa1115dfa --- /dev/null +++ b/crates/trusted-server-cli/release-policy.json @@ -0,0 +1,140 @@ +{ + "artifacts": [ + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom-repeatable", + "bin": "trusted-server-adapter-fastly", + "capabilities": ["fastly-production"], + "compatibility": { + "architecture": "wasm32", + "minimum_version": "wasip1", + "runtime": "fastly-compute" + }, + "default_features": true, + "features": [], + "kind": "wasm-core", + "name": "trusted-server-fastly.wasm", + "package": "trusted-server-adapter-fastly", + "role": "server", + "target": "wasm32-wasip1", + "variant": "default" + }, + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "aarch64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-aarch64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "aarch64-apple-darwin", + "variant": "default" + }, + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-apple-darwin", + "variant": "default" + }, + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-unknown-linux-gnu", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + } + ], + "bounds": { + "attempt_seconds": 900, + "bundle_bytes": 33554432, + "child_seconds": 120, + "cohort_executables": 4, + "companion_bytes": 33554432, + "executable_bytes": 536870912, + "index_bytes": 1048576, + "inventory_bytes": 1048576, + "json_depth": 64, + "matching_attestations_per_bundle": 1, + "metadata_bytes": 1048576, + "policy_bytes": 1048576, + "spdx_bytes": 16777216, + "stderr_bytes": 1048576, + "stdout_bytes": 134217728, + "trusted_roots_bytes": 1048576 + }, + "cohort": "phase1-fastly-cli", + "github_host": "github.com", + "historical_producer_policies": [], + "identity": { + "builder_id": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/", + "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": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/", + "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": 1, + "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-verify"], + "repository": "IABTechLab/trusted-server", + "repository_id": "950860906", + "repository_owner_id": "82903538", + "tool_versions": { + "actions_attest": "4.2.2", + "actions_attest_revision": "1e69f48acb82d1966a394da916b4c1698aa569d6", + "gh": "2.97.0" + } +} diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index b2bdaff368..028d85cd9e 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -1,54 +1,65 @@ # Verify release binaries -Phase 1 publishes `trusted-server-fastly.wasm` and three `ts` executables with -SLSA build provenance, a CycloneDX SBOM for each binary, and immutable GitHub -Release membership. Verify downloads before executing or deploying them. -Reproducible builds and the custom Trusted Server verifier are later phases; -Phase 1 operators must use the downloaded, verified bytes. +Phase 1 publishes four binaries, four SPDX 2.3 SBOMs, eight Sigstore bundles +and `SHA256SUMS`: **17 immutable release assets**. Each binary has SLSA build +provenance and an SBOM attestation. Verify the downloaded bytes before execution +or deployment. Reproducibility, complete composition and the custom Trusted +Server verifier remain later phases. + +The tag caller is `.github/workflows/release.yml`. Its local reusable +`.github/workflows/release-build.yml` owns builds and signing at the same source +revision. The reusable workflow is the certificate signer and provenance builder; +the caller remains the publication owner. Build jobs have no signing authority, +and signing jobs do not check out, compile or execute the downloaded binaries. ## Platforms and coverage -| Asset | Hosted build baseline | -| ----------------------------- | ------------------------------------------------- | -| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features | -| `ts-x86_64-unknown-linux-gnu` | Ubuntu 24.04 x86-64, glibc 2.39 baseline | -| `ts-aarch64-apple-darwin` | macOS 15 or newer, Apple Silicon | -| `ts-x86_64-apple-darwin` | macOS 15 or newer, Intel | - -The Linux binary is built against the Ubuntu 24.04 environment; compatibility -with older glibc releases is not established. Native libraries and tools needed -by individual `ts` commands retain their existing prerequisites. The macOS -builds set `MACOSX_DEPLOYMENT_TARGET=15.0`; these executables are not notarized. -Cloudflare, Spin, Axum, Windows and Linux ARM64 assets follow separately. - -Each `.cdx.json` is a **conservative build dependency inventory**: -`cargo-cyclonedx` 0.5.9 resolves the selected Cargo target after its locked release -build, and the npm bundled with pinned Node generates an SBOM of the installed -JS build tree from that run. The merger namespaces both dependency graphs and -records the final binary's SHA-256. The generator source archive is pinned by -SHA-256 before installation; Cargo's shipped lock and npm's committed lock -check downloaded package integrity. - -This covers the npm dependencies used to build the embedded JS, including build -tools. It can include packages removed by linking, tree-shaking or minification. -The installed `prebid.js` package is a build-tree entry; the external Prebid -bundle is not embedded in tsjs. Native C/assembly, system libraries, Rust's -standard library and compiler runtimes do not have complete coverage here. -Complete composition evidence and artifact-specific retained-code attribution -remain Phase 2 work. An SBOM or provenance result is not a vulnerability scan. +| Asset | Phase 1 baseline and qualification limit | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; reproducibility and hosted runtime qualification remain pending | +| `ts-x86_64-unknown-linux-gnu` | Ubuntu 24.04 x86-64, glibc 2.39 baseline; the retained policy's glibc 2.34 ceiling is not yet established | +| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 15.0; runtime compatibility qualification remains pending | +| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 15.0; runtime compatibility qualification remains pending | + +The retained destination design calls for a macOS 13.0 deployment target; +Phase 1 still builds with 15.0. Lowering that floor and proving older-runtime +compatibility are additive later work. +The executables are not notarized. Compatibility with older Linux runtimes and +the retained GNU policy's clean startup images remains unqualified. Native +libraries and provider tools needed by individual `ts` commands retain their +existing prerequisites. Cloudflare, Spin, Axum, Windows and Linux ARM64 releases +follow separately. + +Each `.spdx.json` is a **conservative build dependency inventory** of +the selected Cargo target and the installed npm build tree used for that build. +It records the final executable's SHA-256. SPDX 2.3 is the published format; +format compatibility does not imply the complete composition qualification +required by the retained design. The conversion preserves package identities +but drops dependency relationships; the document adds only the binary-root +`DESCRIBES` relationship. + +The inventory includes npm build tools and may include packages removed by +linking, tree-shaking or minification. The installed `prebid.js` package is a +build-tree entry; the external Prebid bundle is not embedded in tsjs. Native +C/assembly, system libraries, Rust's standard library and compiler runtimes do +not have complete coverage. Exact retained-code attribution and complete native +and tool closure remain Phase 2 work. An SBOM or provenance result is not a +vulnerability scan. ## Download and verify Use an independently trusted GitHub CLI installation with `gh attestation verify`, -`gh release verify`, and `gh release verify-asset`. Authenticate if GitHub requires -it. Verification needs GitHub network access; Phase 1 does not provide an offline -release-membership decision. Obtain the approved tag and its **peeled commit -SHA** through your own source-review process. A tag name, checksum file or -commit supplied only alongside an untrusted download is not source approval. +`gh release verify`, and `gh release verify-asset`; use the version recorded in +the release job's recorded version. The SBOM comparison below also uses `jq`. +Verification needs GitHub network access. Obtain the approved tag +and its 40-character source commit through an independent source-review process. +The retained strict SemVer/lightweight-tag validation is deferred; use a +separately approved tag and its peeled source commit. A checksum or commit supplied only with +an untrusted download is not source approval. Run this in a new private directory, using Bash. Replace the fictional tag and -commit below. For a rehearsal, change `REPO` to the approved public sandbox and -use the individually approved, never-reused rehearsal tag. +commit. For rehearsal, change `REPO` to the approved public sandbox and use the +individually approved, never-reused rehearsal tag. ```bash set -euo pipefail @@ -62,66 +73,81 @@ gh release download "$TAG" --repo "$REPO" --dir . test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.immutable')" = true gh release verify "$TAG" --repo "$REPO" for asset in "${ASSETS[@]}"; do - gh release verify-asset "$TAG" "$asset" --repo "$REPO" - gh release verify-asset "$TAG" "$asset.cdx.json" --repo "$REPO" + for suffix in '' .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do + gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO" + done done gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" shasum -a 256 --check SHA256SUMS ``` -A checksum detects corruption; immutable-release verification establishes -membership. Neither replaces provenance verification. Verify **each binary** -against both the SLSA provenance predicate and the SBOM predicate: +The checksums cover the other 16 assets, never `SHA256SUMS` itself. Immutable +release verification establishes membership; provenance verification establishes +the reviewed signer and source. Both are required. + +First verify both published bundles for each binary using the reusable signer +workflow. Provenance binds the binary and its SPDX document; the SBOM +attestation's subject is the **binary**, and its predicate is the SPDX document. +Do not pass the JSON asset as the SBOM attestation's subject. ```bash -WORKFLOW="$REPO/.github/workflows/release.yml" +WORKFLOW="$REPO/.github/workflows/release-build.yml" for asset in "${ASSETS[@]}"; do gh attestation verify "$asset" --repo "$REPO" \ + --bundle "$asset.provenance.sigstore.json" \ --signer-workflow "$WORKFLOW" \ --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --deny-self-hosted-runners \ + --signer-digest "$COMMIT" --deny-self-hosted-runners \ --predicate-type https://slsa.dev/provenance/v1 gh attestation verify "$asset" --repo "$REPO" \ + --bundle "$asset.sbom.sigstore.json" \ --signer-workflow "$WORKFLOW" \ --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --deny-self-hosted-runners \ - --predicate-type https://cyclonedx.org/bom + --signer-digest "$COMMIT" --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 done ``` -The SBOM attestation's subject is the **binary**, and its predicate is the SBOM. -Do not pass the JSON asset as the binary subject. The JSON asset's own immutable -membership was checked above. - -`--signer-workflow` matches a workflow prefix. Also enforce the exact certificate -identity, including the tag ref, with these separate checks. GitHub CLI does not -allow `--signer-workflow` together with `--cert-identity` in one invocation. +`--signer-workflow` matches a prefix. Enforce the exact certificate identity, +including the tag ref, in separate checks: GitHub CLI makes `--signer-workflow` +and `--cert-identity` mutually exclusive. These checks use the same bundles +and independently approved source commit. Compare the verified SBOM predicate +to the published SPDX document as well. ```bash IDENTITY="https://github.com/$WORKFLOW@refs/tags/$TAG" for asset in "${ASSETS[@]}"; do - for predicate in https://slsa.dev/provenance/v1 https://cyclonedx.org/bom; do - gh attestation verify "$asset" --repo "$REPO" \ - --cert-identity "$IDENTITY" \ - --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --deny-self-hosted-runners --predicate-type "$predicate" - done + gh attestation verify "$asset" --repo "$REPO" \ + --bundle "$asset.provenance.sigstore.json" \ + --cert-identity "$IDENTITY" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --signer-digest "$COMMIT" --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 + gh attestation verify "$asset" --repo "$REPO" \ + --bundle "$asset.sbom.sigstore.json" \ + --cert-identity "$IDENTITY" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --signer-digest "$COMMIT" --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 \ + --format json > "$asset.sbom-verification.json" + jq -e --slurpfile document "$asset.spdx.json" \ + 'length == 1 and .[0].verificationResult.statement.predicate == $document[0]' \ + "$asset.sbom-verification.json" done ``` -Stop if any check fails. Keep the command outputs, reviewed source identity and -checksums with your deployment record. These checks establish the workflow, -source and published bytes; they do not establish publisher authorization, -runtime configuration or remote execution. See the +Stop if any check fails. Keep the outputs, reviewed source identity, bundles +and checksums with your deployment record. These checks do not approve a +publisher service, runtime configuration or deployment operation. See the [GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify) and [immutable asset verification reference](https://cli.github.com/manual/gh_release_verify-asset). ## Use the verified CLI Choose the executable matching your platform, verify it as above, then copy it -into a directory on your `PATH` as `ts` and make it executable. Verify before the -first execution. A source checkout and provider tools may still be required by -individual commands; see [CLI](/guide/cli). +into a directory on your `PATH` as `ts` and make it executable. Verify before +first execution. Individual commands may still require a source checkout and +provider tools; see [CLI](/guide/cli). ## Deploy the verified Fastly wasm @@ -174,31 +200,55 @@ verified wasm, manifest, package digest, previous active version and observed result. No build step may replace or annotate the verified wasm. Automated EdgeZero prepared-payload deployment and operation recovery are later phases. -## Build inputs and release administration - -Release builds use the tracked source tree and manifests, `Cargo.lock`, the -Rust toolchain from `rust-toolchain.toml`, Node from `.tool-versions`, the JS -lockfile and fresh JS bundles from that build. The tracked `edgezero.toml` -supplies the logical config-store ID and participates in the template build -digest. Build facts include the source commit. The CLI also carries the -fictional example-config scaffold; it does not carry an operator config. - -CI audits compile-time deployment inputs, builds with a scrubbed inherited -environment, and scans every final executable for planted fictional config -values. These are regression checks, not full hermetic-build qualification. -Operator `trusted-server.toml`, credentials, physical resource bindings and -variable permission policy must remain runtime inputs. - -Before any real release, a maintainer must enable immutable releases, protect -`v*` tags, and decide required checks and tag-push authority. Immutability is a -human precondition: the workflow checks the published release and fails loudly -if it is false, but a violation can already have exposed a mutable release. -The job has no repository-administration token. Existing releases, including -drafts from a failed attempt, are never overwritten; inspect failures and use -a fresh approved tag rather than rerunning publication against existing assets. - -First rehearse the same approved commit in the user's public sandbox with -Actions and immutable releases enabled. The user must approve **each** new -`v0.0.0-rehearsal.N` tag before it is pushed. Run the commands above against that -sandbox and record the outputs in the draft PR. Never use IAB's repository for -rehearsal tags or releases. The first real tag requires separate authorization. +## Build inputs, policy and release administration + +Builds use the tracked source, manifests, `Cargo.lock`, pinned Rust and Node, +the JS lockfile and fresh JS bundles. The tracked `edgezero.toml` supplies the +logical config-store ID and participates in the template build digest. Build +facts include the source commit. The CLI carries the fictional example-config +scaffold, not an operator config. + +The three policy files are carried unchanged from the frozen reviewed design. +They have **no Phase 1 readers** and do not authorize these builds or replace +the workflow's explicit selections. + +| Policy | Current use | Additive later adoption | +| ----------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `scripts/release/release-cohort.json` | Retained destination contract; no reader | Checked fixed cohort selection, original assurance profiles and compatibility qualification | +| `scripts/release/release-tools.json` | Retained pending/null qualification records; no reader | Checked tool identities, authenticated acquisition, image/SDK measurements and qualified recipes | +| `crates/trusted-server-cli/release-policy.json` | Retained signer/caller and SPDX contract; no reader or custom verifier | Embedded consumer policy, bounds, historical-policy acceptance and complete index/evidence joins | + +Phase 1 does not ship `release-index.json`, `tsjs-inventory.json`, build-evidence, +build-recipe or rebuild-check placeholders. Their frozen schemas and names +remain reserved for the later implementation. These releases do not yet satisfy +the frozen custom verifier's complete evidence graph. + +CI audits compile-time deployment inputs, scrubs inherited environment values, +and scans every executable for planted fictional config values. These are +regression checks, not hermetic-build qualification. Operator configuration, +credentials, physical bindings and variable permission policy remain runtime +inputs. + +Before publication, a maintainer must enable immutable releases, protect `v*` +tags and approve tag-push authority and required checks. Immutability remains a +human precondition; the publisher has no repository-administration token. A +violation can expose a mutable release before the workflow reports failure. + +Publication lists releases, including drafts, and refuses every existing +matching tag. It creates one draft, retains its release ID, and binds uploads +and the final publish mutation to that ID. It checks the exact signing-approved asset +set and checks immutable membership after publication. Full upload readback +and the reviewed publication owner remain additive later work. There are no +automatic mutation retries or draft-resume claims. + +After a failed or indeterminate mutation, inspect the recorded release ID and +all matching drafts read-only. Do not rerun uploads, delete a release, or reuse +the tag without an independently approved recovery decision. Published immutable +releases are never overwritten. A fresh release needs a fresh approved tag; +Phase 2's full reconciliation and incident owner remains deferred. + +First rehearse the approved commit in the user's public sandbox with Actions +and immutable releases enabled. The user must approve **each** new +`v0.0.0-rehearsal.N` tag before it is pushed. Record the verification outputs +in the draft PR. Never use the upstream repository for rehearsal tags or +releases. The first real tag and every deployment require separate authorization. diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md new file mode 100644 index 0000000000..1b0ab58a31 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -0,0 +1,133 @@ +# Phase 1 compatibility audit and approved alignment + +**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The user selected **SPDX 2.3**. The fork rehearsal `v0.0.0-rehearsal.1` was canceled before signing/publication; its tag will never be reused. Further rehearsal requires a fresh approved tag. + +**Conclusion:** the original Phase 1 signer path and CycloneDX public format would have required a later breaking policy replacement. Fix those now. Phase 1 is the first implementation slice of the existing contract: retain its public names, signer/caller distinction, SPDX semantics and original policy files. Introduce no competing release-index schema or weakened consumer policy. + +Frozen source references below mean: + +- [Spec](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md), especially R-COHORT/R-PLATFORM/R-SOURCE/R-SIGN/R-PUBLISH/R-INDEX/R-EVIDENCE. +- [Plan Tasks 15–16](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/plans/2026-10-13-attested-release-implementation.md#task-15-orchestrate-independent-builds-and-isolated-signing). +- [Cohort](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-cohort.json), [tools](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-tools.json), [consumer policy](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/release-policy.json). +- [Index v1 schema](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/schemas/release-index-v1.schema.json), [distribution names](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-release/src/distribution.rs#L55), [consumer companion rules](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/documents.rs#L236), [SPDX joins](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/evidence/joins.rs#L188). + +## Approved scope after audit + +The user approved the audit before further edits. This document contains the +new decisions; carried spec and plan sentences remain unchanged beyond phase +labels. The frozen source remains the destination contract. The pre-correction +Phase 1 audit baseline is `4dd217125c6e38055e4bcb2cba32c0b95e739914`; the +following corrections are implemented in this slice, with verification and +rehearsal tracked in the PR description at its current commit. No successful signing/publication rehearsal is claimed. + +The current correction aligns the local input-free `release-build.yml` signer +with the `release.yml` caller/publisher, real SPDX 2.3 documents, all eight +named bundles, the existing four binary names and `SHA256SUMS`: 17 assets. +Provenance covers each real binary and SPDX document; no missing evidence +subjects, index, inventory, recipe or rebuild results are fabricated. Exact +artifact selection and signing-approved digest checks replace wildcard merging. +The publisher lists all releases including drafts, refuses a matching tag, +retains the created release ID for mutations, performs no automatic mutation +retries and verifies immutable membership after publication. + +All three original policy files are carried unchanged, with **no Phase 1 +readers**. Checked cohort selection, a supplemental tool policy, environment +normalization/remapping, stricter tag/source validation, upload readback, +macOS 13 targeting, GNU glibc 2.34 qualification, additional binary/runtime +checks and CLI immutable build facts are additive later work. Current builds +retain the hosted GNU glibc 2.39 baseline and macOS 15.0 deployment target; +copying the destination policies does not establish their older-runtime +compatibility requirements. Native reproduction is a later measurement, not a +Phase 1 release gate. Full Task 16 resume/reconciliation remains deferred. + +The [operator guide](/guide/release-verification) describes the corrected +verification procedure and qualification limits. Its commands pin the reusable +signer and exact tag certificate in separate mutually exclusive flag checks. +Future companions and stronger verification remain additive; existing immutable +releases and approved tags are never overwritten or reinterpreted as complete +qualified evidence. + +## Stable public contracts: audit and adoption + +| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release caller | `release.yml`; local reusable call at the same source revision (R-SIGN, Task 15, consumer policy identity) | `release.yml` owns all jobs | Keep caller/publication in `release.yml`; move build orchestration and signing into local input-free `release-build.yml`. | +| Signing workflow identity | SAN/builder `https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/` | Direct `release.yml` signer and guide pins | Correct now; never broaden frozen policy to accept the wrong caller SAN. Fork rehearsal substitutes only the sandbox repository identity. | +| Caller identity / parameters | `buildConfigURI` names `release.yml`; external workflow `{ref, repository, path}`; no arbitrary commands/inputs (consumer `evidence.rs:128–140,213–240`) | No caller/signer distinction | Input-free `workflow_call`; preserve caller/source/run binding. | +| Workflow job IDs | No prescribed spelling; topology/identity is normative | `build`, `sign`, `release` | Keep compatible job IDs; file ownership and permissions are the contract. | +| Provenance predicate / build type | SLSA `https://slsa.dev/provenance/v1`; `https://actions.github.io/buildtypes/workflow/v1` | SLSA default from pinned action | Preserve; inspect actual rehearsal statements rather than supplying custom provenance. | +| Signer permissions | Build read-only; signer contents-read/OIDC/attestations-write; publisher contents-write only; no artifact-metadata write | Already split correctly | Preserve across reusable boundary; caller grants only needed maxima and called jobs narrow privileges. | +| Run/source binding | Same approved source, caller/signer revision and repository run/attempt (R-EVIDENCE) | Same run, no custom evidence joins | Preserve invocation shape now; full graph joins deferred, not substituted by metadata claims. | +| Exact four raw executable assets | `trusted-server-fastly.wasm`, `ts-aarch64-apple-darwin`, `ts-x86_64-apple-darwin`, `ts-x86_64-unknown-linux-gnu` | Same | Preserve exact names and unarchived executable subjects. | +| Cohort identity / selection | `phase1-fastly-cli`, schema 1, exact package/bin/target/default-feature/variant/role/capability combinations | Names/packages/targets duplicated in YAML/shell; no cohort metadata | Carry original cohort unchanged with no reader. Checked declaration-based selection is additive later work, never an arbitrary command interface. | +| SPDX document / predicate | SPDX 2.3; `https://spdx.dev/Document/v2.3` | CycloneDX 1.5 / `https://cyclonedx.org/bom` | User selected SPDX: generate real SPDX 2.3 and restore the source predicate before publication. | +| SBOM companion name | `.spdx.json` (distribution/consumer hardwired suffix) | `.cdx.json` | Align now; never rename CycloneDX bytes to SPDX. | +| SBOM root/binary join | `SPDXRef-DOCUMENT`; exactly one described root package and one `SHA256` checksum equal to executable bytes | CycloneDX metadata hash | Validate real converted SPDX against the existing root/hash contract. Root ID spelling itself is not hardwired. | +| SBOM predicate/document equality | Executable subject; predicate structurally equals distributed parsed SPDX; file bytes independently hashed | Executable subject is correct; no signed-predicate/file comparison | Preserve subject semantics, reject malformed/duplicate JSON before signing, and verify the shipped SPDX against the attested predicate. | +| Provenance bundle name | `.provenance.sigstore.json` | Not shipped | Save action bundle and ship under the existing name now. | +| SBOM bundle name | `.sbom.sigstore.json` | Not shipped | Save and ship now; do not introduce a competing suffix. | +| Provenance subject set | Executable, SPDX, build-evidence, build-recipe, rebuild-check (Task 15/R-EVIDENCE) | Executable only | Sign executable + real SPDX in this slice. Add only the real evidence/recipe/rebuild subjects when their owning slices land; do not fabricate companions. | +| Remaining per-asset names | `.build-evidence.json`, `.build-recipe.json`, `.rebuild-check.json` | Absent | Reserve exact existing names; defer contents until the reviewed producers own them. | +| Release-wide names | `release-index.json`, `release-index.provenance.sigstore.json`, `tsjs-inventory.json`, `SHA256SUMS` | Only `SHA256SUMS` | Preserve names; index/inventory remain additive later assets. No alternative Phase 1 index. | +| Release layout | Four binaries + six companions each + four release-wide assets = 32 | Four binaries + four CDX + checksums = 9 | First corrected slice: 4 binaries + 4 SPDX + 8 saved bundles + checksums = 17. Later 12 evidence/recipe/rebuild and 3 index/inventory assets complete the same 32-asset layout. | +| Checksums | `SHA256SUMS` covers every other published asset, never itself | Correct smaller-set rule | Preserve rule, expanding the exact set as real companions are added. | +| Index structural contract | Unchanged closed v1 schema; `{name,size,sha256}` file records and six fixed companion keys | Schema retained, no index emitted | Keep schema bytes unchanged; later emit the existing index, not a replacement schema. | +| Index source/policy joins | Source/recipe/cohort commits equal; producer policy version/hash; bounded exact names/digests | No index or policy identity claim | Defer these graph joins. No invented policy hash or assertion that the full source consumer accepts an index-less release. | +| Consumer policy / actions | Official repository/IDs, readonly verify, fixed bounds and identities | Custom verifier absent | Carry source policy unchanged as destination contract; manual `gh` verification remains supported for this first slice. | +| Candidate qualification markers | Candidate sentinel and `release_qualification:false` are explicitly rejected for official consumption | No candidate records published | Preserve this separation; never repurpose candidate documents as official evidence. | +| Tag syntax / object | Strict schema SemVer, including prerelease/build metadata; lightweight official tags (R-SOURCE) | Any `v*`; guide discusses peeled SHA; first canceled rehearsal tag was annotated | Strict schema/tag-object validation is additive later work. Use a separately approved fresh tag for rehearsal; never reuse `.1`. | +| Eligible source | Exactly `main`, actual remote ancestry/governance plus same-commit validation (R-SOURCE/Task 14) | Manual source approval; no trusted Task 14 execution | Preserve policy `main` unchanged with no reader. Remote ancestry/governance and required-job validation are deferred; maintainer source approval remains required. | + +### Build/policy contracts and explicit scope limits + +| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Policy file paths | `scripts/release/release-cohort.json`, `scripts/release/release-tools.json`, `crates/trusted-server-cli/release-policy.json` (`policy.rs:23–27`) | All absent | Carry all three unchanged with no Phase 1 readers. Later checked adoption must not execute source-supplied commands. | +| Policy qualification | Tools, environments and recipes remain `pending`, acquisition/digest fields null | Ordinary hosted build | Preserve all pending/null state. A Phase 1 checksum is not a claim of authenticated Phase 2 qualification. | +| Existing tool versions | Rust 1.98.1, Node 24.20.0, npm 11.19.0, gh 2.97.0; attest 4.2.2 at `1e69f48acb82d1966a394da916b4c1698aa569d6` | Rust/Node match; npm unchecked; ambient gh; attest matches | Preserve current tracked Rust/Node/action pins. Additional policy readers and exact gh/npm enforcement remain additive later work; original tool policy is unchanged. | +| New SBOM tool acquisition | No cargo-cyclonedx/converter entries; strict producer rejects unknown tool names | Cargo generator version/hash hardcoded | Supplemental tool-policy adoption is deferred. Keep existing Phase 1 acquisition checks and carry the original strict tools records unchanged. | +| Package/bin/target/features | Exact cohort entries, default features, no extras | Package/target agree; bin implicit | Preserve current fixed selections. A checked cohort reader and explicit-bin reconciliation are additive later work. | +| Profile and Cargo flags | Release, debug 1, strip none; `--frozen`, structured messages, fixed jobs (`recipe/plan.rs:175–197`) | Release/debug agree; `--locked`, online build, implicit bin/jobs | Keep online locked acquisition/build as the current Phase 1 boundary. Structured output/jobs, explicit bin and source normalization are additive later work; frozen confined execution follows its reviewed owner. | +| Normalized environment | C locale, UTC, source-commit epoch, immutable source facts, controlled AWS-LC/SQLite selection (`preflight/child.rs:82–115`) | Full SHA source fact only | Environment normalization and checked commit epoch are additive later work; no Phase 1 reproducibility claim. | +| Rust/C path remapping | Stable `/trusted-server`, `/cargo-vendor`, `/cargo-home`, `/rust-tools`, `/cargo-target` roots (`recipe/remap.rs`) | None | All additional remapping follows later owned inputs; it does not change names or verification identities. | +| Fresh/private inputs | Private prepared state and target; denied network; authenticated paths | Fresh target; hosted HOME/CARGO_HOME/PATH and network | Keep fresh target and scrubbed environment; prepared/offline/sandbox/tool closure remains the reviewed later slice. | +| JS preparation | Checked prebuilt inventory, shared twice-built modules, no Vite env/config loading | Fresh npm per artifact; private Cargo output; no shared comparison | Keep fresh npm/private Cargo outputs. Additional environment controls, authenticated inventory, two builds and shared contribution evidence remain later work; never invent equality claims. | +| macOS baseline | Deployment target **13.0**, hosted `macos-15` / `macos-15-intel` | Same runners, deployment target **15.0** | Keep current 15.0 setting. Lowering to the destination 13.0 floor and older-host runtime qualification are additive later work. | +| Linux baseline | GNU builder maximum **glibc 2.34**, UBI9/Debian12/Ubuntu22 compatibility evidence | Ordinary Ubuntu24 build; 2.39 environment, older compatibility unestablished | Explicit temporary limitation if source GNU builder/sysroot cannot be adopted without deferred platform machinery. Do not claim 2.34 merely by copying policy. Lowering the future requirement to 2.34 broadens support without renaming assets or breaking Phase 1 users. | +| Runtime/startup/file inspection | Owning runtime/startup acceptance and exact target classification | Build-only + config canaries | Additional binary type/architecture checks and full runtime qualification remain additive later work with the source inspection owner. | +| CLI immutable build facts | Source-controlled release/source/target/variant facts in CLI output (Task 4) | Existing package-version CLI output | Mark as additive later CLI metadata, preserving existing commands; do not substitute a different source-identity schema. | +| SBOM precision | Complete Cargo/native/JS composition and retained-byte joins | Conservative Cargo target/npm build-tree inventory; native/std/runtime gaps | Keep gaps explicit in SPDX and docs. Enrich the same SPDX companion later; do not label the smaller inventory complete. | +| Assurance profiles | Fastly `provenance-sbom-repeatable`; native `provenance-sbom` with full evidence | No repeatability/full evidence | Preserve destination profiles in cohort; do not emit an index/profile claim until its gates hold. Native reproduction remains a measurement; Fastly reproducibility is a later gate. | + +### Publication contract and nonbreaking recovery plan + +| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | +| ------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Publication owner / permissions | `release.yml`, contents-write only (Task 16) | Matches | Preserve; no signer/OIDC/settings credential in publisher. | +| Artifact transfer | Validated current-run outputs, exact bytes/sets (Tasks 12/15/16) | Wildcard `release-*` merge | Fix now: exact named downloads in separate directories, signing-owned digest manifests, reject missing/extra/changed files before creation. | +| Draft lookup/recovery | Published refused; matching draft resumable only after source/assets match | Tag lookup + refuse existing; no full draft listing | List all releases including drafts and refuse every matching tag in Phase 1. Capture created ID for mutations and identity-bound verification. Later matching-draft resume is an additive recovery capability. | +| Prepublication validation | Upload, re-download and validate intended set | Upload then publish | Upload readback/digest validation remains additive later work. This correction binds the exact signing-approved 17 assets locally and mutations to the created release ID. | +| Checks after publish | Immutable=true; signed release/membership and exact intended source/bytes | Immutable check and normal gh verification | Preserve and bind checks to created release ID. `gh release verify` accepts tags, so bracket it with created-ID/tag correspondence checks rather than invent an ID attestation endpoint. | +| Mutable/lost/unknown outcomes | Controlled cleanup for confirmed mutable; read-only reconciliation otherwise | Loud failure, manual recovery | Planned narrower Phase 1 behavior: leave failed/unknown state for explicit inspection, no blind retry/deletion. Later owner adds safe reconciliation without changing immutable assets or user verification commands. | +| Concurrency | Tag scoped; never cancel publishing run | Same rule also applies to PR builds | Preserve tags; cancel superseded PR builds only. | +| Public deployment route | Verified raw Fastly bytes; prepared EdgeZero context/recovery later | Manual clean package/member comparison | Preserve manual verification/deployment while later adding prepared-payload APIs; #1235 does not wait for boundaries 5–6. | + +**Nonbreaking adoption rule:** existing `gh` checks remain valid when later releases add real companions, index and richer evidence. The full custom verifier is introduced for index-bearing releases; it must not reinterpret the first slice as having complete graph/reproducibility/qualification evidence. No fake index or placeholder successful companions will be published. The unavoidable missing-evidence/recovery/platform-qualification limits are explicit; signer path, cohort, SPDX predicate/names and reserved companion names are aligned before the next publication attempt. + +## First rehearsal record + +The user approved the first rehearsal tag in the earlier session. The coding +agent pushed the implementation commit and created the annotated fork-only +`v0.0.0-rehearsal.1` tag at `4dd217125`. +[Run 39768688430](https://github.com/prk-Jr/trusted-server/actions/runs/39768688430) +completed the Fastly build. Its three native builds were canceled after review +feedback; the signing and publication jobs executed no steps, so this run +produced no attestations. Read-only reconciliation on 2026-10-22 found the tag +still present and no releases, including drafts, on the fork. Immutable releases +had previously been enabled on the fork under the user's sandbox authorization. +No IAB tags, releases or settings were changed. + +The old tag remains unused and is never reused. From this point, no fork push +or tag creation is authorized until the user writes +`approved: v0.0.0-rehearsal.N` for a fresh tag. Every future rehearsal tag must +be lightweight. diff --git a/scripts/release/release-cohort.json b/scripts/release/release-cohort.json new file mode 100644 index 0000000000..be477ccbb3 --- /dev/null +++ b/scripts/release/release-cohort.json @@ -0,0 +1,79 @@ +{ + "artifacts": [ + { + "assurance_profile": "provenance-sbom-repeatable", + "bin": "trusted-server-adapter-fastly", + "capabilities": ["fastly-production"], + "compatibility": { + "architecture": "wasm32", + "minimum_version": "wasip1", + "runtime": "fastly-compute" + }, + "default_features": true, + "features": [], + "kind": "wasm-core", + "name": "trusted-server-fastly.wasm", + "package": "trusted-server-adapter-fastly", + "role": "server", + "target": "wasm32-wasip1", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "aarch64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-aarch64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "aarch64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-unknown-linux-gnu", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + } + ], + "cohort": "phase1-fastly-cli", + "eligible_branches": ["main"], + "schema_version": 1 +} diff --git a/scripts/release/release-tools.json b/scripts/release/release-tools.json new file mode 100644 index 0000000000..5abaa8e07b --- /dev/null +++ b/scripts/release/release-tools.json @@ -0,0 +1,490 @@ +{ + "environments": [ + { + "acquisition": null, + "id": "debian12-x86_64", + "image_reference": null, + "kind": "oci-runtime", + "oci_index_sha256": null, + "oci_manifest_sha256": null, + "qualification": "pending", + "role": "compatibility-runtime" + }, + { + "acquisition": null, + "glibc_maximum": "2.34", + "id": "gnu-builder-x86_64", + "image_reference": null, + "kind": "oci-builder", + "oci_index_sha256": null, + "oci_manifest_sha256": null, + "qualification": "pending", + "role": "compilation", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "macos15-aarch64", + "kind": "github-hosted-macos", + "qualification": "pending", + "role": "compilation", + "runner_image_version": null, + "runner_label": "macos-15", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "macos15-x86_64", + "kind": "github-hosted-macos", + "qualification": "pending", + "role": "compilation", + "runner_image_version": null, + "runner_label": "macos-15-intel", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "ubi9-x86_64", + "image_reference": null, + "kind": "oci-runtime", + "oci_index_sha256": null, + "oci_manifest_sha256": null, + "qualification": "pending", + "role": "compatibility-runtime" + }, + { + "acquisition": null, + "id": "ubuntu22.04-x86_64", + "image_reference": null, + "kind": "oci-runtime", + "oci_index_sha256": null, + "oci_manifest_sha256": null, + "qualification": "pending", + "role": "compatibility-runtime" + } + ], + "recipes": [ + { + "environment_id": "gnu-builder-x86_64", + "id": "trusted-server-fastly.wasm", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, + "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": "1.98.1", + "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" + }, + "sha256": null + }, + { + "environment_id": "macos15-aarch64", + "id": "ts-aarch64-apple-darwin", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, + "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": "1.98.1", + "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" + }, + "sha256": null + }, + { + "environment_id": "macos15-x86_64", + "id": "ts-x86_64-apple-darwin", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, + "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": "1.98.1", + "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" + }, + "sha256": null + }, + { + "environment_id": "gnu-builder-x86_64", + "id": "ts-x86_64-unknown-linux-gnu", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, + "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": "1.98.1", + "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" + }, + "sha256": null + }, + { + "environment_id": "gnu-builder-x86_64", + "id": "tsjs", + "qualification": "pending", + "script": "scripts/release/build-js.sh", + "settings": { + "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": "24.20.0", + "npm_audit": false, + "npm_fund": false, + "npm_include_dev": true, + "npm_lifecycle": "reviewed-observed-foreground", + "npm_offline": true, + "npm_version": "11.19.0", + "publish": "A-only-after-equality", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production" + }, + "sha256": null + } + ], + "schema_version": 1, + "tools": [ + { + "acquisition": null, + "name": "actions/attest:github-action", + "qualification": "pending", + "revision": "1e69f48acb82d1966a394da916b4c1698aa569d6", + "sha256": null, + "version": "4.2.2" + }, + { + "acquisition": null, + "name": "fastly:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "16.0.0" + }, + { + "acquisition": null, + "name": "fastly:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "16.0.0" + }, + { + "acquisition": null, + "name": "fastly:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "16.0.0" + }, + { + "acquisition": null, + "name": "gh:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "2.97.0" + }, + { + "acquisition": null, + "name": "gh:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "2.97.0" + }, + { + "acquisition": null, + "name": "gh:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "2.97.0" + }, + { + "acquisition": null, + "name": "node:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "24.20.0" + }, + { + "acquisition": null, + "name": "node:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "24.20.0" + }, + { + "acquisition": null, + "name": "node:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "24.20.0" + }, + { + "acquisition": null, + "name": "npm:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "11.19.0" + }, + { + "acquisition": null, + "name": "npm:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "11.19.0" + }, + { + "acquisition": null, + "name": "npm:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "11.19.0" + }, + { + "acquisition": null, + "name": "rust:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "1.98.1" + }, + { + "acquisition": null, + "name": "rust:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "1.98.1" + }, + { + "acquisition": null, + "name": "rust:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "1.98.1" + }, + { + "acquisition": null, + "name": "viceroy:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "0.21.0" + }, + { + "acquisition": null, + "name": "viceroy:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "0.21.0" + }, + { + "acquisition": null, + "name": "viceroy:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "0.21.0" + }, + { + "acquisition": null, + "name": "wasmtime:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "48.0.1" + }, + { + "acquisition": null, + "name": "wasmtime:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "48.0.1" + }, + { + "acquisition": null, + "name": "wasmtime:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "48.0.1" + } + ] +} From ca902b6fea57f6cc37d2054687b4996687e9c476 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 18:16:54 +0530 Subject: [PATCH 008/104] Avoid stale API connections during release verification --- .github/workflows/release.yml | 3 ++- scripts/release/phase1.test.mjs | 23 ++++++++++------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f54004f4..b876ae9264 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,8 +106,9 @@ jobs: } fs.writeFileSync('release-assets/SHA256SUMS', files.map(file => `${hash(regular(`release-assets/${file}`))} ${file}\n`).join('')); files.push('SHA256SUMS'); + // Close API connections before synchronous gh checks can leave pooled sockets idle. const api = async (path, method = 'GET', body) => { - const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com + const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com if (!response.ok) throw Error(`GitHub API failed: ${method} ${path} ${response.status}`); return response.json(); }; diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index a354956de3..4956ad4ab0 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -145,6 +145,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an const script = publicationScript() for (const mode of [ 'success', + 'idle-socket', + 'post-read-error', 'draft', 'api-error', 'missing', @@ -201,6 +203,12 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an const fetch = async (url, options = {}) => { const u = new URL(url) const method = options.method ?? 'GET' + if ( + calls.some(([kind]) => kind === 'gh') && + (mode === 'post-read-error' || + (mode === 'idle-socket' && options.headers.Connection !== 'close')) + ) + throw new TypeError('other side closed') calls.push([method, u.pathname]) const response = (body, status = 200) => new Response( @@ -230,12 +238,6 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an uploaded.push(item) return response(item, 201) } - if (/\/releases\/assets\/\d+$/.test(u.pathname)) { - const asset = uploaded.find( - (a) => a.id === Number(u.pathname.split('/').at(-1)) - ) - return response(mode === 'readback-error' ? 'corrupt' : asset.bytes) - } if (u.pathname.includes('/releases/tags/')) return response({ ...release(false), @@ -281,7 +283,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an } const execute = () => vm.runInNewContext(`(async()=>{${script}\n})()`, context) - if (mode === 'success') await execute() + if (['success', 'idle-socket'].includes(mode)) await execute() else await assert.rejects(execute, undefined, mode) if ( [ @@ -296,7 +298,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an assert.ok(!calls.some(([method]) => method === 'POST'), mode) if (['upload-error'].includes(mode)) assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) - if (mode === 'success') { + if (['success', 'idle-socket'].includes(mode)) { assert.equal(uploaded.length, 17) assert.equal(calls.filter(([method]) => method === 'gh').length, 18) } @@ -305,7 +307,6 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an } } }) - test('acquires the locked workspace graph before offline SBOM resolution', () => { const script = readFileSync( new URL('./phase1-build.sh', import.meta.url), @@ -323,7 +324,6 @@ test('acquires the locked workspace graph before offline SBOM resolution', () => 'should acquire before offline generation' ) }) - // SPDX conversion retains the binary root and conservative package inventory. const binary = Buffer.from('fictional binary') function spdxFixture() { @@ -371,14 +371,12 @@ function spdxFixture() { } return { original, document } } - test('SPDX preserves a binary-bound root and package versions/PURLs', () => { const { original, document } = spdxFixture() assert.equal(validateSpdx(document, binary, original), document) delete document.documentDescribes assert.doesNotThrow(() => validateSpdx(document, binary, original)) }) - test('SPDX rejects root/hash ambiguity and lost package identities', () => { for (const mutate of [ (d) => { @@ -411,7 +409,6 @@ test('SPDX rejects root/hash ambiguity and lost package identities', () => { assert.throws(() => validateSpdx(document, binary, original)) } }) - test('completes real converter package output with a binary-bound root', () => { const { original, document } = spdxFixture() document.packages.shift() From f3a8696cb8cd6249375cebc68a702110ab221b03 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 20:35:48 +0530 Subject: [PATCH 009/104] Clarify rehearsed release status and CLI verification --- CHANGELOG.md | 5 +- docs/guide/cli.md | 7 ++ docs/guide/release-verification.md | 29 ++++---- ...6-10-13-attested-release-implementation.md | 26 +++---- ...inary-attestations-phase1-compatibility.md | 68 ++++++++++--------- ...6-10-09-1235-release-attestation-design.md | 6 +- 6 files changed, 78 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa0d1a5b5..dea16472ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add Phase 1 attested Fastly wasm and Linux/macOS CLI releases through a reusable signer workflow, with conservative SPDX 2.3 inventories, published provenance/SBOM bundles, immutable release verification and a manual verified-byte Fastly route. Platform qualification, reproducibility and the custom verifier remain later work; publication requires maintainer settings and an authorized sandbox rehearsal. +- Add Phase 1 attested Fastly wasm and Linux/macOS CLI releases through a reusable signer workflow, with conservative SPDX 2.3 inventories, published provenance/SBOM bundles, immutable release verification and a manual verified-byte Fastly route. Platform qualification, reproducibility and the custom verifier remain later work; rehearsal `.3` passed, while production publication requires maintainer settings. ### Changed +- Attested release binaries report `x-ts-version` as the full 40-character source commit, rather than the release tag. + - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. @@ -48,7 +50,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the Phase 1 binary-attestation workflow: tag-triggered Fastly wasm and Linux/macOS CLI builds, provenance and conservative Cargo/npm CycloneDX SBOM attestations, immutable release assets and operator verification/deployment instructions. Release enablement still requires maintainer settings and a sandbox signing/publication rehearsal. - Added the `[auction].rewrite_creatives` (default `true`) and `[auction].sanitize_creatives` (default `false`) options. `rewrite_creatives` rewrites winning-bid adm to first-party endpoints across `POST /auction` and publisher SSAT/page-bids delivery (proxy/click URL conversion, bidder `` removal; creative TSJS injection on `POST /auction` only). Enabling `sanitize_creatives` strips executable markup from winning-bid adm before delivery. - `creative_opportunities.slot.gam_unit_path` is now a template supporting `{network_id}`, `{slot_id}`, and `{section}`, so a publisher whose ad unit varies by site section expresses it in one slot rule instead of one per (slot × section). `{section}` derives from the request path: `[creative_opportunities].section_segment` selects which path segment names the section (0-based, default `0`; set `1` for locale-prefixed URLs), and `section_root` supplies the value for paths with no such segment. `section_root` is required when a template uses `{section}`. Existing static and absent `gam_unit_path` configs are unchanged. Startup rejects a blank `gam_network_id` only when an absent/default path or `{network_id}` template consumes it. Trusted Server conservatively caps whole rendered dynamic paths at 100 UTF-8 bytes, informed by Google's 100-character per-ad-unit-code limit; an over-limit request-specific path omits that slot without failing the response. During typed/startup finalization, every placeholder-bearing template that omits `section_segment` materializes `section_segment = 0`, so an older binary rejects the blob loudly. Static and absent paths remain legacy-schema compatible only when both `section_root` and `section_segment` are omitted. Before rolling back below this feature, replace or remove dynamic paths, remove both keys, re-push and finalize the config, then roll back the binary. - Added opt-in APS HTTP debug metadata for controlled test sites, exposing the direct request and response under `/auction` provider metadata using the Prebid Server `debug.httpcalls` shape. diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 7333eef7a5..d52f78b76f 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -59,6 +59,13 @@ commands are shown explicitly. ## Install from source +`cargo install-cli` builds `ts` from source. The resulting local binary is not +covered by the release attestation. Operators who need an attested CLI should +download the matching `ts-` release asset, verify it with the documented +`gh attestation verify` and `gh release verify-asset` commands in the +[release verification guide](/guide/release-verification), and install that +verified file. + From the repository root, install the `ts` binary with the workspace Cargo alias: ```bash diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 028d85cd9e..4e9ed4460a 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -49,8 +49,8 @@ vulnerability scan. ## Download and verify Use an independently trusted GitHub CLI installation with `gh attestation verify`, -`gh release verify`, and `gh release verify-asset`; use the version recorded in -the release job's recorded version. The SBOM comparison below also uses `jq`. +`gh release verify`, and `gh release verify-asset`. Use the GitHub CLI version +recorded in the release job log. The SBOM comparison below also uses `jq`. Verification needs GitHub network access. Obtain the approved tag and its 40-character source commit through an independent source-review process. The retained strict SemVer/lightweight-tag validation is deferred; use a @@ -144,10 +144,13 @@ and [immutable asset verification reference](https://cli.github.com/manual/gh_re ## Use the verified CLI -Choose the executable matching your platform, verify it as above, then copy it -into a directory on your `PATH` as `ts` and make it executable. Verify before -first execution. Individual commands may still require a source checkout and -provider tools; see [CLI](/guide/cli). +`cargo install-cli` builds `ts` from source. The resulting local binary is not +covered by the release attestation. Operators who need an attested CLI should +download the `ts-` release asset matching their platform, verify it with +the `gh attestation verify` and `gh release verify-asset` commands above, then +copy that verified file into a directory on their `PATH` as `ts` and make it +executable. Verify before first execution. Individual commands may still require +a source checkout and provider tools; see [CLI](/guide/cli). ## Deploy the verified Fastly wasm @@ -237,7 +240,8 @@ violation can expose a mutable release before the workflow reports failure. Publication lists releases, including drafts, and refuses every existing matching tag. It creates one draft, retains its release ID, and binds uploads and the final publish mutation to that ID. It checks the exact signing-approved asset -set and checks immutable membership after publication. Full upload readback +set, validates each upload response's size and SHA-256 digest before publication, +and checks immutable membership after publication. Re-downloading uploaded bytes and the reviewed publication owner remain additive later work. There are no automatic mutation retries or draft-resume claims. @@ -247,8 +251,9 @@ the tag without an independently approved recovery decision. Published immutable releases are never overwritten. A fresh release needs a fresh approved tag; Phase 2's full reconciliation and incident owner remains deferred. -First rehearse the approved commit in the user's public sandbox with Actions -and immutable releases enabled. The user must approve **each** new -`v0.0.0-rehearsal.N` tag before it is pushed. Record the verification outputs -in the draft PR. Never use the upstream repository for rehearsal tags or -releases. The first real tag and every deployment require separate authorization. +Before the first production release, rehearse the reviewed commit in a separate +public repository with Actions and immutable releases enabled. Use a fresh, +lightweight `v0.0.0-rehearsal.N` tag for each attempt and retain verification +outputs in the release PR. Keep rehearsal tags and releases out of the upstream +repository. For production, confirm the required settings and checks, then +follow the maintainer release authorization process before tagging or deploying. diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 136be701f6..fa1c0ab000 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -6,7 +6,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-serve **Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. -Phase 1 uses CycloneDX 1.5 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. The complete SPDX/composition contract below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. +Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. CycloneDX is only an intermediate generator format. Complete composition qualification below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. @@ -15,21 +15,21 @@ The phase labels on requirement headings below apply only to the reduced Phase 1 ### Phase 1 implementation checklist - [x] Copy reviewed spec, plan, feasibility and schemas from `122b78664b1235c717e9208fd200f9a9b0c335df`; preserve linked historical evidence. -- [x] Add `.github/workflows/release.yml`: four tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. -- [x] Generate CycloneDX 1.5 from the selected Cargo target after its locked release build and npm's installed tree from that same build; merge without reference collisions and bind the final binary hash. Record conservative build-dependency coverage, including npm build tools; do not claim exact retained-byte or native-source closure. +- [x] Add `.github/workflows/release.yml` as caller/publisher and reusable `release-build.yml` as builder/signer: four tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. +- [x] Generate intermediate CycloneDX from the selected Cargo target after its locked release build and npm's installed tree from that same build; merge without reference collisions, convert to published SPDX 2.3 and bind the final binary hash. Record conservative build-dependency coverage, including npm build tools; do not claim exact retained-byte or native-source closure. - [x] Enforce fresh npm install, prohibit skip/prebuilt overrides, audit compile-time config inputs and reject planted operator values in every binary. - [x] Document exact GitHub CLI provenance/SBOM and immutable-asset verification, native baselines and deployment of verified Fastly bytes; update CLI guide, navigation and changelog. - [x] Run pure local tests, actionlint, format/domain checks, touched build gates, independent and security review; push this branch and open only the requested draft PR. -| #1235 acceptance item | Phase 1 evidence | -| ----------------------------------------------------- | ---------------------------------------------------------------------- | -| Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | -| Tag publishes immutable binaries, SBOMs and checksums | release.yml build/sign/release jobs; human rehearsal pending | -| Provenance, SBOM predicate and asset verification | release-verification.md; human rehearsal pending | -| Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | -| Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | -| Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | -| Operator documentation | CLI guide, release-verification, CHANGELOG | +| #1235 acceptance item | Phase 1 evidence | +| ----------------------------------------------------- | -------------------------------------------------------------------------------- | +| Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | +| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.3` passed | +| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.3` verification passed | +| Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | +| Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | +| Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | +| Operator documentation | CLI guide, release-verification, CHANGELOG | ### Complete remaining-work slice map @@ -49,7 +49,7 @@ The [machine-readable inventory](binary-attestations-slice-map.json) assigns eve | 2.10 | consumer cli | | 2.11 | docs and format misc | -All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare, Spin and Axum distribution/qualification follow these slices. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection, a user-provided throwaway signing rehearsal, and explicit authorization of the first real tag. +All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare, Spin and Axum distribution/qualification follow these slices. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection and explicit authorization of the first real tag; signing/publication rehearsal `.3` passed at `ca902b6fe`. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index 1b0ab58a31..44051e0f89 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -1,6 +1,6 @@ # Phase 1 compatibility audit and approved alignment -**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The user selected **SPDX 2.3**. The fork rehearsal `v0.0.0-rehearsal.1` was canceled before signing/publication; its tag will never be reused. Further rehearsal requires a fresh approved tag. +**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Rehearsal `.3` passed at `ca902b6fe`; the rehearsal history below records earlier outcomes. **Conclusion:** the original Phase 1 signer path and CycloneDX public format would have required a later breaking policy replacement. Fix those now. Phase 1 is the first implementation slice of the existing contract: retain its public names, signer/caller distinction, SPDX semantics and original policy files. Introduce no competing release-index schema or weakened consumer policy. @@ -13,12 +13,13 @@ Frozen source references below mean: ## Approved scope after audit -The user approved the audit before further edits. This document contains the -new decisions; carried spec and plan sentences remain unchanged beyond phase -labels. The frozen source remains the destination contract. The pre-correction +This document records the alignment decisions. Carried requirements remain +unchanged; the phasing sections describe this slice and its rehearsal outcome. The frozen source remains the destination contract. The pre-correction Phase 1 audit baseline is `4dd217125c6e38055e4bcb2cba32c0b95e739914`; the following corrections are implemented in this slice, with verification and -rehearsal tracked in the PR description at its current commit. No successful signing/publication rehearsal is claimed. +rehearsal tracked in the PR description at its current commit. Signing and +immutable publication rehearsal `.3` passed at `ca902b6fe`; see the +[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6343374330). The current correction aligns the local input-free `release-build.yml` signer with the `release.yml` caller/publisher, real SPDX 2.3 documents, all eight @@ -32,7 +33,7 @@ retries and verifies immutable membership after publication. All three original policy files are carried unchanged, with **no Phase 1 readers**. Checked cohort selection, a supplemental tool policy, environment -normalization/remapping, stricter tag/source validation, upload readback, +normalization/remapping, stricter tag/source validation, uploaded-byte re-download, macOS 13 targeting, GNU glibc 2.34 qualification, additional binary/runtime checks and CLI immutable build facts are additive later work. Current builds retain the hosted GNU glibc 2.39 baseline and macOS 15.0 deployment target; @@ -101,33 +102,34 @@ qualified evidence. ### Publication contract and nonbreaking recovery plan -| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | -| ------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Publication owner / permissions | `release.yml`, contents-write only (Task 16) | Matches | Preserve; no signer/OIDC/settings credential in publisher. | -| Artifact transfer | Validated current-run outputs, exact bytes/sets (Tasks 12/15/16) | Wildcard `release-*` merge | Fix now: exact named downloads in separate directories, signing-owned digest manifests, reject missing/extra/changed files before creation. | -| Draft lookup/recovery | Published refused; matching draft resumable only after source/assets match | Tag lookup + refuse existing; no full draft listing | List all releases including drafts and refuse every matching tag in Phase 1. Capture created ID for mutations and identity-bound verification. Later matching-draft resume is an additive recovery capability. | -| Prepublication validation | Upload, re-download and validate intended set | Upload then publish | Upload readback/digest validation remains additive later work. This correction binds the exact signing-approved 17 assets locally and mutations to the created release ID. | -| Checks after publish | Immutable=true; signed release/membership and exact intended source/bytes | Immutable check and normal gh verification | Preserve and bind checks to created release ID. `gh release verify` accepts tags, so bracket it with created-ID/tag correspondence checks rather than invent an ID attestation endpoint. | -| Mutable/lost/unknown outcomes | Controlled cleanup for confirmed mutable; read-only reconciliation otherwise | Loud failure, manual recovery | Planned narrower Phase 1 behavior: leave failed/unknown state for explicit inspection, no blind retry/deletion. Later owner adds safe reconciliation without changing immutable assets or user verification commands. | -| Concurrency | Tag scoped; never cancel publishing run | Same rule also applies to PR builds | Preserve tags; cancel superseded PR builds only. | -| Public deployment route | Verified raw Fastly bytes; prepared EdgeZero context/recovery later | Manual clean package/member comparison | Preserve manual verification/deployment while later adding prepared-payload APIs; #1235 does not wait for boundaries 5–6. | +| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | +| ------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Publication owner / permissions | `release.yml`, contents-write only (Task 16) | Matches | Preserve; no signer/OIDC/settings credential in publisher. | +| Artifact transfer | Validated current-run outputs, exact bytes/sets (Tasks 12/15/16) | Wildcard `release-*` merge | Fix now: exact named downloads in separate directories, signing-owned digest manifests, reject missing/extra/changed files before creation. | +| Draft lookup/recovery | Published refused; matching draft resumable only after source/assets match | Tag lookup + refuse existing; no full draft listing | List all releases including drafts and refuse every matching tag in Phase 1. Capture created ID for mutations and identity-bound verification. Later matching-draft resume is an additive recovery capability. | +| Prepublication validation | Upload, re-download and validate intended set | Upload then publish | Upload responses already validate size and SHA-256 digest before publication. Re-downloading uploaded bytes remains additive later work. Exact signing-approved local assets and mutations are bound to the created release ID. | +| Checks after publish | Immutable=true; signed release/membership and exact intended source/bytes | Immutable check and normal gh verification | Preserve and bind checks to created release ID. `gh release verify` accepts tags, so bracket it with created-ID/tag correspondence checks rather than invent an ID attestation endpoint. | +| Mutable/lost/unknown outcomes | Controlled cleanup for confirmed mutable; read-only reconciliation otherwise | Loud failure, manual recovery | Planned narrower Phase 1 behavior: leave failed/unknown state for explicit inspection, no blind retry/deletion. Later owner adds safe reconciliation without changing immutable assets or user verification commands. | +| Concurrency | Tag scoped; never cancel publishing run | Same rule also applies to PR builds | Preserve tags; cancel superseded PR builds only. | +| Public deployment route | Verified raw Fastly bytes; prepared EdgeZero context/recovery later | Manual clean package/member comparison | Preserve manual verification/deployment while later adding prepared-payload APIs; #1235 does not wait for boundaries 5–6. | **Nonbreaking adoption rule:** existing `gh` checks remain valid when later releases add real companions, index and richer evidence. The full custom verifier is introduced for index-bearing releases; it must not reinterpret the first slice as having complete graph/reproducibility/qualification evidence. No fake index or placeholder successful companions will be published. The unavoidable missing-evidence/recovery/platform-qualification limits are explicit; signer path, cohort, SPDX predicate/names and reserved companion names are aligned before the next publication attempt. -## First rehearsal record - -The user approved the first rehearsal tag in the earlier session. The coding -agent pushed the implementation commit and created the annotated fork-only -`v0.0.0-rehearsal.1` tag at `4dd217125`. -[Run 39768688430](https://github.com/prk-Jr/trusted-server/actions/runs/39768688430) -completed the Fastly build. Its three native builds were canceled after review -feedback; the signing and publication jobs executed no steps, so this run -produced no attestations. Read-only reconciliation on 2026-10-22 found the tag -still present and no releases, including drafts, on the fork. Immutable releases -had previously been enabled on the fork under the user's sandbox authorization. -No IAB tags, releases or settings were changed. - -The old tag remains unused and is never reused. From this point, no fork push -or tag creation is authorized until the user writes -`approved: v0.0.0-rehearsal.N` for a fresh tag. Every future rehearsal tag must -be lightweight. +## Rehearsal history + +- `v0.0.0-rehearsal.1` at `4dd217125`: Fastly build passed; native builds + were canceled after review. Signing and publication executed no steps, so + the run produced no attestations or release. The original annotated tag remains + unused; later rehearsal tags are lightweight and never reused. +- `v0.0.0-rehearsal.2` at `41896cb07`: all builds and signing passed and + an immutable 17-asset release was published. The final API read failed because + of a stale pooled connection after synchronous verification. Independent + verification passed; see the [verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6341078344). +- `v0.0.0-rehearsal.3` at `ca902b6fe`: all builds, signing, publication + and the final API read passed. Independent verification with GitHub CLI 2.102.0 + passed release verification, all 17 asset checks, both predicates and both + signer identity checks for each binary, checksums and SPDX predicate equality; + see the [full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6343374330). + +The immutable `.2` and `.3` releases remain intact; no drafts remain from +these rehearsals. No upstream tags, releases or repository settings were changed. diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 8ecd442b66..f49b6a02ff 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -4,7 +4,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. -**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the reusable `release-build.yml` signer and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. The phase boundaries follow [aram356's round-1 architecture review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857): @@ -19,7 +19,7 @@ The phase boundaries follow [aram356's round-1 architecture review](https://gith Native reproduction is deferred to Phase 2+, where it is measured, not a release gate. Fastly-first byte-for-byte reproducibility is a later phase; Phase 1 makes no reproducibility claim. One artifact's reproduction therefore cannot hold up the first attested release. -Phase 1 uses CycloneDX 1.5 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. The complete SPDX/composition contract below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. +Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. CycloneDX is only an intermediate generator format. Complete composition qualification below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. @@ -353,7 +353,7 @@ The initial verified tool contract is GitHub CLI **2.97.0** and `actions/attest` | Certificate issuer | `https://token.actions.githubusercontent.com` | | Source ref / source digest | Exact `refs/tags/` / independently approved 40-character commit | | Build signer digest | Same source commit for this same-revision local reusable workflow | -| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | +| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | | Caller / external parameters | Exactly `workflow: {ref: "refs/tags/", repository: "https://github.com/IABTechLab/trusted-server", path: ".github/workflows/release.yml"}`; no arbitrary inputs/commands | | Source resolved dependency | `git+https://github.com/IABTechLab/trusted-server@refs/tags/` with `gitCommit` equal to approved commit | | Event / runner | `push` / `github-hosted` | From 922c2211c0cc7941bfc8ffa9f775ef8ac0971232 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 20:47:13 +0530 Subject: [PATCH 010/104] Retry transient post-publication verification reads --- .github/workflows/release.yml | 29 +++++++++++++----- docs/guide/release-verification.md | 43 ++++++++++++++++++++++++++ scripts/release/phase1.test.mjs | 49 +++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b876ae9264..79080c62e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,6 @@ permissions: concurrency: group: binary-attestations-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} - jobs: build: uses: ./.github/workflows/release-build.yml @@ -108,8 +107,8 @@ jobs: files.push('SHA256SUMS'); // Close API connections before synchronous gh checks can leave pooled sockets idle. const api = async (path, method = 'GET', body) => { - const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com - if (!response.ok) throw Error(`GitHub API failed: ${method} ${path} ${response.status}`); + const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(method === 'GET' && path.startsWith('releases/') ? {signal:AbortSignal.timeout(10000)} : {}), ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com + if (!response.ok) throw Object.assign(Error(`GitHub API failed: ${method} ${path} ${response.status}`), {status:response.status, retryAfter:response.headers.get('retry-after')}); return response.json(); }; // List releases, including drafts. Never resume, overwrite or delete a matching tag. @@ -132,19 +131,35 @@ jobs: if (asset.name !== file || asset.state !== 'uploaded' || asset.size !== bytes.length || asset.digest !== `sha256:${hash(bytes)}`) throw Error(`Upload mismatch: ${file}`); } const published = await api(`releases/${id}`, 'PATCH', {draft:false}); + // BEGIN READ RETRIES + const retryDeadline = Date.now() + 180000; + const retryRead = async operation => { + for (let attempt = 1; ; attempt++) try { return await operation(); } catch (error) { + const stderr = error.stderr?.toString().trim() ?? ''; + const transient = !error.stdout?.length && (error.name === 'TimeoutError' || error.status === 429 || error.status >= 500 && error.status <= 599 || /^(ECONNRESET|ETIMEDOUT|EAI_AGAIN|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)$/.test(error.cause?.code ?? error.code) || /^HTTP (429|5\d\d): [^\r\n]+$/.test(stderr)); + if (!transient || attempt === 3) throw error; + const after = Number(error.retryAfter) * 1000 || Date.parse(error.retryAfter) - Date.now(); + const delay = Math.min(10000, Math.max([2000, 5000][attempt - 1], Number.isFinite(after) ? after : 0)); + if (Date.now() + delay >= retryDeadline) throw Error('Post-publish retry window exhausted'); + console.warn(`Post-publish read retry: attempt ${attempt + 1}/3, transient ${error.status ?? 'transport/CLI'} failure, wait ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + }; + // END READ RETRIES const verifyIdentity = async () => { - const release = await api(`releases/${id}`), byTag = await api(`releases/tags/${encodeURIComponent(tag)}`); - if (published.id !== id || release.id !== id || byTag.id !== id || release.tag_name !== tag || release.draft !== false || release.immutable !== true) throw Error('Published release identity or immutability mismatch'); + const release = await retryRead(() => api(`releases/${id}`)); + if (published.id !== id || release.id !== id || release.tag_name !== tag || release.draft !== false || release.immutable !== true) throw Error('Published release identity or immutability mismatch'); if (!Array.isArray(release.assets) || release.assets.length !== files.length || new Set(release.assets.map(asset => asset.name)).size !== files.length) throw Error('Published asset set mismatch'); for (const asset of release.assets) { if (!files.includes(asset.name)) throw Error('Unexpected published asset'); const bytes = regular(`release-assets/${asset.name}`); if (asset.size !== bytes.length || asset.digest !== `sha256:${hash(bytes)}`) throw Error('Published asset digest mismatch'); } + if ((await retryRead(() => api(`releases/tags/${encodeURIComponent(tag)}`))).id !== id) throw Error('Published tag identity mismatch'); }; await verifyIdentity(); - execFileSync('gh', ['release','verify',tag,'--repo',repo], {stdio:'inherit'}); - for (const file of files) execFileSync('gh', ['release','verify-asset',tag,`release-assets/${file}`,'--repo',repo], {stdio:'inherit'}); + console.log('Verified release:', (await retryRead(() => execFileSync('gh', ['release','verify',tag,'--repo',repo], {timeout:10000}))).toString()); + for (const file of files) console.log('Verified asset:', file, (await retryRead(() => execFileSync('gh', ['release','verify-asset',tag,`release-assets/${file}`,'--repo',repo], {timeout:10000}))).toString()); await verifyIdentity(); // END PUBLICATION })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 4e9ed4460a..b49778907c 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -245,6 +245,49 @@ and checks immutable membership after publication. Re-downloading uploaded bytes and the reviewed publication owner remain additive later work. There are no automatic mutation retries or draft-resume claims. +Only post-publish reads may retry: release/asset metadata GETs and the GitHub +CLI release/asset verification calls. Each check has at most three attempts, +with 2s then 5s backoff. A supplied `Retry-After` can increase a delay, capped +at 10s (at most 20s waiting per check). A shared three-minute window bounds +retry scheduling across the job, and individual read requests/commands time out +after 10s. `Connection: close` remains enabled for API calls. + +Retries require a recognized transport error, timeout, HTTP 5xx or 429. +GitHub CLI errors are classified conservatively: an otherwise empty-output +HTTP 5xx/429 diagnostic or a recognized transport/timeout code may retry; +ambiguous failures stop. Successful responses with wrong identity, immutability, +membership or digests, and definitive verification failures stop immediately. +Release listing before creation and every create/upload/publish mutation remain +single-shot. + +If these retries are exhausted, the release has already been published; a +failure does not undo publication or establish that immutability was verified. +Confirm its state using the recorded release ID. For an immutable release, keep +its tag and assets unchanged and rerun only the following read-only checks in +the directory containing the downloaded assets. Set `RELEASE_ID` to the ID from +the release job log; retain the reviewed `REPO`, `TAG`, `COMMIT` and `ASSETS` +values from the verification procedure above. + +```bash +gh api "repos/$REPO/releases/$RELEASE_ID" \ + --jq '{id, tag_name, draft, immutable, assets: [.assets[] | {name, size, digest}]}' +test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" = "$RELEASE_ID" +test "$(gh api "repos/$REPO/releases/$RELEASE_ID" --jq '.immutable')" = true +gh release verify "$TAG" --repo "$REPO" +for asset in "${ASSETS[@]}"; do + for suffix in '' .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do + gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO" + done +done +gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" +shasum -a 256 --check SHA256SUMS +``` + +Then rerun both signer identity checks for provenance and SPDX for each binary, +including the SPDX predicate comparison, exactly as documented above. Preserve +the successful verification outputs with the release record; do not rerun the +publication job to recover a failed read-only check. + After a failed or indeterminate mutation, inspect the recorded release ID and all matching drafts read-only. Do not rerun uploads, delete a release, or reuse the tag without an independently approved recovery decision. Published immutable diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index 4956ad4ab0..e74cda54c5 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -219,7 +219,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ) if (u.pathname.endsWith('/releases') && method === 'GET') return mode === 'api-error' - ? response('denied', 403) + ? response('denied', 503) : response(mode === 'draft' ? [release(true)] : []) if (u.pathname.endsWith('/releases') && method === 'POST') return response(release(true), 201) @@ -249,6 +249,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an } const context = { Buffer, + AbortSignal, URL, console, fetch, @@ -296,6 +297,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ].includes(mode) ) assert.ok(!calls.some(([method]) => method === 'POST'), mode) + if (mode === 'api-error' || mode === 'upload-error') + assert.equal(calls.length, mode === 'api-error' ? 1 : 3) if (['upload-error'].includes(mode)) assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) if (['success', 'idle-socket'].includes(mode)) { @@ -419,3 +422,47 @@ test('completes real converter package output with a binary-bound root', () => { assert.equal(document.packages.at(-1).filesAnalyzed, false) assert.throws(() => completeSpdx(document, binary, original), /Unexpected/) }) +test('post-publish retries distinguish transient failures and cap Retry-After', async () => { + const code = publicationScript() + .split('// BEGIN READ RETRIES')[1] + ?.split('// END READ RETRIES')[0] + assert.ok(code, 'should exercise the actual post-publish retry helper') + for (const [error, failures, expected] of [ + [{ cause: { code: 'UND_ERR_SOCKET' } }, 1, [2000]], + [{ status: 503 }, 9, [2000, 5000]], + [{ status: 503, elapsed: 180000 }, 9, []], + [{ status: 429, retryAfter: '30' }, 1, [10000]], + [{ status: 429, retryAfter: '3' }, 1, [3000]], + [{ status: 403 }, 9, []], + [{ name: 'TimeoutError' }, 1, [2000]], + [{ status: 429, retryAfter: 'Thu, 01 Jan 1970 00:00:03 GMT' }, 1, [3000]], + [new Error('Published asset digest mismatch'), 9, []], + [{ stderr: Buffer.from('HTTP 503: Service Unavailable') }, 1, [2000]], + [{ stderr: Buffer.from('verification failed: HTTP 503') }, 9, []], + ]) { + const waits = [], + logs = [] + let attempts = 0, + now = 0 + const retry = vm.runInNewContext(`${code}; retryRead`, { + Date: { now: () => now, parse: Date.parse }, + setTimeout: (resolve, ms) => { + waits.push(ms) + now += ms + resolve() + }, + console: { warn: (line) => logs.push(line) }, + }) + const operation = () => { + now += error.elapsed ?? 0 + if (++attempts <= failures) throw error + return 'verified' + } + if (failures === 1) assert.equal(await retry(operation), 'verified') + else await assert.rejects(() => retry(operation)) + assert.deepEqual(waits, expected) + assert.equal(attempts, expected.length + 1) + assert.equal(logs.length, expected.length) + assert.ok(logs.every((line) => /attempt [23]\/3/.test(line))) + } +}) From ff9f9c44cd33b3aabebd0290b5cb69c444669377 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 22:33:54 +0530 Subject: [PATCH 011/104] Document offline verification and completed release rehearsal --- CHANGELOG.md | 2 +- docs/guide/release-verification.md | 106 ++++++++++++++++++ ...6-10-13-attested-release-implementation.md | 10 +- ...inary-attestations-phase1-compatibility.md | 22 +++- ...6-10-09-1235-release-attestation-design.md | 50 +++++++++ 5 files changed, 183 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dea16472ee..92304933b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add Phase 1 attested Fastly wasm and Linux/macOS CLI releases through a reusable signer workflow, with conservative SPDX 2.3 inventories, published provenance/SBOM bundles, immutable release verification and a manual verified-byte Fastly route. Platform qualification, reproducibility and the custom verifier remain later work; rehearsal `.3` passed, while production publication requires maintainer settings. +- Add Phase 1 attested Fastly wasm and Linux/macOS CLI releases through a reusable signer workflow, with conservative SPDX 2.3 inventories, published provenance/SBOM bundles, immutable release verification and a manual verified-byte Fastly route. Platform qualification, reproducibility and the custom verifier remain later work; rehearsal `.4` passed at `922c2211c`, while production publication requires maintainer settings. ### Changed diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index b49778907c..a7aea00035 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -142,6 +142,50 @@ publisher service, runtime configuration or deployment operation. See the [GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify) and [immutable asset verification reference](https://cli.github.com/manual/gh_release_verify-asset). +## Verify bundles offline + +While online, obtain the trust roots using an independently trusted GitHub CLI +installation. GitHub CLI 2.102.0 supports the flags below; retain its version, +the roots and their digest with the independently approved source identity. +Do not accept replacement roots supplied only alongside an untrusted binary. + +```bash +gh --version +gh attestation trusted-root > trusted_root.jsonl +shasum -a 256 trusted_root.jsonl +``` + +Transfer the binaries, their bundles, published SPDX documents, approved source +pins and trusted roots to the offline machine. Reuse `REPO`, `TAG`, `COMMIT`, +`ASSETS`, `WORKFLOW` and `IDENTITY` from above. Supplying both the bundle and +custom roots avoids network acquisition; there is no `--offline` flag. + +```bash +for asset in "${ASSETS[@]}"; do + for kind in provenance sbom; do + predicate=https://slsa.dev/provenance/v1 + if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi + checks=(--repo "$REPO" --bundle "$asset.$kind.sigstore.json" + --custom-trusted-root trusted_root.jsonl + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" + --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type "$predicate") + gh attestation verify "$asset" "${checks[@]}" --signer-workflow "$WORKFLOW" + gh attestation verify "$asset" "${checks[@]}" --cert-identity "$IDENTITY" \ + --format json > "$asset.offline-$kind-verification.json" + if [[ "$kind" == sbom ]]; then + jq -e --slurpfile document "$asset.spdx.json" \ + 'length == 1 and .[0].verificationResult.statement.predicate == $document[0]' \ + "$asset.offline-$kind-verification.json" + fi + done +done +``` + +Offline success establishes signed build evidence for those retained bytes. +It does not establish current release membership, immutability or deployment +authorization. Retain the separate online release and asset verification record; +checksums alone cannot replace it. + ## Use the verified CLI `cargo install-cli` builds `ts` from source. The resulting local binary is not @@ -203,6 +247,18 @@ verified wasm, manifest, package digest, previous active version and observed result. No build step may replace or annotate the verified wasm. Automated EdgeZero prepared-payload deployment and operation recovery are later phases. +### Local runtime evidence + +The [rehearsal runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668) +records local execution of the verified Fastly wasm at +`922c2211c0cc7941bfc8ffa9f775ef8ac0971232` using pinned Viceroy 0.21.0. +It uses the example configuration with loopback publisher/origin values, +public development secrets and a local Node origin fixture. A real page request +returned HTTP 200, the fixture marker, injected JS and the full source SHA in +`x-ts-version`. Both the wasm and the CLI used to seed local config were verified +before execution. No Fastly service was deployed. This proves the local prebuilt +execution route; provider submission and production operation remain separate. + ## Build inputs, policy and release administration Builds use the tracked source, manifests, `Cargo.lock`, pinned Rust and Node, @@ -211,6 +267,18 @@ logical config-store ID and participates in the template build digest. Build facts include the source commit. The CLI carries the fictional example-config scaffold, not an operator config. +Release Fastly responses report `x-ts-version` as the full 40-character source +commit supplied by the release build. Development display fallbacks are not the +release identity. `ts --version` reports the command name `ts` and Cargo package version, not a +source-commit field. Source identity comes from the attestation pins above. +The executable's SHA-256 is checked externally against the retained release +bytes; exposing that digest as a runtime build fact belongs to #161. + +The current compile-time input audit is recorded in the spec's Phase 1 note. +It includes Rust 1.98.1, tracked logical IDs, the private JS build path and its +indirect build-module options, and the locked native dependencies. It does not +claim a complete native tool closure or hermetic build. + The three policy files are carried unchanged from the frozen reviewed design. They have **no Phase 1 readers** and do not authorize these builds or replace the workflow's explicit selections. @@ -300,3 +368,41 @@ lightweight `v0.0.0-rehearsal.N` tag for each attempt and retain verification outputs in the release PR. Keep rehearsal tags and releases out of the upstream repository. For production, confirm the required settings and checks, then follow the maintainer release authorization process before tagging or deploying. + +## Maintainer settings checklist + +An administrator must configure these settings before a production tag. The +commands below only inspect settings; this workflow applies none of them. + +- **Settings → General → Releases:** enable immutable releases. See + [GitHub's immutability instructions](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes). +- **Settings → Rules → Rulesets → New tag ruleset:** set enforcement to **Active**, + target `v*` (API pattern `refs/tags/v*`), and enable **Restrict creations**, + **Restrict updates** and **Restrict deletions**. Add only the designated release + maintainers' team to the bypass list, with **Always allow** bypass. Record the + team's name and ID in the maintainer release record; avoid broad role bypasses. + See [repository ruleset configuration](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository). +- Optionally require the reviewed release build checks on the source branch + before approving a tag. Use actual check names from that commit. A tag-triggered + check cannot be required to pass before creating its own tag. Source eligibility + and the later policy reader are not enforced by Phase 1. + +```bash +REPO=IABTechLab/trusted-server +gh api "repos/$REPO/immutable-releases" +gh api "repos/$REPO/rulesets?includes_parents=true" \ + --jq '.[] | {id, name, target, enforcement}' +RULESET_ID=12345 +gh api "repos/$REPO/rulesets/$RULESET_ID?includes_parents=true" \ + --jq '{name, target, enforcement, conditions, rules, bypass_actors}' +gh api "repos/$REPO/commits/$COMMIT/check-runs" \ + --jq '.check_runs[] | {name, status, conclusion}' +``` + +The immutable-releases endpoint must return `enabled: true`; a disabled setting +returns HTTP 404. Inspect the ruleset's tag target, active enforcement, exact +patterns, creation/update/deletion rules and team bypass actors. The API exposes +bypass actors only to callers with sufficient repository access; an omitted field +is not proof of an empty bypass list. Also review inherited rulesets. These are +maintainer gates, not evidence that Phase 1's publication token can administer +repository settings. diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index fa1c0ab000..4a2c8e936f 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -12,6 +12,12 @@ Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installe The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. +Rehearsal `.4` passed at exact implementation SHA +`922c2211c0cc7941bfc8ffa9f775ef8ac0971232`; see the +[full online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346483024) +and [offline/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668). +Later documentation-only commits do not change that rehearsed implementation. + ### Phase 1 implementation checklist - [x] Copy reviewed spec, plan, feasibility and schemas from `122b78664b1235c717e9208fd200f9a9b0c335df`; preserve linked historical evidence. @@ -24,8 +30,8 @@ The phase labels on requirement headings below apply only to the reduced Phase 1 | #1235 acceptance item | Phase 1 evidence | | ----------------------------------------------------- | -------------------------------------------------------------------------------- | | Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | -| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.3` passed | -| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.3` verification passed | +| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.4` passed | +| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.4` verification passed | | Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | | Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | | Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index 44051e0f89..eea98ad10d 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -1,6 +1,6 @@ # Phase 1 compatibility audit and approved alignment -**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Rehearsal `.3` passed at `ca902b6fe`; the rehearsal history below records earlier outcomes. +**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Rehearsal `.4` passed at `922c2211c`; the rehearsal history below records earlier outcomes. **Conclusion:** the original Phase 1 signer path and CycloneDX public format would have required a later breaking policy replacement. Fix those now. Phase 1 is the first implementation slice of the existing contract: retain its public names, signer/caller distinction, SPDX semantics and original policy files. Introduce no competing release-index schema or weakened consumer policy. @@ -18,8 +18,8 @@ unchanged; the phasing sections describe this slice and its rehearsal outcome. T Phase 1 audit baseline is `4dd217125c6e38055e4bcb2cba32c0b95e739914`; the following corrections are implemented in this slice, with verification and rehearsal tracked in the PR description at its current commit. Signing and -immutable publication rehearsal `.3` passed at `ca902b6fe`; see the -[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6343374330). +immutable publication rehearsal `.4` passed at `922c2211c`; see the +[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346483024). The current correction aligns the local input-free `release-build.yml` signer with the `release.yml` caller/publisher, real SPDX 2.3 documents, all eight @@ -131,5 +131,19 @@ qualified evidence. signer identity checks for each binary, checksums and SPDX predicate equality; see the [full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6343374330). -The immutable `.2` and `.3` releases remain intact; no drafts remain from +- `v0.0.0-rehearsal.4` at `922c2211c0cc7941bfc8ffa9f775ef8ac0971232`: + all four builds, signing, immutable publication and post-publish checks passed. + Independent GitHub CLI 2.102.0 verification passed all 17 asset checks, + checksums, both predicates and signer checks, and SPDX equality. Offline + verification also passed with OS-enforced network denial; pinned Viceroy 0.21.0 + served a configured page from the verified wasm with the exact source header. + See the [online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346483024) + and [offline/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668). + Bounded read-only retries were present, but no transient retry was observed. + +The exact rehearsed implementation SHA above is retained even when later +commits change documentation only. This documentation update does not change +workflows, scripts, tests or release bytes. + +The immutable `.2`, `.3` and `.4` releases remain intact; no drafts remain from these rehearsals. No upstream tags, releases or repository settings were changed. diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index f49b6a02ff..3035c1d3db 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -21,6 +21,56 @@ Native reproduction is deferred to Phase 2+, where it is measured, not a release Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. CycloneDX is only an intermediate generator format. Complete composition qualification below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. +### Phase 1 input audit and operator evidence + +The Phase 1 audit against main at `f9281917a7260b723f5f22175911c6d2147ef650` +supplements the historical input table below without changing its destination +requirements. Source references: [core build inputs](https://github.com/IABTechLab/trusted-server/blob/f9281917a7260b723f5f22175911c6d2147ef650/crates/trusted-server-core/build.rs), +[JS private output](https://github.com/IABTechLab/trusted-server/blob/f9281917a7260b723f5f22175911c6d2147ef650/crates/trusted-server-js/build.rs), +[JS module options](https://github.com/IABTechLab/trusted-server/blob/f9281917a7260b723f5f22175911c6d2147ef650/crates/trusted-server-js/lib/build-all.mjs), +[Axum dependency features](https://github.com/IABTechLab/trusted-server/blob/f9281917a7260b723f5f22175911c6d2147ef650/crates/trusted-server-adapter-axum/Cargo.toml), +and [CLI version output](https://github.com/IABTechLab/trusted-server/blob/f9281917a7260b723f5f22175911c6d2147ef650/crates/trusted-server-cli/src/run.rs). + +- Rust is pinned to **1.98.1**. The tracked `Cargo.toml`, `Cargo.lock`, + `.cargo/config.toml`, tool pins, source tree and release profile remain inputs. +- The two workspace build scripts are core's `build.rs` and JS's `build.rs`. + Core also reads `build_support/git_version.rs`, its source tree, the root + manifest/lockfile and tracked `edgezero.toml`. The latter supplies the logical + `trusted_server_config` store ID, not a physical deployment binding. +- JS inputs include `build/bundle_set.rs`, its declared source/manifests/lockfile, + `lib/build-all.mjs` and its indirect `lib/build-module-options.mjs` import. + Fresh npm installation precedes compilation. Cargo embeds modules through + generated `OUT_DIR/tsjs_modules.rs` from private `OUT_DIR/tsjs-dist`; + `crates/trusted-server-js/dist/` is not the release input. Phase 1 rejects the development + `TSJS_SKIP_BUILD`, `TSJS_PREBUILT_DIR` and `TSJS_TEST` overrides. +- Production includes also cover the GPT bootstrap, CLI consent stub, CLI ad-template collector and + fictional example-config scaffolds. The CLI and locked EdgeZero dependency + contain developer checkout fallbacks via `CARGO_MANIFEST_DIR`; removing these + remains a later recipe requirement. Tracked OpenRTB generated source is an + ordinary Rust input, not ambient protobuf generation during this build. +- Cargo supplies package/target facts and `OUT_DIR`; core's Git resolver reads + source refs unless overridden. Phase 1 already supplies the validated full + source SHA through `TRUSTED_SERVER__GIT_VERSION`. Locked EdgeZero CLI at + `85d933b8044dec29fe9e4685cae194eaf0290911` additionally generates its + linked-adapter registry from its own manifest and feature selection. +- Axum has no own build script or compile-time `env!`/`include_*` inputs. + Its normal release dependency enables core defaults only; `test-utils` is + dev-only. Shared logical IDs and JS inputs above remain compiled inputs; + application configuration and secrets are runtime inputs. This audit does + not claim the deferred complete native compiler/library closure. + +For the R-OFFLINE decision below, Phase 1 uses the guide's +[offline bundle verification](../../guide/release-verification.md#verify-bundles-offline) +with independently obtained roots and source pins. Offline success is build +proof, not current official membership or deployment authorization. + +For #161, release Fastly `x-ts-version` is the full 40-character source commit; +`ts --version` currently exposes the command name `ts` and Cargo package version. The executable +digest is verified externally; exposing it at runtime remains with #161. +The [verification guide](../../guide/release-verification.md) records these facts +and the maintainer settings gates. Local Viceroy execution proves a local route, +not submission to a Fastly service. + **Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. From 7c7f4b0bd7b96e1c72f11eab70f76c1c23370035 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 23:14:16 +0530 Subject: [PATCH 012/104] Add production Axum assets and release startup checks --- .github/workflows/release-build.yml | 15 ++ .github/workflows/release.yml | 42 +++- CHANGELOG.md | 2 +- docs/guide/release-verification.md | 94 ++++++-- ...6-10-13-attested-release-implementation.md | 19 +- ...inary-attestations-phase1-compatibility.md | 80 ++++++- .../plans/binary-attestations-slice-map.json | 64 ++++- ...6-10-09-1235-release-attestation-design.md | 14 +- scripts/release/phase1-build.sh | 48 +++- scripts/release/phase1.mjs | 221 +++++++++++++++++- scripts/release/phase1.test.mjs | 48 +++- scripts/release/release-cohort.json | 54 +++++ 12 files changed, 660 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 63edc2f560..ce6565952a 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -40,6 +40,18 @@ jobs: package: trusted-server-cli target: x86_64-apple-darwin asset: ts-x86_64-apple-darwin + - os: ubuntu-24.04 + package: trusted-server-adapter-axum + target: x86_64-unknown-linux-gnu + asset: trusted-server-axum-x86_64-unknown-linux-gnu + - os: ubuntu-24.04 + package: trusted-server-adapter-axum + target: x86_64-unknown-linux-musl + asset: trusted-server-axum-x86_64-unknown-linux-musl + - os: ubuntu-24.04-arm + package: trusted-server-adapter-axum + target: aarch64-unknown-linux-gnu + asset: trusted-server-axum-aarch64-unknown-linux-gnu steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -95,6 +107,9 @@ jobs: matrix: asset: [ + trusted-server-axum-x86_64-unknown-linux-gnu, + trusted-server-axum-x86_64-unknown-linux-musl, + trusted-server-axum-aarch64-unknown-linux-gnu, trusted-server-fastly.wasm, ts-aarch64-apple-darwin, ts-x86_64-apple-darwin, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79080c62e0..68fd23c56c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ on: - 'crates/trusted-server-cli/**' - 'crates/trusted-server-js/**' - 'crates/trusted-server-adapter-fastly/**' + - 'crates/trusted-server-adapter-axum/**' - 'crates/trusted-server-openrtb*/**' permissions: contents: read @@ -69,6 +70,30 @@ jobs: with: name: signed-ts-x86_64-unknown-linux-gnu path: signed/ts-x86_64-unknown-linux-gnu + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-trusted-server-axum-x86_64-unknown-linux-gnu + path: build/trusted-server-axum-x86_64-unknown-linux-gnu + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: signed-trusted-server-axum-x86_64-unknown-linux-gnu + path: signed/trusted-server-axum-x86_64-unknown-linux-gnu + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-trusted-server-axum-x86_64-unknown-linux-musl + path: build/trusted-server-axum-x86_64-unknown-linux-musl + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: signed-trusted-server-axum-x86_64-unknown-linux-musl + path: signed/trusted-server-axum-x86_64-unknown-linux-musl + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: release-trusted-server-axum-aarch64-unknown-linux-gnu + path: build/trusted-server-axum-aarch64-unknown-linux-gnu + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: signed-trusted-server-axum-aarch64-unknown-linux-gnu + path: signed/trusted-server-axum-aarch64-unknown-linux-gnu - name: Verify handoff, create once, publish by release ID and verify env: GH_TOKEN: ${{ github.token }} @@ -86,7 +111,7 @@ jobs: const { execFileSync } = require('node:child_process'); const { GH_REPO: repo, RELEASE_TAG: tag, GH_TOKEN: token, SOURCE_COMMIT: source } = process.env; if (!/^[\w-]+\/[\w.-]+$/.test(repo) || !/^v[\w.+-]+$/.test(tag)) throw Error('Invalid repository or tag'); - const names = ['trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', 'ts-x86_64-unknown-linux-gnu']; + const names = ['trusted-server-axum-x86_64-unknown-linux-gnu', 'trusted-server-axum-x86_64-unknown-linux-musl', 'trusted-server-axum-aarch64-unknown-linux-gnu', 'trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', 'ts-x86_64-unknown-linux-gnu']; const hash = bytes => createHash('sha256').update(bytes).digest('hex'); const regular = path => { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.size === 0) throw Error(`Invalid file: ${path}`); return fs.readFileSync(path); }; const exact = (dir, expected) => { if (JSON.stringify(fs.readdirSync(dir).sort()) !== JSON.stringify([...expected].sort())) throw Error(`Unexpected artifact contents: ${dir}`); }; @@ -119,7 +144,20 @@ jobs: if (releases.some(release => release.tag_name === tag)) throw Error('Release or draft already exists'); if (releases.length < 100) break; } - const created = await api('releases', 'POST', {tag_name:tag, target_commitish:source, name:tag, draft:true, body:'Verify binary provenance, SPDX SBOM and immutable release membership using the release verification guide at this tag.'}); + const body = [ + `[Verification guide](https://github.com/${repo}/blob/${tag}/docs/guide/release-verification.md)`, '', '```bash', 'set -euo pipefail', + `REPO=${repo}`, `TAG=${tag}`, `COMMIT=${source}`, `ASSETS=(${names.join(' ')})`, + 'gh release download "$TAG" --repo "$REPO"', 'gh release verify "$TAG" --repo "$REPO"', + 'for asset in "${ASSETS[@]}"; do', + ' for suffix in "" .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO"; done', + ' for kind in provenance sbom; do', + ' predicate=https://slsa.dev/provenance/v1; if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi', + ' checks=(--repo "$REPO" --bundle "$asset.$kind.sigstore.json" --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type "$predicate")', + ' gh attestation verify "$asset" "${checks[@]}" --signer-workflow "$REPO/.github/workflows/release-build.yml"', + ' gh attestation verify "$asset" "${checks[@]}" --cert-identity "https://github.com/$REPO/.github/workflows/release-build.yml@refs/tags/$TAG"', + ' done', 'done', 'gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO"', 'shasum -a 256 --check SHA256SUMS', '```', + ].join('\n'); + const created = await api('releases', 'POST', {tag_name:tag, target_commitish:source, name:tag, draft:true, body}); if (!Number.isSafeInteger(created.id) || created.id <= 0 || created.tag_name !== tag || created.draft !== true) throw Error('Invalid created release identity'); const id = created.id; console.log(`Created release ID: ${id}`); diff --git a/CHANGELOG.md b/CHANGELOG.md index 92304933b5..9e097b4322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add Phase 1 attested Fastly wasm and Linux/macOS CLI releases through a reusable signer workflow, with conservative SPDX 2.3 inventories, published provenance/SBOM bundles, immutable release verification and a manual verified-byte Fastly route. Platform qualification, reproducibility and the custom verifier remain later work; rehearsal `.4` passed at `922c2211c`, while production publication requires maintainer settings. +- Add Phase 1 attested Fastly wasm, three Linux/macOS CLI and three production Axum binaries, with SPDX 2.3 inventories, provenance/SBOM bundles, immutable verification, GNU glibc 2.34 and static musl startup checks. Reproducibility and the custom verifier remain later work; production publication requires maintainer settings. ### Changed diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index a7aea00035..c3d9d822e7 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -1,7 +1,7 @@ # Verify release binaries -Phase 1 publishes four binaries, four SPDX 2.3 SBOMs, eight Sigstore bundles -and `SHA256SUMS`: **17 immutable release assets**. Each binary has SLSA build +Phase 1 publishes seven binaries, seven SPDX 2.3 SBOMs, fourteen Sigstore bundles +and `SHA256SUMS`: **29 immutable release assets**. Each binary has SLSA build provenance and an SBOM attestation. Verify the downloaded bytes before execution or deployment. Reproducibility, complete composition and the custom Trusted Server verifier remain later phases. @@ -14,21 +14,31 @@ and signing jobs do not check out, compile or execute the downloaded binaries. ## Platforms and coverage -| Asset | Phase 1 baseline and qualification limit | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; reproducibility and hosted runtime qualification remain pending | -| `ts-x86_64-unknown-linux-gnu` | Ubuntu 24.04 x86-64, glibc 2.39 baseline; the retained policy's glibc 2.34 ceiling is not yet established | -| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 15.0; runtime compatibility qualification remains pending | -| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 15.0; runtime compatibility qualification remains pending | +| Asset | Phase 1 baseline and qualification limit | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; reproducibility and hosted runtime qualification remain pending | +| `ts-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 builder; maximum required glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | +| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 15.0; runtime compatibility qualification remains pending | +| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 15.0; runtime compatibility qualification remains pending | +| `trusted-server-axum-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 build; maximum glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | +| `trusted-server-axum-x86_64-unknown-linux-musl` | Fully static x86-64; musl 1.2.4 build; pinned Alpine and empty-rootfs startup smokes | +| `trusted-server-axum-aarch64-unknown-linux-gnu` | ARM64 GNU; native ARM UBI 9 build; maximum glibc 2.34; Debian 12 startup smoke | The retained destination design calls for a macOS 13.0 deployment target; Phase 1 still builds with 15.0. Lowering that floor and proving older-runtime compatibility are additive later work. -The executables are not notarized. Compatibility with older Linux runtimes and -the retained GNU policy's clean startup images remains unqualified. Native +The executables are not notarized. GNU builds require no GLIBC symbol newer than 2.34; glibc 2.34 and newer are +the selected baseline. This includes +Debian 12/bookworm-slim, Ubuntu 22.04+ and RHEL/UBI 9+. Distroless +cc-debian12 has a compatible libc floor but is not a Phase 1 smoke image. +The musl asset needs no dynamic loader or libraries; Alpine and an empty-rootfs +container are required smoke images, while distroless-static is another deployment option +without a separate Phase 1 runtime test. Build logs record acquired runtime image +IDs. These required startup checks do not establish complete platform/tool qualification. +The expanded seven-binary batch awaits hosted CI and rehearsal `.5`; `.4` +proves only the original four binaries. Native libraries and provider tools needed by individual `ts` commands retain their -existing prerequisites. Cloudflare, Spin, Axum, Windows and Linux ARM64 releases -follow separately. +existing prerequisites. Cloudflare, Spin, Windows and Linux ARM64 CLI releases follow separately. Each `.spdx.json` is a **conservative build dependency inventory** of the selected Cargo target and the installed npm build tree used for that build. @@ -67,7 +77,10 @@ REPO=IABTechLab/trusted-server TAG=v1.2.3 COMMIT=0123456789abcdef0123456789abcdef01234567 ASSETS=(trusted-server-fastly.wasm ts-x86_64-unknown-linux-gnu \ - ts-aarch64-apple-darwin ts-x86_64-apple-darwin) + ts-aarch64-apple-darwin ts-x86_64-apple-darwin \ + trusted-server-axum-x86_64-unknown-linux-gnu \ + trusted-server-axum-x86_64-unknown-linux-musl \ + trusted-server-axum-aarch64-unknown-linux-gnu) gh release download "$TAG" --repo "$REPO" --dir . test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.immutable')" = true @@ -81,7 +94,7 @@ gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" shasum -a 256 --check SHA256SUMS ``` -The checksums cover the other 16 assets, never `SHA256SUMS` itself. Immutable +The checksums cover the other 28 assets, never `SHA256SUMS` itself. Immutable release verification establishes membership; provenance verification establishes the reviewed signer and source. Both are required. @@ -196,6 +209,51 @@ copy that verified file into a directory on their `PATH` as `ts` and make it executable. Verify before first execution. Individual commands may still require a source checkout and provider tools; see [CLI](/guide/cli). +## Run a verified Axum release + +Select one of the three `trusted-server-axum-` assets above, download it +and complete the same online/offline attestation and immutable membership checks +before execution. The aarch64 GNU variant is for ARM servers. Copy the verified +file into your deployment directory, make it executable, and retain its digest. + +Application configuration and secrets stay external. With a reviewed checkout's +`edgezero.toml` and adapter manifests available, use the verified `ts` CLI to +validate and publish your private `trusted-server.toml` into the local config +store envelope; this does not rebuild Axum: + +```bash +ts config push --adapter axum --local --manifest ./edgezero.toml \ + --app-config ./trusted-server.toml --no-diff --yes +export TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG="$( + jq -er '.trusted_server_config' .edgezero/local-config-trusted_server_config.json +)" +``` + +Supply these runtime secret environment variables through your private secret +management, matching the secret key references in your configuration: + +- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD` +- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET` +- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE` + +For example, after verifying the x86-64 GNU asset and supplying those variables: + +```bash +chmod u+x ./trusted-server-axum-x86_64-unknown-linux-gnu +PORT=8080 ./trusted-server-axum-x86_64-unknown-linux-gnu +``` + +`PORT` binds Axum to loopback (`127.0.0.1`); use your reviewed reverse proxy and +TLS configuration for public traffic. Axum has no help/version or dry-run mode. +Its build smoke starts an isolated server with a fictional external config +envelope and requires `/health` to return `200` and `ok`, distinguishing a +configured router from the configuration-error router. This is a startup check, +not production proxy/load qualification. + +An Axum source build, like `cargo install-cli`, is not covered by these release +attestations. The musl asset is fully static; no allocator change is included. +Later work should consider mimalloc/jemalloc for musl under load. + ## Deploy the verified Fastly wasm Keep the verified download unchanged. The ordinary source-build route does not @@ -279,13 +337,15 @@ It includes Rust 1.98.1, tracked logical IDs, the private JS build path and its indirect build-module options, and the locked native dependencies. It does not claim a complete native tool closure or hermetic build. -The three policy files are carried unchanged from the frozen reviewed design. -They have **no Phase 1 readers** and do not authorize these builds or replace +The tools and consumer policy files are carried unchanged. The cohort retains +its original four entries and adds the three Axum entries documented in the +[release PR’s compatibility audit](https://github.com/IABTechLab/trusted-server/pull/1435). +All three have **no Phase 1 readers** and do not authorize these builds or replace the workflow's explicit selections. | Policy | Current use | Additive later adoption | | ----------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `scripts/release/release-cohort.json` | Retained destination contract; no reader | Checked fixed cohort selection, original assurance profiles and compatibility qualification | +| `scripts/release/release-cohort.json` | Original four plus three descriptive Axum entries; no reader | Checked fixed cohort selection, original assurance profiles and compatibility qualification | | `scripts/release/release-tools.json` | Retained pending/null qualification records; no reader | Checked tool identities, authenticated acquisition, image/SDK measurements and qualified recipes | | `crates/trusted-server-cli/release-policy.json` | Retained signer/caller and SPDX contract; no reader or custom verifier | Embedded consumer policy, bounds, historical-policy acceptance and complete index/evidence joins | diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 4a2c8e936f..978d9447cb 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -4,11 +4,21 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. -**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. CycloneDX is only an intermediate generator format. Complete composition qualification below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. -**Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. +**Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin distributions plus full custom-verifier qualification of Axum. These slices remain closed to production selection and signing until separately qualified and authorized. + +**Phase 1 Axum extension:** the original four asset names remain unchanged; +three named Axum variants extend this slice to seven binaries and 29 files. +GNU builds enforce a maximum glibc requirement of 2.34; musl is fully static. +The cohort records this explicit extension, but tools/policy and the frozen v1 +index schema remain unchanged. Phase 1 emits no index. The later index/consumer +slices need a reviewed seven-artifact amendment, including new target/capability +forms; see the [compatibility audit](binary-attestations-phase1-compatibility.md) +and slice-map annotations. Rehearsal `.4` covers the original four assets only; +the expanded implementation requires rehearsal `.5`. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. @@ -16,12 +26,13 @@ Rehearsal `.4` passed at exact implementation SHA `922c2211c0cc7941bfc8ffa9f775ef8ac0971232`; see the [full online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346483024) and [offline/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668). -Later documentation-only commits do not change that rehearsed implementation. +Rehearsal `.4` covers that four-binary implementation only; the seven-binary +changes require new CI and rehearsal evidence. ### Phase 1 implementation checklist - [x] Copy reviewed spec, plan, feasibility and schemas from `122b78664b1235c717e9208fd200f9a9b0c335df`; preserve linked historical evidence. -- [x] Add `.github/workflows/release.yml` as caller/publisher and reusable `release-build.yml` as builder/signer: four tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. +- [x] Add `.github/workflows/release.yml` as caller/publisher and reusable `release-build.yml` as builder/signer: seven tag builds, isolated provenance/SBOM signing, draft/upload/publish and immutable readback; optional read-only PR builds. - [x] Generate intermediate CycloneDX from the selected Cargo target after its locked release build and npm's installed tree from that same build; merge without reference collisions, convert to published SPDX 2.3 and bind the final binary hash. Record conservative build-dependency coverage, including npm build tools; do not claim exact retained-byte or native-source closure. - [x] Enforce fresh npm install, prohibit skip/prebuilt overrides, audit compile-time config inputs and reject planted operator values in every binary. - [x] Document exact GitHub CLI provenance/SBOM and immutable-asset verification, native baselines and deployment of verified Fastly bytes; update CLI guide, navigation and changelog. diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index eea98ad10d..5f65df03c5 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -31,14 +31,14 @@ The publisher lists all releases including drafts, refuses a matching tag, retains the created release ID for mutations, performs no automatic mutation retries and verifies immutable membership after publication. -All three original policy files are carried unchanged, with **no Phase 1 +The tools and consumer policy files remain unchanged. The cohort adds the +three descriptive Axum entries below; all three policies still have **no Phase 1 readers**. Checked cohort selection, a supplemental tool policy, environment normalization/remapping, stricter tag/source validation, uploaded-byte re-download, -macOS 13 targeting, GNU glibc 2.34 qualification, additional binary/runtime -checks and CLI immutable build facts are additive later work. Current builds -retain the hosted GNU glibc 2.39 baseline and macOS 15.0 deployment target; -copying the destination policies does not establish their older-runtime -compatibility requirements. Native reproduction is a later measurement, not a +macOS 13 targeting, full platform qualification and CLI immutable build facts +are additive later work. The next batch adds UBI GNU glibc 2.34 checks and +startup smokes, while retaining the macOS 15.0 deployment target; copying a +destination policy alone never establishes its qualification requirements. Native reproduction is a later measurement, not a Phase 1 release gate. Full Task 16 resume/reconciliation remains deferred. The [operator guide](/guide/release-verification) describes the corrected @@ -115,6 +115,74 @@ qualified evidence. **Nonbreaking adoption rule:** existing `gh` checks remain valid when later releases add real companions, index and richer evidence. The full custom verifier is introduced for index-bearing releases; it must not reinterpret the first slice as having complete graph/reproducibility/qualification evidence. No fake index or placeholder successful companions will be published. The unavoidable missing-evidence/recovery/platform-qualification limits are explicit; signer path, cohort, SPDX predicate/names and reserved companion names are aligned before the next publication attempt. +### Approved seven-binary Axum extension + +The next implementation batch deliberately extends the frozen cohort from four +to **seven binaries / 29 release files**, retaining all four original names. +Rehearsal `.4` at `922c2211c` proves only the original four-binary/17-file +implementation. The seven-binary batch requires fresh rehearsal `.5`; it is not +claimed rehearsed by the `.4` evidence. + +The exact cohort diff is three appended records, all default features, empty +extra features, `variant: default`, `assurance_profile: provenance-sbom`, +`role: server`, `bin: trusted-server-axum`, package +`trusted-server-adapter-axum`, and descriptive capability `axum-production`: + +| Added name / target | Compatibility record | +| ----------------------------------------------------------------------------- | ------------------------------------ | +| `trusted-server-axum-x86_64-unknown-linux-gnu` / `x86_64-unknown-linux-gnu` | `x86_64`, `linux-gnu`, `glibc-2.34` | +| `trusted-server-axum-x86_64-unknown-linux-musl` / `x86_64-unknown-linux-musl` | `x86_64`, `linux-musl`, `static` | +| `trusted-server-axum-aarch64-unknown-linux-gnu` / `aarch64-unknown-linux-gnu` | `aarch64`, `linux-gnu`, `glibc-2.34` | + +This adds production Axum distributions by explicit scope decision. `static` +means no dynamic libc/loader requirement, not qualification of every Linux +kernel. The historical cohort identifier `phase1-fastly-cli` and its original +four entries remain unchanged. **No Phase 1 reader** enforces these records or +their destination assurance profiles/capability labels. `release-tools.json` and +consumer `release-policy.json` remain byte-for-byte unchanged. The workflow's +explicit seven selections own the Phase 1 builds; no supplemental tool file or +policy reader is introduced. + +Every binary gets `.spdx.json`, `.provenance.sigstore.json` and +`.sbom.sigstore.json`; only actual actions/attest outputs are retained. +`SHA256SUMS` covers the other **28** files and never itself. Exact-name downloads, +signing receipts and release membership checks cover the entire 29-file set. +Signer identity stays at reusable `release-build.yml` with the exact tag ref. +Existing users' four asset names and verification commands continue to work. + +GNU compilation runs in digest-pinned UBI 9.6 with pinned Rust installed through +rustup, explicit target installation and `--locked`; final ELF requirements above +glibc 2.34 fail. Node is the existing pinned distribution mounted read-only. +The builder's system packages are acquired through DNF and are not a fully +qualified or pinned native tool closure. Musl uses Ubuntu's exact `1.2.4-2` +`musl`, `musl-dev` and `musl-tools` packages, the explicit `musl-gcc` linker and +GCC 13 driver, whose actual version is logged. Target-specific C/linker/static +flags leave host SBOM-tool builds unchanged. No allocator or Rust source changes +are included; consider mimalloc/jemalloc for musl under load later. + +Builds scan the final bytes for config canaries and check locked inputs are +unchanged. Before upload they check wasm magic/version, each CLI's existing +`ts 0.1.0` package-version output, GNU symbol ceilings and static-musl loader +requirements. GNU x86 binaries start on Debian 12-slim, Ubuntu 22.04 and UBI 9.6 +minimal; ARM Axum starts on Debian 12-slim on the ARM runner; musl starts on +pinned Alpine and an empty root filesystem. Acquired builder/runtime image IDs +are logged. Axum has no help/version mode; its isolated, read-only, nonroot +container receives only a fictional runtime envelope/secrets and must return +`200 ok` from its configured health router. This does not establish publisher +proxy/load qualification or complete native composition. + +The carried **v1 index schema remains unchanged**: its closed four-entry graph +cannot represent this cohort. Phase 1 publishes no index or deferred companions. +Phase 2 must review a seven-entry schema/policy amendment including musl/ARM +compatibility and `axum-production` capability forms before reopening its custom +consumer. The [slice-map annotations](binary-attestations-slice-map.json) mark +producer/consumer `FIXED_ARTIFACTS`, `cohort_order`, descriptor slots, bounds, +qualification enums, candidate workflows and fixtures as **update for Axum**. +The full frozen graph would have 53 files for seven artifacts; Phase 1's 29 is +not a replacement for those deferred bounds. Richer companions and consumer +support remain additive later work; existing manual GitHub verification stays +valid. + ## Rehearsal history - `v0.0.0-rehearsal.1` at `4dd217125`: Fastly build passed; native builds diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json index b26c825208..0e2e63b6d0 100644 --- a/docs/superpowers/plans/binary-attestations-slice-map.json +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -603,5 +603,67 @@ "destination": "scripts/release/phase1.mjs", "subset": "Translate the small planted-operator-value byte scanner; no producer crate or qualification machinery is imported." } - ] + ], + "axum_amendment": { + "status": "Update for Axum before deferred slices reopen; frozen v1 contracts stay unchanged", + "executables": 7, + "phase1_release_files": 29, + "requires_reviewed_schema_and_policy_amendment": true, + "full_graph_note": "The frozen full graph has 4 release-wide files plus 7 per executable: 32 for 4 executables, or 53 for 7. Phase 1 publishes only 4 per executable plus SHA256SUMS: 29.", + "paths": { + "crates/trusted-server-release/src/policy.rs": "Update for Axum: ReleaseTarget/ALL and producer FIXED_ARTIFACTS encode four identities; extend targets, server product and exact seven-entry cohort.", + "crates/trusted-server-cli/src/release/policy.rs": "Update for Axum: Consumer FIXED_ARTIFACTS/validate_artifacts encode four identities; review seven-entry policy and new capability forms.", + "crates/trusted-server-cli/src/release/bounds.rs": "Update for Axum: COHORT_EXECUTABLES is four; review all derived bounds.", + "crates/trusted-server-cli/release-policy.json": "Update for Axum: Four records and cohort_executables=4; leave carried bytes unchanged until reviewed consumer-policy amendment.", + "scripts/release/release-cohort.json": "Update for Axum: Phase 1 deliberately adds three Axum records; original four remain unchanged and no Phase 1 reader is added.", + "scripts/release/release-tools.json": "Update for Axum: Existing recipe/tool records cover four artifacts; keep carried bytes unchanged and review musl/ARM/Axum tool identities later.", + "scripts/release/qualification/approvals.json": "Update for Axum: Qualification keys cover the original four products and environments.", + "scripts/release/qualification/results.schema.json": "Update for Axum: Asset/environment/tool/platform enumerations require Axum, musl and ARM review.", + "docs/superpowers/specs/schemas/release-index-v1.schema.json": "Update for Axum: Frozen min/max4, identity-presence clauses and target/capability/compatibility enums require reviewed amendment; do not edit this carried schema.", + "crates/trusted-server-release/src/distribution.rs": "Update for Axum: cohort_order and index validation enforce exact FIXED_ARTIFACTS membership.", + "crates/trusted-server-release/src/distribution/ordinary.rs": "Update for Axum: DESCRIPTOR_SLOTS[4] and four-job collection require three additional entries.", + "crates/trusted-server-release/src/publication/model.rs": "Update for Axum: INDEX_ARTIFACTS=4 and derived 32-asset full graph; seven-artifact full graph would have 53, not Phase 1 count 29.", + ".github/workflows/test.yml": "Update for Axum: Selection/candidate-index dependencies, descriptor variables and four-job fixture invocation need seven producers.", + "crates/trusted-server-release/src/policy/tests.rs": "Update for Axum: Exact four-name assertions and every-native-is-CLI assumptions.", + "crates/trusted-server-release/src/collection/tests.rs": "Update for Axum: Exact four-artifact expectation.", + "crates/trusted-server-release/src/distribution/tests.rs": "Update for Axum: Four-entry schema joins and duplicate-entry assertions.", + "crates/trusted-server-release/src/distribution/tests/transfer.rs": "Update for Axum: Fewer-than-four rejection cases.", + "crates/trusted-server-release/src/distribution/tests/golden.rs": "Update for Axum: Four-artifact embedded candidate fixture set.", + "crates/trusted-server-release/src/recipe/tests/candidate_entry.rs": "Update for Axum: Explicit four-artifact rebuild outcome list.", + "crates/trusted-server-release/src/recipe/tests/candidate_index.rs": "Update for Axum: Four-artifact candidate-index assembly fixtures.", + "crates/trusted-server-release/src/publication/tests/inputs.rs": "Update for Axum: 32-asset acceptance/33-asset rejection reflect the full frozen graph.", + "crates/trusted-server-cli/src/release/tests/documents/mod.rs": "Update for Axum: Explicit artifacts.len()==4 assertion.", + "crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs": "Update for Axum: Embedded four-artifact fixture set.", + "crates/trusted-server-cli/tests/fixtures/release/release-index.json": "Update for Axum: Exact original four identities.", + "crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json": "Update for Axum: Exact original four identities.", + "crates/trusted-server-release/src/inspect.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/inspect/elf.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/execute.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/plan.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/startup.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/startup/accepted.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/recipe/startup/native.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/composition/external.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/composition/native.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/composition/platform.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/src/spdx/platform.rs": "Update for Axum: Review exhaustive ReleaseTarget matches, native-CLI assumptions, Axum product/runtime inspection and composition.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", + "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index." + } + } } diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 3035c1d3db..28574247dc 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -4,7 +4,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. -**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm and three native CLI binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the reusable `release-build.yml` signer and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the reusable `release-build.yml` signer and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. The phase boundaries follow [aram356's round-1 architecture review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857): @@ -71,7 +71,17 @@ The [verification guide](../../guide/release-verification.md) records these fact and the maintainer settings gates. Local Viceroy execution proves a local route, not submission to a Fastly service. -**Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin/Axum. These slices remain closed to production selection and signing until separately qualified and authorized. +**Phase 2+ retains all remaining requirements:** two-build reproducibility, confined recipes, platform qualification, authenticated tool/image acquisition, complete composition/SPDX coverage, candidate collection/index, the custom consumer, publication owner, Task 14 same-commit validation, and Cloudflare/Spin distributions plus full custom-verifier qualification of Axum. These slices remain closed to production selection and signing until separately qualified and authorized. + +**Phase 1 Axum extension:** the original four asset names remain unchanged; +three named Axum variants extend this slice to seven binaries and 29 files. +GNU builds enforce a maximum glibc requirement of 2.34; musl is fully static. +The cohort records this explicit extension, but tools/policy and the frozen v1 +index schema remain unchanged. Phase 1 emits no index. The later index/consumer +slices need a reviewed seven-artifact amendment, including new target/capability +forms; see the [compatibility audit](../plans/binary-attestations-phase1-compatibility.md) +and slice-map annotations. Rehearsal `.4` covers the original four assets only; +the expanded implementation requires rehearsal `.5`. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index 1e7ca88cf0..27956bca92 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -7,6 +7,7 @@ release_asset=${3:?asset required} case "$build_package/$build_target/$release_asset" in trusted-server-adapter-fastly/wasm32-wasip1/trusted-server-fastly.wasm) executable=trusted-server-adapter-fastly.wasm ;; trusted-server-cli/x86_64-unknown-linux-gnu/ts-x86_64-unknown-linux-gnu|trusted-server-cli/aarch64-apple-darwin/ts-aarch64-apple-darwin|trusted-server-cli/x86_64-apple-darwin/ts-x86_64-apple-darwin) executable=ts ;; + trusted-server-adapter-axum/x86_64-unknown-linux-gnu/trusted-server-axum-x86_64-unknown-linux-gnu|trusted-server-adapter-axum/x86_64-unknown-linux-musl/trusted-server-axum-x86_64-unknown-linux-musl|trusted-server-adapter-axum/aarch64-unknown-linux-gnu/trusted-server-axum-aarch64-unknown-linux-gnu) executable=trusted-server-axum ;; *) echo 'Unsupported release build selection' >&2; exit 1 ;; esac : "${RUNNER_TEMP:?}" @@ -37,7 +38,7 @@ proxy_secret = "phase1-secret-canary-32-characters" [permissions] policy = "phase1-permission-policy-canary" CONFIG -trap 'rm -f trusted-server.toml' EXIT +trap 'rm -f trusted-server.toml; if [[ -n "${build_container:-}" ]]; then docker rm -f "$build_container" >/dev/null 2>&1 || true; fi' EXIT export CARGO_TARGET_DIR="$RUNNER_TEMP/phase1-target" [[ ! -e "$CARGO_TARGET_DIR" ]] mkdir -p release-assets @@ -45,9 +46,47 @@ mkdir -p release-assets # The pinned Node distribution includes npm. npm verifies lockfile integrity for # fetched packages; no generator dependencies are installed with unpinned npx. npm --prefix crates/trusted-server-js/lib ci -cargo build --package "$build_package" --release --target "$build_target" --locked +if [[ "$build_target" == *-linux-gnu ]]; then + builder_base=registry.access.redhat.com/ubi9/ubi:9.6@sha256:dec374e05cc13ebbc0975c9f521f3db6942d27f8ccdf06b180160490eef8bdbc + docker build --build-arg "BASE_IMAGE=$builder_base" --iidfile "$RUNNER_TEMP/phase1-builder-id" - <<'DOCKERFILE' +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +RUN dnf install -y gcc gcc-c++ glibc-devel git-core cmake make perl && dnf clean all +DOCKERFILE + builder=$(cat "$RUNNER_TEMP/phase1-builder-id") + echo "GNU builder: $builder_base $builder" + build_container=phase1-build-$RANDOM-$$ + mkdir "$CARGO_TARGET_DIR" + docker run --rm --name "$build_container" --workdir /source \ + --mount "type=bind,src=$PWD,dst=/source" \ + --mount "type=bind,src=$CARGO_TARGET_DIR,dst=/target" \ + --mount "type=bind,src=$(dirname "$(dirname "$(node -p process.execPath)")"),dst=/opt/node,readonly" \ + --mount "type=bind,src=$(readlink -f "$(command -v rustup)"),dst=/usr/local/bin/rustup,readonly" \ + --env TRUSTED_SERVER__GIT_VERSION --env TRUSTED_SERVER__PUBLISHER__ORIGIN_URL \ + --env TRUSTED_SERVER__PUBLISHER__PROXY_SECRET --env EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME \ + --env FASTLY_SERVICE_ID --env CARGO_TARGET_DIR=/target \ + "$builder" bash -euc ' + export PATH=/usr/local/bin:/opt/node/bin:/usr/bin:/bin CARGO_HOME=/tmp/cargo RUSTUP_HOME=/tmp/rustup + git config --global --add safe.directory /source + ldd --version | head -1 | grep -F "2.34" + rust_version=$(sed -n '\''s/^channel = "\([^"]*\)"/\1/p'\'' rust-toolchain.toml) + rustup toolchain install "$rust_version" --profile minimal + rustup target add --toolchain "$rust_version" "$2" + rustup run "$rust_version" cargo build --package "$1" --release --target "$2" --locked + ' phase1 "$build_package" "$build_target" +else + if [[ "$build_target" == x86_64-unknown-linux-musl ]]; then + sudo apt-get update + sudo apt-get install --yes --no-install-recommends musl=1.2.4-2 musl-dev=1.2.4-2 musl-tools=1.2.4-2 + export REALGCC=gcc-13 CC_x86_64_unknown_linux_musl=musl-gcc CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS='-C target-feature=+crt-static' + musl-gcc -dumpfullversion + fi + cargo build --package "$build_package" --release --target "$build_target" --locked +fi cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset" node scripts/release/phase1.mjs check "release-assets/$release_asset" +node scripts/release/phase1.mjs smoke "release-assets/$release_asset" "$build_target" # Pin and checksum the generator source before extraction or execution. Cargo's # --locked installation authenticates its transitive crates via its shipped lock. tool_root="$RUNNER_TEMP/phase1-cyclonedx" @@ -73,9 +112,12 @@ node scripts/release/phase1.mjs merge \ "release-assets/$release_asset" "$tool_root/combined.cdx.json" # Convert the internal dependency graph into real SPDX 2.3 before publication. case "$build_target" in - wasm32-wasip1|x86_64-unknown-linux-gnu) + wasm32-wasip1|x86_64-unknown-linux-gnu|x86_64-unknown-linux-musl) converter_asset=cyclonedx-linux-x64 converter_sha=bfc8b2538da86fe239bc53658bbb63c1c8c510a293c1e6891aa5bea5d3c58746 ;; + aarch64-unknown-linux-gnu) + converter_asset=cyclonedx-linux-arm64 + converter_sha=b2e9fdf9665ef49868a2ec012171c6e785dcd69745bc5869e53e4f4bfb096a5f ;; aarch64-apple-darwin) converter_asset=cyclonedx-osx-arm64 converter_sha=750c148780154833f6401f9067d08c5a4c31567b6ee3c26c062c3a95c62d741c ;; diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs index 2abd2587ce..31c263f783 100644 --- a/scripts/release/phase1.mjs +++ b/scripts/release/phase1.mjs @@ -1,9 +1,37 @@ /** Small Phase 1 build-inventory and configuration checks, without release authority. */ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { createServer } from 'node:net' +import { once } from 'node:events' import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +/** Check minimal wasm headers and final Linux loader requirements. */ +export function checkReleaseFormat(bytes, target, elf = '') { + if (target === 'wasm32-wasip1') { + if (!bytes.subarray(0, 8).equals(Buffer.from('0061736d01000000', 'hex'))) + throw Error('Invalid wasm magic or version') + } else if (target.includes('-linux-')) { + if (!bytes.subarray(0, 4).equals(Buffer.from('7f454c46', 'hex'))) + throw Error('Expected ELF binary') + if (target.endsWith('-musl')) { + if (/INTERP|\(NEEDED\)/.test(elf)) + throw Error('Expected fully static musl binary') + } else { + if (/GLIBC_[A-Z]/.test(elf)) throw Error('Unknown GLIBC requirement') + for (const [, major, minor, patch] of elf.matchAll( + /GLIBC_(\d+)\.(\d+)(?:\.(\d+))?/g + )) + if ( + +major > 2 || + (+major === 2 && (+minor > 34 || (+minor === 34 && +patch > 0))) + ) + throw Error('GLIBC requirement exceeds 2.34') + } + } +} + /** Complete converter output with the actual binary's SPDX package. */ export function completeSpdx(document, binary, original) { if ( @@ -229,6 +257,195 @@ function audit(root) { } } +async function smokeRelease(file, target) { + const elf = target.includes('-linux-') + ? execFileSync( + 'readelf', + ['--program-headers', '--dynamic', '--version-info', file], + { encoding: 'utf8', maxBuffer: 8388608 } + ) + : '' + checkReleaseFormat(readFileSync(file), target, elf) + console.log( + 'SUCCESS: final-byte checks', + target, + [...new Set(elf.match(/GLIBC_[\w.]+/g) ?? [])].join(' ') + ) + if (target === 'wasm32-wasip1') return + const cli = file.split('/').at(-1).startsWith('ts-') + const expected = + 'ts ' + + readFileSync('Cargo.toml', 'utf8').match( + /\[workspace\.package\][\s\S]*?^version = "([^"]+)"/m + )[1] + const version = (output) => { + if (output.trim() !== expected) throw Error('Unexpected CLI version') + console.log('SUCCESS:', output.trim()) + } + if (cli) + version( + execFileSync(resolve(file), ['--version'], { + encoding: 'utf8', + timeout: 10000, + }) + ) + if (!target.includes('-linux-')) return + const docker = (args, input) => + execFileSync('docker', args, { + input, + encoding: 'utf8', + timeout: args[0] === 'pull' ? 180000 : 10000, + maxBuffer: 8388608, + }) + const images = target.endsWith('-musl') + ? [ + 'alpine:3.22.2@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412', + 'scratch', + ] + : target.startsWith('aarch64-') + ? ['debian:12-slim'] + : [ + 'debian:12-slim', + 'ubuntu:22.04', + 'registry.access.redhat.com/ubi9/ubi-minimal:9.6', + ] + for (const reference of images) { + let image + if (reference === 'scratch') + image = docker(['import', '-'], Buffer.alloc(1024)).trim() + else { + docker(['pull', reference]) + image = docker([ + 'image', + 'inspect', + '--format', + '{{.Id}}', + reference, + ]).trim() + } + console.log('Runtime image:', reference, image) + const restrictions = [ + '--read-only', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--user=65534:65534', + '--entrypoint=/release', + '--mount', + `type=bind,src=${resolve(file)},dst=/release,readonly`, + ] + const container = 'phase1-smoke-' + randomUUID() + let failed + try { + if (cli) { + version( + docker([ + 'run', + '--name', + container, + '--network=none', + ...restrictions, + image, + '--version', + ]) + ) + continue + } + const reservation = createServer().listen(0, '127.0.0.1') + await once(reservation, 'listening') + const port = reservation.address().port + await new Promise((resolve) => reservation.close(resolve)) + // Fixed ASCII fixture keys are sorted for the envelope's canonical hash. + const data = { + ec: { passphrase: 'ec_passphrase' }, + handlers: [ + { + password: 'handler_password', + path: '^/_ts/admin', + username: 'smoke', + }, + ], + publisher: { + cookie_domain: '127.0.0.1', + domain: '127.0.0.1', + origin_url: 'http://127.0.0.1:9', + proxy_secret: 'publisher_proxy_secret', + }, + } + const envelope = { + data, + generated_at: '2026-01-01T00:00:00Z', + sha256: createHash('sha256').update(JSON.stringify(data)).digest('hex'), + version: 1, + } + const environment = [ + `PORT=${port}`, + `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=${JSON.stringify(envelope)}`, + 'TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE=smoke-edge-cookie-passphrase-32-bytes', + 'TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD=smoke-admin-password-32-bytes-ok', + 'TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET=smoke-publisher-proxy-secret-32-bytes', + ] + docker([ + 'run', + '--name', + container, + '-d', + '--network=host', + ...restrictions, + ...environment.flatMap((value) => ['--env', value]), + image, + ]) + let healthy = false + for (let attempt = 0; attempt < 30; attempt++) { + if ( + docker([ + 'inspect', + '--format', + '{{.State.Running}}', + container, + ]).trim() !== 'true' + ) + throw Error('Axum exited before readiness') + let response + try { + response = await fetch(`http://127.0.0.1:${port}/health`, { + signal: AbortSignal.timeout(1000), + }) + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)) + continue + } + if (response.status !== 200 || (await response.text()) !== 'ok') + throw Error('Axum configuration or health mismatch') + healthy = true + break + } + if (!healthy) throw Error('Axum startup deadline exhausted') + console.log( + 'SUCCESS: configured Axum health returned 200 ok:', + reference, + image + ) + } catch (error) { + failed = error + try { + console.error(docker(['logs', container]).slice(-4000)) + } catch {} + throw error + } finally { + try { + docker(['rm', '-f', container]) + } catch (error) { + if ( + !error.stderr?.toString().includes(`No such container: ${container}`) + ) { + if (!failed) throw error + console.error('Smoke container cleanup failed:', error.message) + } + } + } + } +} + if ( process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href @@ -242,6 +459,8 @@ if ( throw new Error('Expected regular release binary') checkBinary(readFileSync(file)) } + } else if (command === 'smoke' && args.length === 2) { + await smokeRelease(...args) } else if (command === 'merge' && args.length === 4) { const [cargo, npm, binary, output] = args writeFileSync( diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index e74cda54c5..e53e05705d 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' -import { completeSpdx, validateSpdx } from './phase1.mjs' +import { execFileSync } from 'node:child_process' +import { completeSpdx, validateSpdx, checkReleaseFormat } from './phase1.mjs' import { test } from 'node:test' import { readFileSync, @@ -121,6 +122,10 @@ test('workflow signs only tags, separates privileged jobs, and never checks out } }) const assets = [ + 'trusted-server-axum-x86_64-unknown-linux-gnu', + 'trusted-server-axum-x86_64-unknown-linux-musl', + 'trusted-server-axum-aarch64-unknown-linux-gnu', + 'trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', @@ -221,8 +226,17 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an return mode === 'api-error' ? response('denied', 503) : response(mode === 'draft' ? [release(true)] : []) - if (u.pathname.endsWith('/releases') && method === 'POST') + if (u.pathname.endsWith('/releases') && method === 'POST') { + const body = JSON.parse(options.body).body + assert.match(body, /--signer-workflow.*release-build\.yml/) + assert.match(body, /--cert-identity.*release-build\.yml@refs\/tags/) + assert.ok(body.includes('COMMIT=' + 'a'.repeat(40))) + for (const asset of assets) assert.ok(body.includes(asset)) + execFileSync('bash', ['-n'], { + input: body.match(/```bash\n([\s\S]*?)\n```/)[1], + }) return response(release(true), 201) + } if (u.pathname.endsWith('/assets') && method === 'POST') { if (mode === 'upload-error') return response('upload failed', 500) const bytes = Buffer.from(options.body) @@ -302,8 +316,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an if (['upload-error'].includes(mode)) assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) if (['success', 'idle-socket'].includes(mode)) { - assert.equal(uploaded.length, 17) - assert.equal(calls.filter(([method]) => method === 'gh').length, 18) + assert.equal(uploaded.length, 29) + assert.equal(calls.filter(([method]) => method === 'gh').length, 30) } } finally { rmSync(root, { recursive: true, force: true }) @@ -466,3 +480,29 @@ test('post-publish retries distinguish transient failures and cap Retry-After', assert.ok(logs.every((line) => /attempt [23]\/3/.test(line))) } }) + +test('rejects invalid wasm, GNU symbols above 2.34 and dynamic musl', () => { + const wasm = Buffer.from('0061736d01000000', 'hex') + checkReleaseFormat(wasm, 'wasm32-wasip1') + for (const bytes of [ + wasm.subarray(0, 7), + Buffer.alloc(8), + Buffer.from('0061736d02000000', 'hex'), + ]) + assert.throws(() => checkReleaseFormat(bytes, 'wasm32-wasip1'), /wasm/) + const elf = Buffer.from('7f454c46', 'hex') + const gnu = 'x86_64-unknown-linux-gnu', + musl = 'x86_64-unknown-linux-musl' + checkReleaseFormat(elf, gnu, 'GLIBC_2.2.5 GLIBC_2.34') + for (const versions of [ + 'GLIBC_2.35', + 'GLIBC_3.0', + 'GLIBC_2.34.1', + 'GLIBC_ABI_DT_RELR', + ]) + assert.throws(() => checkReleaseFormat(elf, gnu, versions), /GLIBC/) + checkReleaseFormat(elf, musl, 'There is no dynamic section') + for (const dynamic of ['INTERP', '(NEEDED) libgcc_s.so.1']) + assert.throws(() => checkReleaseFormat(elf, musl, dynamic), /static/) + assert.throws(() => checkReleaseFormat(wasm, gnu, 'GLIBC_2.34'), /ELF/) +}) diff --git a/scripts/release/release-cohort.json b/scripts/release/release-cohort.json index be477ccbb3..d66e0b3f09 100644 --- a/scripts/release/release-cohort.json +++ b/scripts/release/release-cohort.json @@ -71,6 +71,60 @@ "role": "operator-cli", "target": "x86_64-unknown-linux-gnu", "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "trusted-server-axum", + "capabilities": ["axum-production"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "trusted-server-axum-x86_64-unknown-linux-gnu", + "package": "trusted-server-adapter-axum", + "role": "server", + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "trusted-server-axum", + "capabilities": ["axum-production"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "static", + "runtime": "linux-musl" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "trusted-server-axum-x86_64-unknown-linux-musl", + "package": "trusted-server-adapter-axum", + "role": "server", + "target": "x86_64-unknown-linux-musl", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "trusted-server-axum", + "capabilities": ["axum-production"], + "compatibility": { + "architecture": "aarch64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "trusted-server-axum-aarch64-unknown-linux-gnu", + "package": "trusted-server-adapter-axum", + "role": "server", + "target": "aarch64-unknown-linux-gnu", + "variant": "default" } ], "cohort": "phase1-fastly-cli", From b818b599e8b54ee5316db70ae6fb749574d6c7f5 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 23:23:38 +0530 Subject: [PATCH 013/104] Disable rustup self-updates in temporary build homes --- scripts/release/phase1-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index 27956bca92..a3fa3e2fbb 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -70,7 +70,7 @@ DOCKERFILE git config --global --add safe.directory /source ldd --version | head -1 | grep -F "2.34" rust_version=$(sed -n '\''s/^channel = "\([^"]*\)"/\1/p'\'' rust-toolchain.toml) - rustup toolchain install "$rust_version" --profile minimal + rustup toolchain install "$rust_version" --profile minimal --no-self-update rustup target add --toolchain "$rust_version" "$2" rustup run "$rust_version" cargo build --package "$1" --release --target "$2" --locked ' phase1 "$build_package" "$build_target" From 16d6690c65a82654ea2d1e0f13e44e5ca4e702e0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 23:35:56 +0530 Subject: [PATCH 014/104] Expose pinned compiler binaries in the GNU builder --- scripts/release/phase1-build.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index a3fa3e2fbb..3536f2595b 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -72,6 +72,7 @@ DOCKERFILE rust_version=$(sed -n '\''s/^channel = "\([^"]*\)"/\1/p'\'' rust-toolchain.toml) rustup toolchain install "$rust_version" --profile minimal --no-self-update rustup target add --toolchain "$rust_version" "$2" + export PATH="$(dirname "$(rustup which --toolchain "$rust_version" rustc)"):$PATH" rustup run "$rust_version" cargo build --package "$1" --release --target "$2" --locked ' phase1 "$build_package" "$build_target" else From 937214a632f9387601dba94f996de78b2e6ee424 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 08:17:12 +0530 Subject: [PATCH 015/104] Supply runtime trust data to Axum startup checks --- docs/guide/release-verification.md | 9 +++++++-- .../binary-attestations-phase1-compatibility.md | 7 ++++++- scripts/release/phase1.mjs | 13 +++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index c3d9d822e7..8ede827fe1 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -21,7 +21,7 @@ and signing jobs do not check out, compile or execute the downloaded binaries. | `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 15.0; runtime compatibility qualification remains pending | | `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 15.0; runtime compatibility qualification remains pending | | `trusted-server-axum-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 build; maximum glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | -| `trusted-server-axum-x86_64-unknown-linux-musl` | Fully static x86-64; musl 1.2.4 build; pinned Alpine and empty-rootfs startup smokes | +| `trusted-server-axum-x86_64-unknown-linux-musl` | Fully static x86-64; musl-tools 1.2.4-2; pinned Alpine and scratch-like startup smokes with runtime CA data | | `trusted-server-axum-aarch64-unknown-linux-gnu` | ARM64 GNU; native ARM UBI 9 build; maximum glibc 2.34; Debian 12 startup smoke | The retained destination design calls for a macOS 13.0 deployment target; @@ -31,10 +31,15 @@ The executables are not notarized. GNU builds require no GLIBC symbol newer than the selected baseline. This includes Debian 12/bookworm-slim, Ubuntu 22.04+ and RHEL/UBI 9+. Distroless cc-debian12 has a compatible libc floor but is not a Phase 1 smoke image. -The musl asset needs no dynamic loader or libraries; Alpine and an empty-rootfs +The musl asset needs no dynamic loader or libraries; Alpine and a scratch-like container are required smoke images, while distroless-static is another deployment option without a separate Phase 1 runtime test. Build logs record acquired runtime image IDs. These required startup checks do not establish complete platform/tool qualification. +Axum's TLS client requires a runtime CA certificate bundle, including for health +request initialization. Every Axum smoke mounts the runner's system CA +bundle read-only at `/ca.pem` with `SSL_CERT_FILE=/ca.pem`; production operators +must supply their own runtime trust store. This is runtime data, not an embedded +configuration or dynamically linked library. The expanded seven-binary batch awaits hosted CI and rehearsal `.5`; `.4` proves only the original four binaries. Native libraries and provider tools needed by individual `ts` commands retain their diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index 5f65df03c5..d8b34e23ed 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -165,7 +165,12 @@ unchanged. Before upload they check wasm magic/version, each CLI's existing `ts 0.1.0` package-version output, GNU symbol ceilings and static-musl loader requirements. GNU x86 binaries start on Debian 12-slim, Ubuntu 22.04 and UBI 9.6 minimal; ARM Axum starts on Debian 12-slim on the ARM runner; musl starts on -pinned Alpine and an empty root filesystem. Acquired builder/runtime image IDs +pinned Alpine and a scratch-like root filesystem with a read-only runtime CA +bundle. The locked adapter initializes a TLS client before dispatching requests, +so an entirely empty trust store fails even a health request. Every Axum smoke mounts +the runner's system certificates at `/ca.pem` and sets `SSL_CERT_FILE`; operators +must supply runtime trust data independently. No dynamic libraries are added. +Acquired builder/runtime image IDs are logged. Axum has no help/version mode; its isolated, read-only, nonroot container receives only a fictional runtime envelope/secrets and must return `200 ok` from its configured health router. This does not establish publisher diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs index 31c263f783..34d6209fca 100644 --- a/scripts/release/phase1.mjs +++ b/scripts/release/phase1.mjs @@ -333,6 +333,12 @@ async function smokeRelease(file, target) { '--mount', `type=bind,src=${resolve(file)},dst=/release,readonly`, ] + if (!cli) + restrictions.push( + '--mount', + 'type=bind,src=/etc/ssl/certs/ca-certificates.crt,dst=/ca.pem,readonly', + '--env=SSL_CERT_FILE=/ca.pem' + ) const container = 'phase1-smoke-' + randomUUID() let failed try { @@ -414,8 +420,11 @@ async function smokeRelease(file, target) { await new Promise((resolve) => setTimeout(resolve, 100)) continue } - if (response.status !== 200 || (await response.text()) !== 'ok') - throw Error('Axum configuration or health mismatch') + const body = await response.text() + if (response.status !== 200 || body !== 'ok') + throw Error( + `Axum health mismatch: HTTP ${response.status}: ${body.slice(0, 500)}` + ) healthy = true break } From 6824226fa43cc0bc34d78220695b0db7391d8d23 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 09:57:32 +0530 Subject: [PATCH 016/104] Record the seven-binary release rehearsal --- docs/guide/release-verification.md | 8 ++--- ...6-10-13-attested-release-implementation.md | 23 ++++++------ ...inary-attestations-phase1-compatibility.md | 35 +++++++++++++------ ...6-10-09-1235-release-attestation-design.md | 5 +-- 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 8ede827fe1..ba45401277 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -40,8 +40,8 @@ request initialization. Every Axum smoke mounts the runner's system CA bundle read-only at `/ca.pem` with `SSL_CERT_FILE=/ca.pem`; production operators must supply their own runtime trust store. This is runtime data, not an embedded configuration or dynamically linked library. -The expanded seven-binary batch awaits hosted CI and rehearsal `.5`; `.4` -proves only the original four binaries. Native +The seven-binary implementation passed hosted CI and [rehearsal `.5`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438) +at `937214a632f9387601dba94f996de78b2e6ee424`; `.4` proves only the original four binaries. Native libraries and provider tools needed by individual `ts` commands retain their existing prerequisites. Cloudflare, Spin, Windows and Linux ARM64 CLI releases follow separately. @@ -312,9 +312,9 @@ EdgeZero prepared-payload deployment and operation recovery are later phases. ### Local runtime evidence -The [rehearsal runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668) +The [rehearsal runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357352104) records local execution of the verified Fastly wasm at -`922c2211c0cc7941bfc8ffa9f775ef8ac0971232` using pinned Viceroy 0.21.0. +`937214a632f9387601dba94f996de78b2e6ee424` using pinned Viceroy 0.21.0. It uses the example configuration with loopback publisher/origin values, public development secrets and a local Node origin fixture. A real page request returned HTTP 200, the fixture marker, injected JS and the full source SHA in diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 978d9447cb..4ef8715c6a 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -17,17 +17,20 @@ The cohort records this explicit extension, but tools/policy and the frozen v1 index schema remain unchanged. Phase 1 emits no index. The later index/consumer slices need a reviewed seven-artifact amendment, including new target/capability forms; see the [compatibility audit](binary-attestations-phase1-compatibility.md) -and slice-map annotations. Rehearsal `.4` covers the original four assets only; -the expanded implementation requires rehearsal `.5`. +and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementation at +`937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. +Rehearsal `.4` remains historical evidence for the original four assets only. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. -Rehearsal `.4` passed at exact implementation SHA -`922c2211c0cc7941bfc8ffa9f775ef8ac0971232`; see the -[full online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346483024) -and [offline/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668). -Rehearsal `.4` covers that four-binary implementation only; the seven-binary -changes require new CI and rehearsal evidence. +Rehearsal `.5` passed at exact implementation SHA +`937214a632f9387601dba94f996de78b2e6ee424`; see the +[full online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438), +[offline proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357351810) +and [local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357352104). +All seven builds/signing jobs, 29 immutable release assets, 28 checksum entries, +both predicates/signer checks, seven SPDX comparisons and startup smokes passed. +Later documentation-only heads retain this exact rehearsed implementation SHA. ### Phase 1 implementation checklist @@ -41,8 +44,8 @@ changes require new CI and rehearsal evidence. | #1235 acceptance item | Phase 1 evidence | | ----------------------------------------------------- | -------------------------------------------------------------------------------- | | Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | -| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.4` passed | -| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.4` verification passed | +| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.5` passed | +| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.5` verification passed | | Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | | Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | | Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index d8b34e23ed..c75fbe8fa7 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -1,6 +1,6 @@ # Phase 1 compatibility audit and approved alignment -**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Rehearsal `.4` passed at `922c2211c`; the rehearsal history below records earlier outcomes. +**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Rehearsal `.5` passed at `937214a632f9387601dba94f996de78b2e6ee424`; the rehearsal history below records earlier outcomes. **Conclusion:** the original Phase 1 signer path and CycloneDX public format would have required a later breaking policy replacement. Fix those now. Phase 1 is the first implementation slice of the existing contract: retain its public names, signer/caller distinction, SPDX semantics and original policy files. Introduce no competing release-index schema or weakened consumer policy. @@ -18,13 +18,14 @@ unchanged; the phasing sections describe this slice and its rehearsal outcome. T Phase 1 audit baseline is `4dd217125c6e38055e4bcb2cba32c0b95e739914`; the following corrections are implemented in this slice, with verification and rehearsal tracked in the PR description at its current commit. Signing and -immutable publication rehearsal `.4` passed at `922c2211c`; see the -[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346483024). +immutable publication rehearsal `.5` passed at `937214a632f9387601dba94f996de78b2e6ee424`; see the +[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438). The current correction aligns the local input-free `release-build.yml` signer -with the `release.yml` caller/publisher, real SPDX 2.3 documents, all eight -named bundles, the existing four binary names and `SHA256SUMS`: 17 assets. -Provenance covers each real binary and SPDX document; no missing evidence +with the `release.yml` caller/publisher, real SPDX 2.3 documents, fourteen +named bundles, seven binary names and `SHA256SUMS`: 29 assets. Each binary has +provenance and an SPDX predicate attestation; each signed SPDX predicate matches +its published companion. No missing evidence subjects, index, inventory, recipe or rebuild results are fabricated. Exact artifact selection and signing-approved digest checks replace wildcard merging. The publisher lists all releases including drafts, refuses a matching tag, @@ -36,7 +37,7 @@ three descriptive Axum entries below; all three policies still have **no Phase 1 readers**. Checked cohort selection, a supplemental tool policy, environment normalization/remapping, stricter tag/source validation, uploaded-byte re-download, macOS 13 targeting, full platform qualification and CLI immutable build facts -are additive later work. The next batch adds UBI GNU glibc 2.34 checks and +are additive later work. This slice includes UBI GNU glibc 2.34 checks and startup smokes, while retaining the macOS 15.0 deployment target; copying a destination policy alone never establishes its qualification requirements. Native reproduction is a later measurement, not a Phase 1 release gate. Full Task 16 resume/reconciliation remains deferred. @@ -117,11 +118,11 @@ qualified evidence. ### Approved seven-binary Axum extension -The next implementation batch deliberately extends the frozen cohort from four +This implementation extends the frozen cohort from four to **seven binaries / 29 release files**, retaining all four original names. Rehearsal `.4` at `922c2211c` proves only the original four-binary/17-file -implementation. The seven-binary batch requires fresh rehearsal `.5`; it is not -claimed rehearsed by the `.4` evidence. +implementation. Rehearsal `.5` passed for the seven-binary implementation at +`937214a632f9387601dba94f996de78b2e6ee424`; the `.4` evidence is not reused for the expanded cohort. The exact cohort diff is three appended records, all default features, empty extra features, `variant: default`, `assurance_profile: provenance-sbom`, @@ -214,9 +215,21 @@ valid. and [offline/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6346428668). Bounded read-only retries were present, but no transient retry was observed. +- `v0.0.0-rehearsal.5` at `937214a632f9387601dba94f996de78b2e6ee424`: + all seven builds/signing jobs, immutable publication and post-publish checks + passed. Independent GitHub CLI 2.102.0 verification passed all 29 asset checks, + 28 checksum entries, both predicates and signer checks for all seven binaries, + and seven SPDX predicate comparisons. Offline verification passed under + OS-enforced network denial; Viceroy 0.21.0 served a configured page from the + verified wasm with the exact source header. GNU runtime image checks, native + ARM startup and static-musl Alpine/scratch-like checks passed with explicit + runtime CA data. See the [online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438), + [offline proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357351810) and + [build/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357352104). + The exact rehearsed implementation SHA above is retained even when later commits change documentation only. This documentation update does not change workflows, scripts, tests or release bytes. -The immutable `.2`, `.3` and `.4` releases remain intact; no drafts remain from +The immutable `.2`, `.3`, `.4` and `.5` releases remain intact; no drafts remain from these rehearsals. No upstream tags, releases or repository settings were changed. diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 28574247dc..34df724e33 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -80,8 +80,9 @@ The cohort records this explicit extension, but tools/policy and the frozen v1 index schema remain unchanged. Phase 1 emits no index. The later index/consumer slices need a reviewed seven-artifact amendment, including new target/capability forms; see the [compatibility audit](../plans/binary-attestations-phase1-compatibility.md) -and slice-map annotations. Rehearsal `.4` covers the original four assets only; -the expanded implementation requires rehearsal `.5`. +and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementation at +`937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. +Rehearsal `.4` remains historical evidence for the original four assets only. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. From e907e2637689e339720691da473479d5909132fb Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 11:38:18 +0530 Subject: [PATCH 017/104] Derive immutable release build facts --- crates/trusted-server-adapter-fastly/build.rs | 26 ++ .../trusted-server-adapter-fastly/src/main.rs | 77 +++++- crates/trusted-server-cli/build.rs | 31 +++ crates/trusted-server-cli/src/lib.rs | 2 +- crates/trusted-server-cli/src/run.rs | 21 +- .../tests/release_build_facts.rs | 261 ++++++++++++++++++ crates/trusted-server-core/build.rs | 41 +-- .../build_support/git_version.rs | 56 ++++ crates/trusted-server-core/src/build_info.rs | 101 +++++++ crates/trusted-server-core/src/constants.rs | 5 +- crates/trusted-server-core/src/lib.rs | 1 + .../tests/git_version_resolve.rs | 113 ++++++++ 12 files changed, 709 insertions(+), 26 deletions(-) create mode 100644 crates/trusted-server-adapter-fastly/build.rs create mode 100644 crates/trusted-server-cli/build.rs create mode 100644 crates/trusted-server-cli/tests/release_build_facts.rs create mode 100644 crates/trusted-server-core/src/build_info.rs diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs new file mode 100644 index 0000000000..0889fe217b --- /dev/null +++ b/crates/trusted-server-adapter-fastly/build.rs @@ -0,0 +1,26 @@ +//! Compile immutable facts owned by the Fastly product. + +#![allow( + clippy::print_stdout, + reason = "Cargo build script directives use stdout" +)] + +#[allow( + dead_code, + reason = "This owner uses only release validation from the shared resolver" +)] +#[path = "../trusted-server-core/build_support/git_version.rs"] +mod git_version; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../trusted-server-core/build_support/git_version.rs"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION"); + let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION"); + git_version::release_source(control.as_deref(), source.as_deref()) + .expect("should provide valid release build inputs"); + let target = std::env::var("TARGET").expect("should set the owning Cargo target"); + println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}"); +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b8f814a01c..c3e8090f43 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -11,6 +11,7 @@ use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; +use trusted_server_core::build_info::{BuildInfo, BuildVariant}; use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive}; use trusted_server_core::constants::HEADER_X_TS_VERSION; use trusted_server_core::ec::device::DeviceSignals; @@ -56,6 +57,17 @@ use crate::sandbox::{RetainedApp, Sandbox, SandboxCounters, ServeMode, StartupDi #[cfg(feature = "reusable-sandbox")] use crate::sandbox::RetirementCounters; +/// Immutable facts compiled into the owning Fastly product. +pub const BUILD_INFO: BuildInfo = BuildInfo::new( + env!("CARGO_PKG_VERSION"), + env!("TRUSTED_SERVER__BUILD_TARGET"), + if cfg!(feature = "reusable-sandbox") { + BuildVariant::ReusableSandbox + } else { + BuildVariant::Default + }, +); + /// Opens the Fastly Config Store used by the `EdgeZero` dispatcher. /// /// # Errors @@ -97,10 +109,7 @@ fn main() { // Held outside the sandbox: `serve_custom` owns the `Sandbox` and exposes // no slot for application state that is not the retained payload. - let mut startup = StartupDiagnostics::default(); - for message in diagnostics { - startup.push(message); - } + let mut startup = startup_diagnostics(diagnostics); match mode { ServeMode::Single => { @@ -116,6 +125,21 @@ fn main() { } } +fn startup_diagnostics(diagnostics: Vec) -> StartupDiagnostics { + let mut startup = StartupDiagnostics::default(); + startup.push(format!( + "Build facts: package={} source={} target={} variant={}", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target(), + BUILD_INFO.variant() + )); + for message in diagnostics { + startup.push(message); + } + startup +} + /// Serves requests from one sandbox under explicit bounds. /// /// Only compiled with the `reusable-sandbox` feature; [`serve_mode`] can never @@ -912,6 +936,51 @@ mod tests { ); } + #[test] + fn fastly_build_info_uses_actual_owning_package_target_source_and_feature() { + assert_eq!( + BUILD_INFO.package_version(), + env!("CARGO_PKG_VERSION"), + "should own Fastly package version" + ); + assert_eq!( + BUILD_INFO.target(), + "wasm32-wasip1", + "should own Fastly Cargo target" + ); + assert_eq!( + BUILD_INFO.source(), + trusted_server_core::constants::TS_GIT_VERSION, + "should read source from core" + ); + let expected = if cfg!(feature = "reusable-sandbox") { + BuildVariant::ReusableSandbox + } else { + BuildVariant::Default + }; + assert_eq!( + BUILD_INFO.variant(), + expected, + "should derive actual Fastly feature variant" + ); + } + + #[test] + fn startup_queues_one_build_fact_message_for_existing_log_flush() { + let startup = startup_diagnostics(Vec::new()); + assert_eq!( + startup.pending(), + 1, + "should queue exactly one build fact message" + ); + let startup = startup_diagnostics(vec!["example diagnostic".to_owned()]); + assert_eq!( + startup.pending(), + 2, + "should preserve existing startup diagnostics" + ); + } + #[test] fn health_response_reports_git_version_only() { let req = FastlyRequest::get("https://example.com/health"); diff --git a/crates/trusted-server-cli/build.rs b/crates/trusted-server-cli/build.rs new file mode 100644 index 0000000000..1c40ab678b --- /dev/null +++ b/crates/trusted-server-cli/build.rs @@ -0,0 +1,31 @@ +//! Compile immutable facts owned by the CLI product. + +#![allow( + clippy::print_stdout, + reason = "Cargo build script directives use stdout" +)] + +#[allow( + dead_code, + reason = "This owner uses only release validation from the shared resolver" +)] +#[path = "../trusted-server-core/build_support/git_version.rs"] +mod git_version; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../trusted-server-core/build_support/git_version.rs"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION"); + println!("cargo:rustc-check-cfg=cfg(trusted_server_release_build)"); + let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION"); + if git_version::release_source(control.as_deref(), source.as_deref()) + .expect("should provide valid release build inputs") + .is_some() + { + println!("cargo:rustc-cfg=trusted_server_release_build"); + } + let target = std::env::var("TARGET").expect("should set the owning Cargo target"); + println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}"); +} diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 24e001d853..080bdb0db5 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -14,7 +14,7 @@ mod tls; mod url_guard; #[cfg(not(target_arch = "wasm32"))] -pub use run::{RunOutcome, run_from_env}; +pub use run::{BUILD_INFO, RunOutcome, run_from_env}; // Every `ts` subcommand's implementation lives under `commands/`. The // `ts dev` group is available on every host target; `ts dev lint` and diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 05b4779f8f..f573c491ee 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -1,10 +1,12 @@ use std::process; +use std::sync::LazyLock; use clap::{Parser, Subcommand}; use edgezero_cli::args::{ ActiveVersionArgs, AuthArgs, BuildArgs, ConfigDiffArgs, ConfigGcArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, HealthcheckArgs, ProvisionArgs, RollbackArgs, ServeArgs, }; +use trusted_server_core::build_info::{BuildInfo, BuildVariant}; use trusted_server_core::config::TrustedServerAppConfig; use crate::commands::audit::{AuditArgs, run_audit}; @@ -13,8 +15,25 @@ use crate::commands::config::init::{ConfigInitArgs, run_config_init}; use crate::commands::pbs::{self, PbsArgs}; use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; +/// Immutable facts compiled into the owning CLI product. +pub const BUILD_INFO: BuildInfo = BuildInfo::new( + env!("CARGO_PKG_VERSION"), + env!("TRUSTED_SERVER__BUILD_TARGET"), + BuildVariant::Default, +); + +static VERSION_TEXT: LazyLock = LazyLock::new(|| { + format!( + "package={} source={} target={} variant={}", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target(), + BUILD_INFO.variant() + ) +}); + #[derive(Debug, Parser)] -#[command(name = "ts", version, about = "Trusted Server CLI")] +#[command(name = "ts", version = VERSION_TEXT.as_str(), about = "Trusted Server CLI")] struct Args { #[command(subcommand)] command: Command, diff --git a/crates/trusted-server-cli/tests/release_build_facts.rs b/crates/trusted-server-cli/tests/release_build_facts.rs new file mode 100644 index 0000000000..f5995e3bdd --- /dev/null +++ b/crates/trusted-server-cli/tests/release_build_facts.rs @@ -0,0 +1,261 @@ +//! Host checks of actual owning build scripts and the compiled CLI binary. + +#![cfg(not(target_arch = "wasm32"))] + +use std::ffi::OsStr; +use std::fs; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::TempDir; +use trusted_server_cli::BUILD_INFO; +use trusted_server_core::build_info::BuildVariant; + +const SOURCE: &str = "abcdef0123456789abcdef0123456789abcdef01"; + +fn workspace() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("should resolve crates") + .parent() + .expect("should resolve workspace") +} + +fn compile_build_script(owner: &str, directory: &TempDir) -> PathBuf { + let output = directory.path().join(format!("{owner}-build")); + let compiled = Command::new("rustc") + .args(["--edition=2024"]) + .arg(workspace().join("crates").join(owner).join("build.rs")) + .arg("-o") + .arg(&output) + .output() + .expect("should run rustc"); + assert!( + compiled.status.success(), + "should compile actual owner script: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + output +} + +fn owner_command(script: &Path) -> Command { + let mut command = Command::new(script); + command + .env("TARGET", "example-target") + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .env_remove("CARGO_FEATURE_REUSABLE_SANDBOX"); + command +} + +#[cfg(unix)] +#[test] +fn actual_owner_scripts_reject_invalid_release_controls_and_sources() { + let directory = tempfile::tempdir().expect("should create fixture"); + for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { + let script = compile_build_script(owner, &directory); + for control in [ + OsStr::new(""), + OsStr::new("0"), + OsStr::new(" 1"), + OsStr::new("1\n"), + OsStr::from_bytes(b"\xff"), + ] { + let output = owner_command(&script) + .env("TRUSTED_SERVER__RELEASE_BUILD", control) + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .output() + .expect("should run actual owner script"); + assert!( + !output.status.success(), + "should reject control for {owner}: {control:?}" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1"), + "should identify malformed release control" + ); + } + for source in [ + None, + Some(OsStr::new("main")), + Some(OsStr::new("v1.2.3")), + Some(OsStr::new("abcdef")), + Some(OsStr::new("ABCDEF0123456789abcdef0123456789abcdef01")), + Some(OsStr::new(" abcdef0123456789abcdef0123456789abcdef01")), + Some(OsStr::new("abcdef0123456789abcdef0123456789abcdef01\n")), + Some(OsStr::from_bytes(b"\xff")), + ] { + let mut command = owner_command(&script); + command.env("TRUSTED_SERVER__RELEASE_BUILD", "1"); + if let Some(source) = source { + command.env("TRUSTED_SERVER__GIT_VERSION", source); + } + let output = command.output().expect("should run actual owner script"); + assert!( + !output.status.success(), + "should reject source for {owner}: {source:?}" + ); + let diagnostic = if source.and_then(OsStr::to_str).is_none() { + "release builds require TRUSTED_SERVER__GIT_VERSION" + } else { + "release source must be exactly 40 lowercase ASCII hexadecimal bytes" + }; + assert!( + String::from_utf8_lossy(&output.stderr).contains(diagnostic), + "should identify malformed release source: {diagnostic}" + ); + } + } +} + +#[test] +fn actual_owner_scripts_emit_only_own_target_and_cli_release_cfg() { + let directory = tempfile::tempdir().expect("should create fixture"); + for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { + let script = compile_build_script(owner, &directory); + let mut outputs = Vec::new(); + for tag in ["v1.2.3", "v1.2.4"] { + let output = owner_command(&script) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .env("GITHUB_REF_NAME", tag) + .env("GITHUB_RUN_ID", tag) + .output() + .expect("should run release owner script"); + assert!( + output.status.success(), + "should accept valid release source" + ); + let directives = + String::from_utf8(output.stdout).expect("should emit UTF-8 directives"); + assert!( + directives.contains("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET=example-target"), + "should emit the target owned by Cargo TARGET" + ); + assert!( + !directives.contains("cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION="), + "should consume the source fact owned by core" + ); + assert!( + !directives.contains("BUILD_VARIANT"), + "should derive variant from owning feature cfg" + ); + if owner == "trusted-server-cli" { + assert!( + directives.contains("cargo:rustc-cfg=trusted_server_release_build"), + "should identify release mode for immutable-fact tests" + ); + } + outputs.push(directives); + } + assert_eq!( + outputs[0], outputs[1], + "should exclude external tags and run IDs from compiled facts" + ); + let output = owner_command(&script) + .output() + .expect("should run development script"); + assert!( + output.status.success(), + "should preserve development builds" + ); + assert!( + !String::from_utf8_lossy(&output.stdout) + .contains("cargo:rustc-cfg=trusted_server_release_build"), + "should retain development build mode" + ); + } +} + +#[test] +fn actual_cli_version_reads_all_owning_facts_and_ignores_external_tags() { + assert_eq!( + BUILD_INFO.package_version(), + env!("CARGO_PKG_VERSION"), + "should own package version" + ); + assert_eq!( + BUILD_INFO.source(), + trusted_server_core::constants::TS_GIT_VERSION, + "should consume the source owned by core" + ); + assert_eq!( + BUILD_INFO.target(), + env!("TRUSTED_SERVER__BUILD_TARGET"), + "should own Cargo target" + ); + assert_eq!( + BUILD_INFO.variant(), + BuildVariant::Default, + "should use CLI default variant" + ); + if cfg!(trusted_server_release_build) { + let source = BUILD_INFO + .source() + .expect("should compile release source into the actual CLI"); + assert_eq!(source.len(), 40, "should preserve complete release source"); + assert!( + source + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "should use lowercase source SHA in release facts" + ); + } + let directory = tempfile::tempdir().expect("should create outside-checkout fixture"); + for tag in ["v1.2.3", "v1.2.4"] { + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .current_dir(directory.path()) + .arg("--version") + .env("GITHUB_REF_NAME", tag) + .env("GITHUB_RUN_ID", tag) + .output() + .expect("should run actual CLI binary"); + assert!( + output.status.success(), + "should print version outside checkout" + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!( + "ts package={} source={} target={} variant=default\n", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target() + ), + "should label all four actual immutable facts" + ); + } +} + +#[test] +fn help_and_config_init_work_outside_the_checkout() { + let directory = tempfile::tempdir().expect("should create outside-checkout fixture"); + let binary = env!("CARGO_BIN_EXE_ts"); + let output = Command::new(binary) + .current_dir(directory.path()) + .arg("--help") + .output() + .expect("should run portable CLI"); + assert!( + output.status.success(), + "should print help outside checkout" + ); + let output = Command::new(binary) + .current_dir(directory.path()) + .args(["config", "init"]) + .output() + .expect("should initialize scaffold outside checkout"); + assert!( + output.status.success(), + "should initialize embedded example config" + ); + assert_eq!( + fs::read_to_string(directory.path().join("trusted-server.toml")) + .expect("should read config scaffold"), + include_str!("../../../trusted-server.example.toml"), + "should use tracked fictional scaffold" + ); +} diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index db9c31d27c..552e5bb116 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use edgezero_core::manifest::ManifestLoader; -use git_version::{Candidates, is_usable, resolve_git_version}; +use git_version::{Candidates, is_usable, resolve_build_source, resolve_git_version}; use sha2::{Digest as _, Sha256}; /// Crate-relative build inputs that are both watched and hashed besides `src/`. @@ -160,24 +160,29 @@ fn main() { fn emit_git_version() { println!("cargo:rerun-if-env-changed={OVERRIDE_ENV}"); - let override_value = std::env::var(OVERRIDE_ENV).ok(); - let resolved = match override_value.as_deref() { - Some(value) if is_usable(value) => resolve_git_version(&Candidates { - override_value: Some(value), - exact_tag: None, - branch: None, - commit: None, - }), - Some(value) => { - if !value.trim().is_empty() { - println!( - "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ - falling back to local git for x-ts-version" - ); - } - resolve_from_local_git() + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + let control = env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = env::var_os(OVERRIDE_ENV); + if control.is_none() + && let Some(value) = source.as_deref().and_then(std::ffi::OsStr::to_str) + && !is_usable(value) + && !value.trim().is_empty() + { + println!( + "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ + falling back to local git for x-ts-version" + ); + } + let resolved = match resolve_build_source( + control.as_deref(), + source.as_deref(), + resolve_from_local_git, + ) { + Ok(source) => source, + Err(message) => { + println!("cargo::error={message}"); + std::process::exit(1); } - None => resolve_from_local_git(), }; // Always emitted, even when empty, so the raw pipeline value never reaches diff --git a/crates/trusted-server-core/build_support/git_version.rs b/crates/trusted-server-core/build_support/git_version.rs index f4cf2d3688..36f426372a 100644 --- a/crates/trusted-server-core/build_support/git_version.rs +++ b/crates/trusted-server-core/build_support/git_version.rs @@ -3,6 +3,62 @@ //! Shared by `build.rs` and `tests/git_version_resolve.rs` via `#[path]`, so it //! must stay dependency-free. +use std::ffi::OsStr; + +/// Returns the exact source commit in release mode, or `None` in development. +/// +/// # Errors +/// +/// Rejects any present release control except `1`, and release sources other +/// than exactly 40 lowercase ASCII hexadecimal bytes. +pub fn release_source( + control: Option<&OsStr>, + source: Option<&OsStr>, +) -> Result, &'static str> { + match control { + None => return Ok(None), + Some(value) if value == OsStr::new("1") => {} + Some(_) => return Err("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1 when present"), + } + let source = source + .and_then(OsStr::to_str) + .ok_or("release builds require TRUSTED_SERVER__GIT_VERSION as a full lowercase commit")?; + if source.len() != 40 + || !source + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("release source must be exactly 40 lowercase ASCII hexadecimal bytes"); + } + Ok(Some(source.to_owned())) +} + +/// Resolve the actual build source, calling local Git only in development. +/// +/// # Errors +/// +/// Returns release input errors before invoking the fallback. +pub fn resolve_build_source( + control: Option<&OsStr>, + source: Option<&OsStr>, + local_git: impl FnOnce() -> Option, +) -> Result, &'static str> { + if let Some(commit) = release_source(control, source)? { + return Ok(Some(commit)); + } + let override_value = source.and_then(OsStr::to_str); + if override_value.is_some_and(is_usable) { + Ok(resolve_git_version(&Candidates { + override_value, + exact_tag: None, + branch: None, + commit: None, + })) + } else { + Ok(local_git()) + } +} + /// Raw candidates for the deployed git version, in priority order. pub struct Candidates<'a> { /// `TRUSTED_SERVER__GIT_VERSION`, supplied by the deploy pipeline. diff --git a/crates/trusted-server-core/src/build_info.rs b/crates/trusted-server-core/src/build_info.rs new file mode 100644 index 0000000000..2809ded936 --- /dev/null +++ b/crates/trusted-server-core/src/build_info.rs @@ -0,0 +1,101 @@ +//! Immutable facts compiled into an owning product. +//! +//! These facts identify build inputs. They do not establish release provenance +//! or publishing authority; release tags, run IDs and artifact checksums remain +//! external to the compiled product. + +use crate::constants::TS_GIT_VERSION; + +/// The owning product's compile-time feature variant. +#[derive(Debug, Clone, Copy, Eq, PartialEq, derive_more::Display)] +pub enum BuildVariant { + /// The product's default feature set. + #[display("default")] + Default, + /// Fastly's opt-in reusable sandbox feature. + #[display("reusable-sandbox")] + ReusableSandbox, +} + +/// Immutable package, source, target and variant facts for an owning product. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct BuildInfo { + package_version: &'static str, + source: Option<&'static str>, + target: &'static str, + variant: BuildVariant, +} + +impl BuildInfo { + /// Defines owning product facts using the source compiled into core. + /// + /// Call this in an owning constant with Cargo's package version, its build + /// script's target and its own feature variant. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::build_info::{BuildInfo, BuildVariant}; + /// const FACTS: BuildInfo = BuildInfo::new("1.2.3", "example-target", BuildVariant::Default); + /// assert_eq!(FACTS.package_version(), "1.2.3"); + /// ``` + #[must_use] + pub const fn new( + package_version: &'static str, + target: &'static str, + variant: BuildVariant, + ) -> Self { + Self { + package_version, + source: TS_GIT_VERSION, + target, + variant, + } + } + + /// Returns the owning product's Cargo package version. + #[must_use] + pub const fn package_version(&self) -> &'static str { + self.package_version + } + + /// Returns the compiled source identity, when known. + /// + /// Release builds require a complete commit. Development builds retain + /// the existing tag, branch and abbreviated commit resolution. + #[must_use] + pub const fn source(&self) -> Option<&'static str> { + self.source + } + + /// Returns the owning product's Cargo target triple. + #[must_use] + pub const fn target(&self) -> &'static str { + self.target + } + + /// Returns the owning product's feature variant. + #[must_use] + pub const fn variant(&self) -> BuildVariant { + self.variant + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variants_display_canonical_product_labels() { + assert_eq!( + BuildVariant::Default.to_string(), + "default", + "should label the default product variant" + ); + assert_eq!( + BuildVariant::ReusableSandbox.to_string(), + "reusable-sandbox", + "should label the reusable sandbox product variant" + ); + } +} diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 1fd3c2382c..ae6d953004 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -32,14 +32,15 @@ pub const HEADER_X_COMPRESS_HINT: HeaderName = HeaderName::from_static("x-compre pub const HEADER_X_DEBUG_FASTLY_POP: HeaderName = HeaderName::from_static("x-debug-fastly-pop"); // Staging / version identification headers -/// Deployed git version: tag, else branch, else 6-char commit (see [`TS_GIT_VERSION`]). +/// Release source commit, or development ref/short commit (see [`TS_GIT_VERSION`]). pub const HEADER_X_TS_VERSION: HeaderName = HeaderName::from_static("x-ts-version"); /// Fastly service version (`FASTLY_SERVICE_VERSION`), formerly sent as `x-ts-version`. pub const HEADER_X_TS_FASTLY_VERSION: HeaderName = HeaderName::from_static("x-ts-fastly-version"); pub const HEADER_X_TS_ENV: HeaderName = HeaderName::from_static("x-ts-env"); /// Deployed git version compiled in by `build.rs`, from the deploy pipeline's -/// `TRUSTED_SERVER__GIT_VERSION` or local git. `None` when unknown. +/// `TRUSTED_SERVER__GIT_VERSION` or local git. Release mode requires the full +/// lowercase commit; development retains ref/short-commit display. `None` when unknown. pub const TS_GIT_VERSION: Option<&str> = match option_env!("TRUSTED_SERVER__GIT_VERSION") { Some(version) if !version.is_empty() => Some(version), _ => None, diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 357660ea0d..4cab6fcdde 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod build_info; pub mod cache_policy; pub mod cache_purge; pub mod config; diff --git a/crates/trusted-server-core/tests/git_version_resolve.rs b/crates/trusted-server-core/tests/git_version_resolve.rs index 9fb77a2b54..c1dbbd58f7 100644 --- a/crates/trusted-server-core/tests/git_version_resolve.rs +++ b/crates/trusted-server-core/tests/git_version_resolve.rs @@ -4,6 +4,9 @@ mod git_version; use git_version::{Candidates, resolve_git_version}; +use std::ffi::OsStr; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt as _; const COMMIT: &str = "abcdef0123456789abcdef0123456789abcdef01"; @@ -139,3 +142,113 @@ fn is_usable_accepts_only_visible_ascii() { ); assert!(!git_version::is_usable("v1-été"), "should reject non-ASCII"); } + +#[test] +fn release_source_requires_an_exact_full_lowercase_commit() { + let commit = "abcdef0123456789abcdef0123456789abcdef01"; + assert_eq!( + git_version::release_source(Some(OsStr::new("1")), Some(OsStr::new(commit))), + Ok(Some(commit.to_owned())), + "should preserve the complete release source" + ); + for source in [ + None, + Some(""), + Some("main"), + Some("v1.2.3"), + Some("abcdef"), + Some("ABCDEF0123456789abcdef0123456789abcdef01"), + Some(" abcdef0123456789abcdef0123456789abcdef01"), + Some("abcdef0123456789abcdef0123456789abcdef01\n"), + Some("abcdef0123456789abcdef0123456789abcdef0g"), + ] { + assert!( + git_version::release_source(Some(OsStr::new("1")), source.map(OsStr::new)).is_err(), + "should reject malformed release source {source:?}" + ); + } +} + +#[test] +fn release_control_accepts_only_absence_or_exact_one() { + assert_eq!( + git_version::release_source(None, Some(OsStr::new("main"))), + Ok(None), + "should retain development resolution when release control is absent" + ); + for control in ["", "0", "true", " 1", "1\n"] { + assert!( + git_version::release_source( + Some(OsStr::new(control)), + Some(OsStr::new("abcdef0123456789abcdef0123456789abcdef01")) + ) + .is_err(), + "should reject invalid control even with valid source" + ); + } +} + +#[cfg(unix)] +#[test] +fn non_unicode_release_inputs_fail_closed() { + let non_unicode = OsStr::from_bytes(b"\xff"); + assert!( + git_version::release_source(Some(non_unicode), None).is_err(), + "should recognize non-Unicode control as present and invalid" + ); + assert!( + git_version::release_source(Some(OsStr::new("1")), Some(non_unicode)).is_err(), + "should reject non-Unicode source" + ); +} + +#[test] +fn actual_build_source_resolution_never_calls_git_in_release_mode() { + let source = + git_version::resolve_build_source(Some(OsStr::new("1")), Some(OsStr::new(COMMIT)), || { + panic!("should not call Git in release mode") + }) + .expect("should resolve valid release source"); + assert_eq!( + source.as_deref(), + Some(COMMIT), + "should retain the complete release commit" + ); + let result = + git_version::resolve_build_source(Some(OsStr::new("1")), Some(OsStr::new("main")), || { + panic!("should not fall back after invalid release source") + }); + assert!( + result.is_err(), + "should reject malformed release source without Git fallback" + ); +} + +#[test] +fn actual_build_source_resolution_preserves_development_override_and_fallback() { + for identity in ["main", " v1.2.3 ", COMMIT] { + let source = git_version::resolve_build_source(None, Some(OsStr::new(identity)), || { + panic!("should not call Git with a usable development override") + }) + .expect("should resolve development override"); + assert_eq!( + source.as_deref(), + Some(identity.trim()), + "should preserve development override" + ); + } + for identity in [None, Some(OsStr::new("")), Some(OsStr::new("bad identity"))] { + let mut called = false; + let source = git_version::resolve_build_source(None, identity, || { + called = true; + Some("local-branch".to_owned()) + }) + .expect("should resolve development fallback"); + assert!(called, "should call actual fallback for unusable override"); + assert_eq!( + source.as_deref(), + Some("local-branch"), + "should use fallback result" + ); + } +} From 72b7e1e3009011811f172d74c3f85400ab7eb3d6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 11:38:27 +0530 Subject: [PATCH 018/104] Allow runtime Axum bind address overrides --- .../trusted-server-adapter-axum/src/main.rs | 118 ++++++++++++++---- 1 file changed, 95 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b7..337399974f 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -26,15 +26,16 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let config = match port_from_env() { - // When PORT is set, bind to a specific address so integration tests - // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. - Some(port) => AxumDevServerConfig { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), - enable_ctrl_c: true, - }, - // Normal development path: read bind address from axum.toml. - None => AxumDevServerConfig::default(), + // The default is loopback; runtime overrides are passed directly to EdgeZero. + let host = std::env::var_os("EDGEZERO__ADAPTER__HOST"); + let port = std::env::var_os("PORT"); + let addr = parse_bind_address(host.as_deref(), port.as_deref()).unwrap_or_else(|error| { + log::error!("Invalid runtime bind address: {error}"); + std::process::exit(1); + }); + let config = AxumDevServerConfig { + addr, + enable_ctrl_c: true, }; log::info!("Listening on http://{}", config.addr); @@ -65,26 +66,37 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Read a port number from the `PORT` environment variable. +/// Parse runtime binding without compiling deployment configuration into the executable. /// -/// Returns `None` when the variable is unset. Exits non-zero if the value -/// is set but cannot be parsed — silently falling back to a different port -/// would surprise tooling that expects the server at the requested address. -#[allow(clippy::print_stderr)] -fn port_from_env() -> Option { - let raw = std::env::var("PORT").ok()?; - match raw.parse() { - Ok(port) => Some(port), - Err(e) => { - eprintln!("error: PORT env var '{raw}' is not a valid u16: {e}"); - std::process::exit(1); - } - } +/// # Errors +/// +/// Rejects explicitly supplied non-Unicode or malformed host/port values. +fn parse_bind_address( + host: Option<&std::ffi::OsStr>, + port: Option<&std::ffi::OsStr>, +) -> Result { + let host = match host { + None => std::net::IpAddr::from([127, 0, 0, 1]), + Some(value) => value + .to_str() + .and_then(|value| value.parse().ok()) + .ok_or("EDGEZERO__ADAPTER__HOST must be an IP address")?, + }; + let port = match port { + None => 8787, + Some(value) => value + .to_str() + .and_then(|value| value.parse().ok()) + .ok_or("PORT must be an integer from 0 to 65535")?, + }; + Ok(std::net::SocketAddr::new(host, port)) } #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt as _; #[test] fn crate_compiles() {} @@ -145,4 +157,64 @@ mod tests { "should reject an unknown level name and return it trimmed" ); } + + #[test] + fn bind_address_defaults_and_runtime_overrides() { + assert_eq!( + parse_bind_address(None, None).expect("should default"), + "127.0.0.1:8787".parse().expect("should parse fixture"), + "should retain loopback default" + ); + assert_eq!( + parse_bind_address( + Some(std::ffi::OsStr::new("0.0.0.0")), + Some(std::ffi::OsStr::new("8080")) + ) + .expect("should override") + .to_string(), + "0.0.0.0:8080", + "should bind all interfaces at runtime" + ); + assert_eq!( + parse_bind_address(Some(std::ffi::OsStr::new("::1")), None) + .expect("should accept IPv6") + .to_string(), + "[::1]:8787", + "should accept IPv6" + ); + assert_eq!( + parse_bind_address(None, Some(std::ffi::OsStr::new("9000"))) + .expect("should preserve PORT") + .port(), + 9000, + "should honor existing port setting" + ); + } + + #[test] + fn bind_address_rejects_invalid_explicit_values() { + for host in ["", "localhost", "0.0.0.0:8080", " 0.0.0.0"] { + assert!( + parse_bind_address(Some(std::ffi::OsStr::new(host)), None).is_err(), + "should reject invalid host" + ); + } + for port in ["", "invalid", "65536", " 80"] { + assert!( + parse_bind_address(None, Some(std::ffi::OsStr::new(port))).is_err(), + "should reject invalid port" + ); + } + #[cfg(unix)] + { + assert!( + parse_bind_address(Some(std::ffi::OsStr::from_bytes(b"\xff")), None).is_err(), + "should reject non-Unicode host" + ); + assert!( + parse_bind_address(None, Some(std::ffi::OsStr::from_bytes(b"\xff"))).is_err(), + "should reject non-Unicode port" + ); + } + } } From 81a5a72f954e9566f9981a893bbfc7743c4954b2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 11:38:38 +0530 Subject: [PATCH 019/104] Harden release eligibility and artifact validation --- .github/workflows/release-build.yml | 3 +- .github/workflows/release.yml | 24 ++- AGENTS.md | 2 + scripts/release/phase1-build.sh | 7 +- scripts/release/phase1.mjs | 234 +++++++++++++++++++++++++--- scripts/release/phase1.test.mjs | 205 +++++++++++++++++++++++- 6 files changed, 435 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ce6565952a..6641adbf70 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -78,13 +78,14 @@ jobs: BUILD_TARGET: ${{ matrix.target }} RELEASE_ASSET: ${{ matrix.asset }} SOURCE_COMMIT: ${{ github.sha }} + RELEASE_VERSION: ${{ github.ref_name }} run: | set -euo pipefail env -i PATH="$PATH" HOME="$HOME" \ CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" \ RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ - SOURCE_COMMIT="$SOURCE_COMMIT" \ + SOURCE_COMMIT="$SOURCE_COMMIT" RELEASE_VERSION="$RELEASE_VERSION" \ bash scripts/release/phase1-build.sh "$BUILD_PACKAGE" "$BUILD_TARGET" "$RELEASE_ASSET" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68fd23c56c..b92b072d4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,17 @@ on: paths: - '.github/workflows/release*.yml' - 'scripts/release/phase1*' + - 'scripts/release/release-cohort.json' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - '.tool-versions' - '.cargo/**' - 'edgezero.toml' - - 'crates/trusted-server-core/**' + - 'crates/trusted-server-core/src/**' + - 'crates/trusted-server-core/build_support/**' + - 'crates/trusted-server-core/build.rs' + - 'crates/trusted-server-core/Cargo.toml' - 'crates/trusted-server-cli/**' - 'crates/trusted-server-js/**' - 'crates/trusted-server-adapter-fastly/**' @@ -31,7 +35,7 @@ jobs: id-token: write attestations: write release: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.RELEASE_PUBLICATION_ENABLED == 'true' needs: build runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -100,6 +104,7 @@ jobs: GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ github.ref_name }} SOURCE_COMMIT: ${{ github.sha }} + RELEASE_PUBLICATION_ENABLED: ${{ vars.RELEASE_PUBLICATION_ENABLED }} run: | set -euo pipefail gh --version @@ -111,6 +116,8 @@ jobs: const { execFileSync } = require('node:child_process'); const { GH_REPO: repo, RELEASE_TAG: tag, GH_TOKEN: token, SOURCE_COMMIT: source } = process.env; if (!/^[\w-]+\/[\w.-]+$/.test(repo) || !/^v[\w.+-]+$/.test(tag)) throw Error('Invalid repository or tag'); + if (process.env.RELEASE_PUBLICATION_ENABLED !== 'true') throw Error('Release publication is not enabled by maintainers'); + const prerelease = tag.includes('-'), make_latest = prerelease ? 'false' : 'true'; const names = ['trusted-server-axum-x86_64-unknown-linux-gnu', 'trusted-server-axum-x86_64-unknown-linux-musl', 'trusted-server-axum-aarch64-unknown-linux-gnu', 'trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', 'ts-x86_64-unknown-linux-gnu']; const hash = bytes => createHash('sha256').update(bytes).digest('hex'); const regular = path => { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.size === 0) throw Error(`Invalid file: ${path}`); return fs.readFileSync(path); }; @@ -147,18 +154,19 @@ jobs: const body = [ `[Verification guide](https://github.com/${repo}/blob/${tag}/docs/guide/release-verification.md)`, '', '```bash', 'set -euo pipefail', `REPO=${repo}`, `TAG=${tag}`, `COMMIT=${source}`, `ASSETS=(${names.join(' ')})`, - 'gh release download "$TAG" --repo "$REPO"', 'gh release verify "$TAG" --repo "$REPO"', + 'gh release download "$TAG" --repo "$REPO"', 'test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq .immutable)" = true', 'gh release verify "$TAG" --repo "$REPO"', 'for asset in "${ASSETS[@]}"; do', ' for suffix in "" .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO"; done', ' for kind in provenance sbom; do', ' predicate=https://slsa.dev/provenance/v1; if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi', ' checks=(--repo "$REPO" --bundle "$asset.$kind.sigstore.json" --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type "$predicate")', - ' gh attestation verify "$asset" "${checks[@]}" --signer-workflow "$REPO/.github/workflows/release-build.yml"', + ' gh attestation verify "$asset" "${checks[@]}" --signer-workflow "$REPO/.github/workflows/release-build.yml" --format json > "$asset.$kind.verified.json"', + ` if [[ "$kind" == sbom ]]; then jq -e --slurpfile sbom "$asset.spdx.json" '[.[].verificationResult.statement.predicate] | length == 1 and .[0] == $sbom[0]' "$asset.$kind.verified.json"; fi`, ' gh attestation verify "$asset" "${checks[@]}" --cert-identity "https://github.com/$REPO/.github/workflows/release-build.yml@refs/tags/$TAG"', ' done', 'done', 'gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO"', 'shasum -a 256 --check SHA256SUMS', '```', ].join('\n'); - const created = await api('releases', 'POST', {tag_name:tag, target_commitish:source, name:tag, draft:true, body}); - if (!Number.isSafeInteger(created.id) || created.id <= 0 || created.tag_name !== tag || created.draft !== true) throw Error('Invalid created release identity'); + const created = await api('releases', 'POST', {tag_name:tag, target_commitish:source, name:tag, draft:true, prerelease, make_latest, body}); + if (!Number.isSafeInteger(created.id) || created.id <= 0 || created.tag_name !== tag || created.draft !== true || created.prerelease !== prerelease) throw Error('Invalid created release identity'); const id = created.id; console.log(`Created release ID: ${id}`); for (const file of files) { @@ -168,7 +176,7 @@ jobs: const asset = await response.json(); if (asset.name !== file || asset.state !== 'uploaded' || asset.size !== bytes.length || asset.digest !== `sha256:${hash(bytes)}`) throw Error(`Upload mismatch: ${file}`); } - const published = await api(`releases/${id}`, 'PATCH', {draft:false}); + const published = await api(`releases/${id}`, 'PATCH', {draft:false, prerelease, make_latest}); // BEGIN READ RETRIES const retryDeadline = Date.now() + 180000; const retryRead = async operation => { @@ -186,7 +194,7 @@ jobs: // END READ RETRIES const verifyIdentity = async () => { const release = await retryRead(() => api(`releases/${id}`)); - if (published.id !== id || release.id !== id || release.tag_name !== tag || release.draft !== false || release.immutable !== true) throw Error('Published release identity or immutability mismatch'); + if (published.id !== id || release.id !== id || release.tag_name !== tag || release.draft !== false || release.immutable !== true || release.prerelease !== prerelease) throw Error('Published release identity or immutability mismatch'); if (!Array.isArray(release.assets) || release.assets.length !== files.length || new Set(release.assets.map(asset => asset.name)).size !== files.length) throw Error('Published asset set mismatch'); for (const asset of release.assets) { if (!files.includes(asset.name)) throw Error('Unexpected published asset'); diff --git a/AGENTS.md b/AGENTS.md index a88d0df59e..3d5b8d85a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -376,6 +376,8 @@ Every PR must pass: --- +12. Release producer checks: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh` + ## Standard Workflow 1. **Read & plan** — understand the request, explore relevant code. diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index 3536f2595b..b27769b65e 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -23,12 +23,13 @@ done node scripts/release/phase1.mjs audit # No operator values enter through inherited environment; these fictional canaries # additionally catch known config embedding regressions in the final executable. +export TRUSTED_SERVER__RELEASE_BUILD=1 export TRUSTED_SERVER__GIT_VERSION="$SOURCE_COMMIT" export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://phase1-origin-canary.example.com export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=phase1-secret-canary-32-characters export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=phase1-physical-store-canary export FASTLY_SERVICE_ID=phase1-service-account-canary -export MACOSX_DEPLOYMENT_TARGET=15.0 +export MACOSX_DEPLOYMENT_TARGET=13.0 [[ ! -e trusted-server.toml ]] cat > trusted-server.toml <<'CONFIG' # Fictional config must never become a build input. @@ -62,7 +63,7 @@ DOCKERFILE --mount "type=bind,src=$CARGO_TARGET_DIR,dst=/target" \ --mount "type=bind,src=$(dirname "$(dirname "$(node -p process.execPath)")"),dst=/opt/node,readonly" \ --mount "type=bind,src=$(readlink -f "$(command -v rustup)"),dst=/usr/local/bin/rustup,readonly" \ - --env TRUSTED_SERVER__GIT_VERSION --env TRUSTED_SERVER__PUBLISHER__ORIGIN_URL \ + --env TRUSTED_SERVER__RELEASE_BUILD --env TRUSTED_SERVER__GIT_VERSION --env TRUSTED_SERVER__PUBLISHER__ORIGIN_URL \ --env TRUSTED_SERVER__PUBLISHER__PROXY_SECRET --env EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME \ --env FASTLY_SERVICE_ID --env CARGO_TARGET_DIR=/target \ "$builder" bash -euc ' @@ -86,6 +87,7 @@ else cargo build --package "$build_package" --release --target "$build_target" --locked fi cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset" +shasum -a 256 "release-assets/$release_asset" > "$RUNNER_TEMP/binary.sha256" node scripts/release/phase1.mjs check "release-assets/$release_asset" node scripts/release/phase1.mjs smoke "release-assets/$release_asset" "$build_target" # Pin and checksum the generator source before extraction or execution. Cargo's @@ -137,4 +139,5 @@ chmod +x "$tool_root/converter" node scripts/release/phase1.mjs validate-spdx \ "release-assets/$release_asset.spdx.json" "release-assets/$release_asset" \ "$tool_root/combined.cdx.json" +shasum -a 256 --check "$RUNNER_TEMP/binary.sha256" git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs index 34d6209fca..ae1d020d8b 100644 --- a/scripts/release/phase1.mjs +++ b/scripts/release/phase1.mjs @@ -29,6 +29,40 @@ export function checkReleaseFormat(bytes, target, elf = '') { ) throw Error('GLIBC requirement exceeds 2.34') } + } else if (target.endsWith('-apple-darwin')) { + if ( + bytes.length < 32 || + bytes.readUInt32LE(0) !== 0xfeedfacf || + bytes.readUInt32LE(4) !== + (target.startsWith('aarch64-') ? 0x0100000c : 0x01000007) + ) + throw Error('Expected target-matched 64-bit Mach-O') + const end = 32 + bytes.readUInt32LE(20), + count = bytes.readUInt32LE(16) + if (end > bytes.length || count > Math.floor((end - 32) / 8)) + throw Error('Truncated Mach-O commands') + let offset = 32, + minimum + for (let i = 0; i < count; i++) { + if (offset + 8 > end) throw Error('Truncated Mach-O command') + const command = bytes.readUInt32LE(offset), + size = bytes.readUInt32LE(offset + 4) + if (size < 8 || offset + size > end) + throw Error('Invalid Mach-O command size') + if (command === 0x32 || command === 0x24) { + if ( + minimum !== undefined || + size < (command === 0x32 ? 24 : 16) || + (command === 0x32 && bytes.readUInt32LE(offset + 8) !== 1) + ) + throw Error('Invalid macOS deployment command') + minimum = bytes.readUInt32LE(offset + (command === 0x32 ? 12 : 8)) + } + offset += size + } + if (offset !== end || minimum === undefined || minimum !== 0x000d0000) + throw Error('Expected macOS minimum OS 13.0') + console.log('SUCCESS: Mach-O target and macOS minimum OS 13.0:', target) } } @@ -40,6 +74,19 @@ export function completeSpdx(document, binary, original) { document.packages.some((entry) => entry.SPDXID === 'SPDXRef-Artifact') ) throw new Error('Unexpected converter root or relationships') + document.creationInfo ??= {} + document.creationInfo.creators = [ + ...new Set([ + ...(document.creationInfo.creators ?? []), + 'Tool: cargo-cyclonedx-0.5.9', + 'Tool: cyclonedx-cli-0.33.1', + 'Tool: trusted-server-phase1', + ]), + ] + for (const item of document.packages) + for (const key of ['licenseDeclared', 'licenseConcluded']) + if (typeof item[key] === 'string') + item[key] = item[key].replace(/\s+/g, ' ').trim() const root = original.metadata.component document.packages.push({ SPDXID: 'SPDXRef-Artifact', @@ -156,24 +203,153 @@ export function checkBinary(bytes) { } } +// Resolve literal macro arguments, including nested concat!, without Rust dataflow claims. +function rustTokens(source) { + const tokens = [] + for (let i = 0; i < source.length; ) { + if (/\s/.test(source[i])) { + i++ + continue + } + if (source.startsWith('//', i)) { + i = source.indexOf('\n', i) + if (i < 0) break + continue + } + if (source.startsWith('/*', i)) { + let depth = 1 + i += 2 + while (i < source.length && depth) { + if (source.startsWith('/*', i)) { + depth++ + i += 2 + } else if (source.startsWith('*/', i)) { + depth-- + i += 2 + } else i++ + } + continue + } + const raw = source.slice(i).match(/^r(#{0,255})"/) + if (raw) { + const start = i + raw[0].length, + end = source.indexOf('"' + raw[1], start) + if (end < 0) throw Error('Unterminated Rust raw string') + tokens.push({ string: source.slice(start, end) }) + i = end + 1 + raw[1].length + continue + } + const character = source + .slice(i) + .match(/^'(?:\\(?:u\{[0-9a-fA-F_]+\}|x[0-9a-fA-F]{2}|.)|[^'\\\r\n])'/) + if (character) { + i += character[0].length + continue + } + if (source[i] === '"') { + let end = i + 1 + while (end < source.length && source[end] !== '"') { + if (source[end] === '\\') end++ + end++ + } + if (end === source.length) throw Error('Unterminated Rust string') + const value = source + .slice(i + 1, end) + .replace( + /\\(?:x([0-9a-fA-F]{2})|u\{([0-9a-fA-F_]+)\}|(\r?\n)\s*|([nrt0\\"']))/g, + (_, hex, unicode, newline, simple) => { + if (hex) return String.fromCodePoint(parseInt(hex, 16)) + if (unicode) + return String.fromCodePoint( + parseInt(unicode.replaceAll('_', ''), 16) + ) + if (newline) return '' + return { n: '\n', r: '\r', t: '\t', 0: '\0' }[simple] ?? simple + } + ) + tokens.push({ string: value }) + i = end + 1 + continue + } + const word = source.slice(i).match(/^[A-Za-z_][A-Za-z_0-9]*/) + if (word) { + tokens.push(word[0]) + i += word[0].length + } else tokens.push(source[i++]) + } + return tokens +} + export function checkSource(file, source) { - const macros = source.matchAll(/\b(?:option_env|env)!\s*\(\s*"([^"]+)"/g) - for (const [, variable] of macros) { + const tokens = rustTokens(source) + const deployment = (value) => /^(?:TRUSTED_SERVER__|EDGEZERO__)/.test(value) + // Only release control and Cargo-owned target facts are additional build-time inputs. + const allowedBuild = new Set([ + 'TRUSTED_SERVER__GIT_VERSION', + 'TRUSTED_SERVER__RELEASE_BUILD', + 'TRUSTED_SERVER__BUILD_TARGET', + ]) + const expression = (start) => { if ( - /^(?:TRUSTED_SERVER__|EDGEZERO__)/.test(variable) && - variable !== 'TRUSTED_SERVER__GIT_VERSION' + ['env', 'option_env'].includes(tokens[start]) && + tokens[start + 1] === '!' && + ['(', '[', '{'].includes(tokens[start + 2]) ) { - throw new Error(`${file}: deployment compile-time environment input`) + const close = { '(': ')', '[': ']', '{': '}' }[tokens[start + 2]] + const argument = expression(start + 3) + if (argument && tokens[argument.end] === close) + return { value: '__BUILD_ENV__', end: argument.end + 1 } + return null } + if (tokens[start]?.string !== undefined) + return { value: tokens[start].string, end: start + 1 } + if ( + tokens[start] !== 'concat' || + tokens[start + 1] !== '!' || + !['(', '[', '{'].includes(tokens[start + 2]) + ) + return null + const close = { '(': ')', '[': ']', '{': '}' }[tokens[start + 2]] + let cursor = start + 3, + value = '' + while (tokens[cursor] !== close) { + const part = expression(cursor) + if (!part) return null + value += part.value + cursor = part.end + if (tokens[cursor] === ',') cursor++ + else if (tokens[cursor] !== close) return null + } + return { value, end: cursor + 1 } } - if ( - /\binclude_(?:str|bytes)!\s*\([^)]*"[^"\n]*(?:trusted-server\.toml|\.env(?:[."]))/s.test( - source - ) || - (file.endsWith('build.rs') && - /["'][^"'\n]*trusted-server\.toml["']/.test(source)) - ) { - throw new Error(`${file}: operator compile-time file input`) + const operatorPath = (value) => + /(?:^|[/\\])(?:trusted-server\.toml|\.env(?:[./\\]|$))/.test(value) + for (let i = 0; i < tokens.length; i++) { + if ( + ['env', 'option_env', 'include_str', 'include_bytes'].includes( + tokens[i] + ) && + tokens[i + 1] === '!' + ) { + const argument = expression(i + 3) + if (!argument) continue + const value = argument.value + if ( + (['env', 'option_env'].includes(tokens[i]) && + deployment(value) && + !allowedBuild.has(value)) || + (tokens[i].startsWith('include_') && operatorPath(value)) + ) + throw Error(`${file}: deployment compile-time input`) + } + if (file.endsWith('build.rs')) { + const value = expression(i)?.value + if ( + value !== undefined && + ((deployment(value) && !allowedBuild.has(value)) || operatorPath(value)) + ) + throw Error(`${file}: operator compile-time build input`) + } } } @@ -199,7 +375,7 @@ function namespace(bom, prefix) { return rename(bom) } -export function mergeSboms(cargo, npm, binary, name) { +export function mergeSboms(cargo, npm, binary, name, version) { checkBinary(binary) const rust = namespace(cargo, 'cargo') const javascript = namespace(npm, 'npm') @@ -221,6 +397,7 @@ export function mergeSboms(cargo, npm, binary, name) { component: { ...root, name, + version: version ?? root.version, hashes: [ { alg: 'SHA-256', @@ -274,10 +451,11 @@ async function smokeRelease(file, target) { if (target === 'wasm32-wasip1') return const cli = file.split('/').at(-1).startsWith('ts-') const expected = - 'ts ' + + 'ts package=' + readFileSync('Cargo.toml', 'utf8').match( /\[workspace\.package\][\s\S]*?^version = "([^"]+)"/m - )[1] + )[1] + + ` source=${process.env.SOURCE_COMMIT} target=${target} variant=default` const version = (output) => { if (output.trim() !== expected) throw Error('Unexpected CLI version') console.log('SUCCESS:', output.trim()) @@ -303,11 +481,13 @@ async function smokeRelease(file, target) { 'scratch', ] : target.startsWith('aarch64-') - ? ['debian:12-slim'] + ? [ + 'debian:12-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8', + ] : [ - 'debian:12-slim', - 'ubuntu:22.04', - 'registry.access.redhat.com/ubi9/ubi-minimal:9.6', + 'debian:12-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8', + 'ubuntu:22.04@sha256:0a9887931b0e38af2e3487ed39e29436b0535c4f3f52e57745baaf79d1898956', + 'registry.access.redhat.com/ubi9/ubi-minimal:9.6@sha256:34880b64c07f28f64d95737f82f891516de9a3b43583f39970f7bf8e4cfa48b7', ] for (const reference of images) { let image @@ -384,7 +564,8 @@ async function smokeRelease(file, target) { version: 1, } const environment = [ - `PORT=${port}`, + 'PORT=8787', + 'EDGEZERO__ADAPTER__HOST=0.0.0.0', `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=${JSON.stringify(envelope)}`, 'TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE=smoke-edge-cookie-passphrase-32-bytes', 'TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD=smoke-admin-password-32-bytes-ok', @@ -395,7 +576,8 @@ async function smokeRelease(file, target) { '--name', container, '-d', - '--network=host', + '--publish', + `127.0.0.1:${port}:8787`, ...restrictions, ...environment.flatMap((value) => ['--env', value]), image, @@ -430,7 +612,7 @@ async function smokeRelease(file, target) { } if (!healthy) throw Error('Axum startup deadline exhausted') console.log( - 'SUCCESS: configured Axum health returned 200 ok:', + 'SUCCESS: Axum bound to 0.0.0.0; host reached published container port, HTTP 200 ok:', reference, image ) @@ -479,7 +661,8 @@ if ( JSON.parse(readFileSync(cargo)), JSON.parse(readFileSync(npm)), readFileSync(binary), - binary.split('/').at(-1) + binary.split('/').at(-1), + process.env.RELEASE_VERSION ), null, 2 @@ -490,6 +673,9 @@ if ( const document = JSON.parse(readFileSync(file)) const inventory = JSON.parse(readFileSync(original)) completeSpdx(document, readFileSync(binary), inventory) + document.creationInfo.creators.push( + `Tool: npm-${execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim()}` + ) const coverage = inventory.metadata.properties?.find( (entry) => entry.name === 'trusted-server:coverage' )?.value diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index e53e05705d..b9c5ec9966 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -151,6 +151,9 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an for (const mode of [ 'success', 'idle-socket', + 'stable', + 'publication-disabled', + 'prerelease-mismatch', 'post-read-error', 'draft', 'api-error', @@ -200,7 +203,8 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ) const release = (draft) => ({ id: 42, - tag_name: 'v0.0.0-example', + tag_name: mode === 'stable' ? 'v1.0.0' : 'v0.0.0-example', + prerelease: mode !== 'stable' && mode !== 'prerelease-mismatch', draft, immutable: mode !== 'mutable', assets: uploaded, @@ -227,7 +231,13 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ? response('denied', 503) : response(mode === 'draft' ? [release(true)] : []) if (u.pathname.endsWith('/releases') && method === 'POST') { - const body = JSON.parse(options.body).body + const creation = JSON.parse(options.body) + assert.equal(creation.prerelease, mode !== 'stable') + assert.equal( + creation.make_latest, + mode === 'stable' ? 'true' : 'false' + ) + const body = creation.body assert.match(body, /--signer-workflow.*release-build\.yml/) assert.match(body, /--cert-identity.*release-build\.yml@refs\/tags/) assert.ok(body.includes('COMMIT=' + 'a'.repeat(40))) @@ -270,7 +280,9 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an process: { env: { GH_REPO: 'example/project', - RELEASE_TAG: 'v0.0.0-example', + RELEASE_TAG: mode === 'stable' ? 'v1.0.0' : 'v0.0.0-example', + RELEASE_PUBLICATION_ENABLED: + mode === 'publication-disabled' ? 'false' : 'true', SOURCE_COMMIT: 'a'.repeat(40), }, }, @@ -298,11 +310,12 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an } const execute = () => vm.runInNewContext(`(async()=>{${script}\n})()`, context) - if (['success', 'idle-socket'].includes(mode)) await execute() + if (['success', 'idle-socket', 'stable'].includes(mode)) await execute() else await assert.rejects(execute, undefined, mode) if ( [ 'draft', + 'publication-disabled', 'api-error', 'missing', 'extra', @@ -315,7 +328,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an assert.equal(calls.length, mode === 'api-error' ? 1 : 3) if (['upload-error'].includes(mode)) assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) - if (['success', 'idle-socket'].includes(mode)) { + if (['success', 'idle-socket', 'stable'].includes(mode)) { assert.equal(uploaded.length, 29) assert.equal(calls.filter(([method]) => method === 'gh').length, 30) } @@ -506,3 +519,185 @@ test('rejects invalid wasm, GNU symbols above 2.34 and dynamic musl', () => { assert.throws(() => checkReleaseFormat(elf, musl, dynamic), /static/) assert.throws(() => checkReleaseFormat(wasm, gnu, 'GLIBC_2.34'), /ELF/) }) +test('audit resolves raw and nested concatenated deployment inputs', () => { + for (const source of [ + 'include_str!(concat!("trusted-", "server.toml"))', + 'include_bytes!(concat!(".en", r#"v.production"#))', + 'env!(r#"TRUSTED_SERVER__EC__PASSPHRASE"#)', + 'option_env!(concat!("EDGEZERO__", concat!("STORE", "S")))', + ]) + assert.throws(() => checkSource('src/lib.rs', source), /compile-time/) + for (const source of [ + 'std::env::var("TRUSTED_SERVER__EC__PASSPHRASE")', + 'std::env::var_os(concat!("EDGEZERO__", "STORE"))', + 'const SECRET: &str = r#"TRUSTED_SERVER__SECRET"#; env::var(SECRET)', + ]) + assert.throws(() => checkSource('build.rs', source), /compile-time/) + for (const source of [ + 'include_str!(concat!(env!("OUT_DIR"), "/generated.rs"))', + 'include_str!("trusted-server.example.toml")', + '// env!("EDGEZERO__SECRET")\nlet product = "literal";', + '/* include_str!("trusted-server.toml") */', + ]) + assert.doesNotThrow(() => checkSource('src/lib.rs', source)) + assert.doesNotThrow(() => + checkSource('build.rs', 'env::var("TRUSTED_SERVER__GIT_VERSION")') + ) + assert.doesNotThrow(() => + checkSource('src/lib.rs', 'std::env::var("EDGEZERO__ADAPTER__HOST")') + ) +}) +test('rejects non-Mach-O, wrong CPU and newer or absent macOS floor', () => { + const macho = (cpu = 0x0100000c, minimum = 0x000d0000) => { + const b = Buffer.alloc(56) + b.writeUInt32LE(0xfeedfacf, 0) + b.writeUInt32LE(cpu, 4) + b.writeUInt32LE(1, 16) + b.writeUInt32LE(24, 20) + b.writeUInt32LE(0x32, 32) + b.writeUInt32LE(24, 36) + b.writeUInt32LE(1, 40) + b.writeUInt32LE(minimum, 44) + return b + } + assert.doesNotThrow(() => checkReleaseFormat(macho(), 'aarch64-apple-darwin')) + assert.doesNotThrow(() => + checkReleaseFormat(macho(0x01000007), 'x86_64-apple-darwin') + ) + for (const b of [ + Buffer.alloc(8), + macho(0x01000007), + macho(0x0100000c, 0x000e0000), + macho().subarray(0, 40), + ]) + assert.throws( + () => checkReleaseFormat(b, 'aarch64-apple-darwin'), + /Mach-O|macOS/ + ) +}) +test('workflows select exactly the descriptive cohort and restrict actual build jobs', () => { + const cohort = JSON.parse(readFileSync('scripts/release/release-cohort.json')) + const names = cohort.artifacts.map((a) => a.name).sort() + const build = readFileSync('.github/workflows/release-build.yml', 'utf8') + const release = readFileSync('.github/workflows/release.yml', 'utf8') + assert.deepEqual( + [...build.matchAll(/^ asset: (\S+)/gm)].map((m) => m[1]).sort(), + names + ) + const sign = build.split('\n sign:')[1] + for (const name of names) assert.equal(sign.split(name).length - 1, 1) + const published = publicationScript().match(/const names = (\[[^;]+\]);/)[1] + assert.deepEqual(Array.from(vm.runInNewContext(published)).sort(), names) + for (const prefix of ['release-', 'signed-']) + assert.deepEqual( + [ + ...release.matchAll( + new RegExp(' name: ' + prefix + '(\\S+)', 'g') + ), + ] + .map((m) => m[1]) + .sort(), + names + ) + const actualBuild = build.split('\n build:')[1].split('\n sign:')[0] + assert.doesNotMatch(actualBuild, /id-token|attestations:/) + assert.match(release, /vars\.RELEASE_PUBLICATION_ENABLED == 'true'/) +}) +test('release preflight rejects inherited JS controls and mismatched source before tools run', () => { + const script = readFileSync('scripts/release/phase1-build.sh', 'utf8') + const root = mkdtempSync(join(tmpdir(), 'phase1-preflight-')) + const packageArgs = [ + 'trusted-server-cli', + 'x86_64-unknown-linux-gnu', + 'ts-x86_64-unknown-linux-gnu', + ] + const actual = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim() + try { + for (const variable of [ + 'TSJS_SKIP_BUILD', + 'TSJS_PREBUILT_DIR', + 'TSJS_TEST', + ]) { + assert.throws( + () => + execFileSync('bash', ['-c', script, 'phase1', ...packageArgs], { + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + RUNNER_TEMP: root, + SOURCE_COMMIT: actual, + [variable]: '', + }, + stdio: 'pipe', + }), + (error) => error.stderr.toString().includes('Refusing ' + variable) + ) + } + assert.throws( + () => + execFileSync('bash', ['-c', script, 'phase1', ...packageArgs], { + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + RUNNER_TEMP: root, + SOURCE_COMMIT: '0'.repeat(40), + }, + stdio: 'pipe', + }), + (error) => error.status !== 0 + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +test('SPDX identifies all producing tools and normalizes license spacing', () => { + const { document, original } = spdxFixture() + document.documentDescribes = [] + document.relationships = [] + document.packages = document.packages.filter( + (p) => p.SPDXID !== 'SPDXRef-Root' + ) + document.packages[0].licenseDeclared = 'Apache-2.0 OR MIT' + original.metadata.component.version = 'v1.2.3-example' + const output = completeSpdx(document, binary, original) + assert.equal( + output.packages.find((p) => p.SPDXID === 'SPDXRef-Artifact').versionInfo, + 'v1.2.3-example' + ) + assert.equal(output.packages[0].licenseDeclared, 'Apache-2.0 OR MIT') + assert.ok(output.creationInfo.creators.includes('Tool: cyclonedx-cli-0.33.1')) + assert.ok( + output.creationInfo.creators.includes('Tool: trusted-server-phase1') + ) +}) +test('audit handles Rust escapes and every macro delimiter', () => { + for (const source of [ + String.raw`env!("\x54RUSTED_SERVER__SECRET")`, + String.raw`env!("\u{54}RUSTED_SERVER__SECRET")`, + String.raw`include_str!("\x2eenv.production")`, + 'env![concat!["TRUSTED_SERVER__", "SECRET"]]', + 'env!{concat!{"TRUSTED_SERVER__", "SECRET"}}', + ]) + assert.throws(() => checkSource('src/lib.rs', source), /compile-time/) +}) +test('audit handles ordinary char literals and generated include prefixes', () => { + assert.doesNotThrow(() => + checkSource('src/lib.rs', `const QUOTE: char = '"';`) + ) + assert.throws( + () => + checkSource( + 'src/lib.rs', + 'include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/trusted-server.toml"))' + ), + /compile-time/ + ) + assert.doesNotThrow(() => + checkSource( + 'src/lib.rs', + 'include_str!(concat!(env!("OUT_DIR"), "/generated.rs"))' + ) + ) +}) From 39a769c3e3d6476395fa7a215bd0a5e74a760f3e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 11:38:50 +0530 Subject: [PATCH 020/104] Align release documentation and reference-host policy --- CHANGELOG.md | 3 +- .../src/commands/dev/lint/domains.rs | 6 +- docs/guide/release-verification.md | 50 ++++++-- ...6-10-13-attested-release-implementation.md | 44 ++++++- ...inary-attestations-phase1-compatibility.md | 110 +++++++++++++----- .../plans/binary-attestations-slice-map.json | 14 ++- ...6-10-09-1235-release-attestation-design.md | 46 +++++++- 7 files changed, 220 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e097b4322..37f428737e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Attested release binaries report `x-ts-version` as the full 40-character source commit, rather than the release tag. - +- Attested release binaries report `x-ts-version` as the full 40-character source commit, rather than the release tag. CLI output labels package/source/target/variant, and Axum supports runtime `EDGEZERO__ADAPTER__HOST` binding with a loopback default. - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. 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 186688e5ad..5b6df96154 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -123,8 +123,6 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "slsa.dev", // SPDX: canonical document predicate in the retained reviewed contract. "spdx.dev", - // CycloneDX: canonical format reference for the build inventory generator. - "cyclonedx.org", // Specs (other) "webassembly.org", // Fastly docs @@ -331,8 +329,8 @@ mod allow_check_tests { "should allow the canonical spdx.dev reference" ); assert!( - is_allowed("cyclonedx.org", ¬hing_suppressed()), - "should allow the canonical cyclonedx.org reference" + !is_allowed("cyclonedx.org", ¬hing_suppressed()), + "should require per-line suppression for the inventory format reference" ); assert!( is_allowed("cli.github.com", ¬hing_suppressed()), diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index ba45401277..d928ea4b6c 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -18,23 +18,27 @@ and signing jobs do not check out, compile or execute the downloaded binaries. | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; reproducibility and hosted runtime qualification remain pending | | `ts-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 builder; maximum required glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | -| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 15.0; runtime compatibility qualification remains pending | -| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 15.0; runtime compatibility qualification remains pending | +| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 13.0; runtime compatibility qualification remains pending | +| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 13.0; runtime compatibility qualification remains pending | | `trusted-server-axum-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 build; maximum glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | | `trusted-server-axum-x86_64-unknown-linux-musl` | Fully static x86-64; musl-tools 1.2.4-2; pinned Alpine and scratch-like startup smokes with runtime CA data | | `trusted-server-axum-aarch64-unknown-linux-gnu` | ARM64 GNU; native ARM UBI 9 build; maximum glibc 2.34; Debian 12 startup smoke | -The retained destination design calls for a macOS 13.0 deployment target; -Phase 1 still builds with 15.0. Lowering that floor and proving older-runtime -compatibility are additive later work. +The build checks 64-bit Mach-O magic, target CPU and minimum macOS 13.0 load commands. Actual execution on macOS 13 remains later qualification. The executables are not notarized. GNU builds require no GLIBC symbol newer than 2.34; glibc 2.34 and newer are the selected baseline. This includes Debian 12/bookworm-slim, Ubuntu 22.04+ and RHEL/UBI 9+. Distroless cc-debian12 has a compatible libc floor but is not a Phase 1 smoke image. The musl asset needs no dynamic loader or libraries; Alpine and a scratch-like container are required smoke images, while distroless-static is another deployment option -without a separate Phase 1 runtime test. Build logs record acquired runtime image -IDs. These required startup checks do not establish complete platform/tool qualification. +without a separate Phase 1 runtime test. All acquired smoke images are digest-pinned; build logs record their image IDs. These required startup checks do not establish complete platform/tool qualification. +Axum defaults to `127.0.0.1:8787`. Set runtime `EDGEZERO__ADAPTER__HOST=0.0.0.0` +and `PORT=8080` for containers and publish that port. Invalid explicit values +fail startup. This runtime setting is never compiled into the executable. +The smoke reaches a bridge-networked container through a host-published port; +loopback-only binding cannot pass it. TLS termination, graceful shutdown and +request/resource limits remain follow-up production hardening. + Axum's TLS client requires a runtime CA certificate bundle, including for health request initialization. Every Axum smoke mounts the runner's system CA bundle read-only at `/ca.pem` with `SSL_CERT_FILE=/ca.pem`; production operators @@ -178,7 +182,13 @@ pins and trusted roots to the offline machine. Reuse `REPO`, `TAG`, `COMMIT`, `ASSETS`, `WORKFLOW` and `IDENTITY` from above. Supplying both the bundle and custom roots avoids network acquisition; there is no `--offline` flag. +Before disconnecting, obtain an independent trusted SHA-256 pin for each binary +(for example from a separately authenticated release verification record). +Do not treat SHA256SUMS delivered with the same untrusted download as that pin. +Transfer that approved pin file as `APPROVED_BINARY_SHA256SUMS`. + ```bash +shasum -a 256 --check APPROVED_BINARY_SHA256SUMS for asset in "${ASSETS[@]}"; do for kind in provenance sbom; do predicate=https://slsa.dev/provenance/v1 @@ -266,7 +276,7 @@ establish equality with this release, and Phase 1 adds no `ts deploy --release` command. Use a prebuilt package route for the verified bytes. For a manual deployment to an **existing** Fastly service, use the Fastly CLI -version pinned in the release tag's `.tool-versions`. Prepare a fresh private +16.0.0 pinned in the release tag's `.tool-versions`. Prepare a fresh private packaging directory containing only the verified wasm and a minimal manifest: ```toml @@ -285,9 +295,12 @@ From the private directory: ```bash fastly compute pack --wasm-binary ./trusted-server-fastly.wasm +fastly compute validate --package ./pkg/example-app.tar.gz tar -tzf ./pkg/example-app.tar.gz tar -xOzf ./pkg/example-app.tar.gz example-app/bin/main.wasm > packaged.wasm cmp trusted-server-fastly.wasm packaged.wasm +tar -xOzf ./pkg/example-app.tar.gz example-app/fastly.toml > packaged.toml +cmp fastly.toml packaged.toml ``` Inspect the archive inventory: it must contain only the expected named package, @@ -332,8 +345,7 @@ scaffold, not an operator config. Release Fastly responses report `x-ts-version` as the full 40-character source commit supplied by the release build. Development display fallbacks are not the -release identity. `ts --version` reports the command name `ts` and Cargo package version, not a -source-commit field. Source identity comes from the attestation pins above. +release identity. `ts --version` labels `package= source= target= variant=default`. Release mode requires the full 40-character lowercase source commit; the package version is not the release tag. Attestation pins authenticate these inputs externally. The executable's SHA-256 is checked externally against the retained release bytes; exposing that digest as a runtime build fact belongs to #161. @@ -367,9 +379,11 @@ inputs. Before publication, a maintainer must enable immutable releases, protect `v*` tags and approve tag-push authority and required checks. Immutability remains a -human precondition; the publisher has no repository-administration token. A +human precondition. Set repository variable `RELEASE_PUBLICATION_ENABLED=true` only after confirming those settings with the read-only commands below. Without it, publication is skipped. There is no repository-administration token or prepublication settings API call, following the reviewed decision to check the published release instead. A violation can expose a mutable release before the workflow reports failure. +Tags containing `-` publish as prereleases with Latest disabled; stable tags publish as normal releases. Whether to authorize tags from rc branches is a maintainer decision; the Phase 2 ancestry/ruleset validator is not implemented here. + Publication lists releases, including drafts, and refuses every existing matching tag. It creates one draft, retains its release ID, and binds uploads and the final publish mutation to that ID. It checks the exact signing-approved asset @@ -471,3 +485,17 @@ bypass actors only to callers with sufficient repository access; an omitted fiel is not proof of an empty bypass list. Also review inherited rulesets. These are maintainer gates, not evidence that Phase 1's publication token can administer repository settings. + +### Phase 1 review boundary notes + +The reusable caller declares the maximum signing permissions required by its +called workflow. GitHub does not allow a read-only caller to invoke a nested +workflow that declares greater permissions, even for conditionally skipped jobs. +Actual build/check jobs narrow permissions to contents-read; signing remains +tag-only. Splitting a new build-only workflow is deferred to preserve this slice. + +The configuration scanner covers literal/raw/concatenated macros and literal +build-script deployment prefixes. It is a best-effort source regression audit, +not Rust expansion or dataflow analysis: computed names, procedural macros and +third-party build scripts are not completely analyzed. Planted canary scans and +clean inherited environments provide additional checks, not hermeticity. diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 4ef8715c6a..43cb758771 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -4,7 +4,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. -**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +**Phase 1 contributes to #1235 (D7 settings pending):** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. CycloneDX is only an intermediate generator format. Complete composition qualification below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. @@ -21,6 +21,46 @@ and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementa `937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. Rehearsal `.4` remains historical evidence for the original four assets only. +### Phase 1 review notes — rehearsal .6 pending + +Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede +historical phase applicability, not the retained destination requirements. +Goals, Non-goals, R-COHORT and R-BOUNDS refer to the original four-artifact design; +this slice explicitly includes seven binaries / 29 files, including Axum and +Linux ARM64. Its original four public names stay unchanged; no v1 index is emitted. +The Node producer helpers and inline publisher are interim exceptions to the +retained Rust-producer constraint, replaced by slices 2.5 (recipes/inspection), +2.6 (composition/SPDX) and 2.8 (publication). The frozen Status and tool versions +below describe #1262, not current Phase 1 implementation status. + +Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; +verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. +Rehearsal .5 is historical evidence only; this corrective batch needs .6. + +Slice 2.3 immutable build facts are reused from introducing commit +`bfb697404551a28217af8906024506095bcd9559` and revision +`6d0efda7210983021ab3c9efe8a0a3190e9eb81b`, as retained at the frozen source. +Core's resolver and new CLI/Fastly owner build scripts validate +`TRUSTED_SERVER__RELEASE_BUILD=1` and the full lowercase source SHA. +Cargo supplies the owning target/package; features supply the variant. Tags and +run IDs stay external. CLI now labels package/source/target/variant; Fastly queues +the same immutable facts through existing startup diagnostics. The source audit +permits exactly this release control, GIT_VERSION and Cargo-owned BUILD_TARGET, +not deployment values. CLI source-resource cleanup remains deferred. + +Axum binding is runtime-only: `EDGEZERO__ADAPTER__HOST` defaults to loopback; +containers can set `0.0.0.0`, with the existing `PORT` override. The smoke uses +bridge networking and reaches the process through a published host port. +TLS termination, graceful shutdown and request/resource limits remain follow-up. + +Publication requires `RELEASE_PUBLICATION_ENABLED=true` only after maintainers +confirm immutable releases and the v\* tag ruleset. There is no settings token or +prepublication settings API call, following the reviewed signing boundary; +postpublication immutable=true readback fails loudly. Hyphenated tags are +prereleases and never Latest. Stable tags are normal releases. Eligibility of +rc-branch tags is an unresolved maintainer decision; Phase 1 does not implement +the destination branch-ancestry/ruleset validator or authorize rc tags. + The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. Rehearsal `.5` passed at exact implementation SHA @@ -69,7 +109,7 @@ The [machine-readable inventory](binary-attestations-slice-map.json) assigns eve | 2.10 | consumer cli | | 2.11 | docs and format misc | -All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare, Spin and Axum distribution/qualification follow these slices. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection and explicit authorization of the first real tag; signing/publication rehearsal `.3` passed at `ca902b6fe`. +All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare and Spin distribution, plus full Axum consumer/platform qualification, follow these slices; Phase 1 already distributes the seven binaries described above. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection and explicit authorization of the first real tag; historical signing/publication evidence is recorded above; the corrective batch needs rehearsal `.6`. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index c75fbe8fa7..a1ab3dcb77 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -11,6 +11,46 @@ Frozen source references below mean: - [Cohort](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-cohort.json), [tools](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-tools.json), [consumer policy](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/release-policy.json). - [Index v1 schema](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/schemas/release-index-v1.schema.json), [distribution names](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-release/src/distribution.rs#L55), [consumer companion rules](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/documents.rs#L236), [SPDX joins](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/evidence/joins.rs#L188). +### Phase 1 review notes — rehearsal .6 pending + +Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede +historical phase applicability, not the retained destination requirements. +Goals, Non-goals, R-COHORT and R-BOUNDS refer to the original four-artifact design; +this slice explicitly includes seven binaries / 29 files, including Axum and +Linux ARM64. Its original four public names stay unchanged; no v1 index is emitted. +The Node producer helpers and inline publisher are interim exceptions to the +retained Rust-producer constraint, replaced by slices 2.5 (recipes/inspection), +2.6 (composition/SPDX) and 2.8 (publication). The frozen Status and tool versions +below describe #1262, not current Phase 1 implementation status. + +Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; +verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. +Rehearsal .5 is historical evidence only; this corrective batch needs .6. + +Slice 2.3 immutable build facts are reused from introducing commit +`bfb697404551a28217af8906024506095bcd9559` and revision +`6d0efda7210983021ab3c9efe8a0a3190e9eb81b`, as retained at the frozen source. +Core's resolver and new CLI/Fastly owner build scripts validate +`TRUSTED_SERVER__RELEASE_BUILD=1` and the full lowercase source SHA. +Cargo supplies the owning target/package; features supply the variant. Tags and +run IDs stay external. CLI now labels package/source/target/variant; Fastly queues +the same immutable facts through existing startup diagnostics. The source audit +permits exactly this release control, GIT_VERSION and Cargo-owned BUILD_TARGET, +not deployment values. CLI source-resource cleanup remains deferred. + +Axum binding is runtime-only: `EDGEZERO__ADAPTER__HOST` defaults to loopback; +containers can set `0.0.0.0`, with the existing `PORT` override. The smoke uses +bridge networking and reaches the process through a published host port. +TLS termination, graceful shutdown and request/resource limits remain follow-up. + +Publication requires `RELEASE_PUBLICATION_ENABLED=true` only after maintainers +confirm immutable releases and the v\* tag ruleset. There is no settings token or +prepublication settings API call, following the reviewed signing boundary; +postpublication immutable=true readback fails loudly. Hyphenated tags are +prereleases and never Latest. Stable tags are normal releases. Eligibility of +rc-branch tags is an unresolved maintainer decision; Phase 1 does not implement +the destination branch-ancestry/ruleset validator or authorize rc tags. + ## Approved scope after audit This document records the alignment decisions. Carried requirements remain @@ -36,9 +76,8 @@ The tools and consumer policy files remain unchanged. The cohort adds the three descriptive Axum entries below; all three policies still have **no Phase 1 readers**. Checked cohort selection, a supplemental tool policy, environment normalization/remapping, stricter tag/source validation, uploaded-byte re-download, -macOS 13 targeting, full platform qualification and CLI immutable build facts -are additive later work. This slice includes UBI GNU glibc 2.34 checks and -startup smokes, while retaining the macOS 15.0 deployment target; copying a +full platform qualification remains additive later work. This corrective batch adds immutable build facts and a verified macOS 13.0 deployment target; UBI GNU glibc 2.34 checks and +startup smokes remain. Copying a destination policy alone never establishes its qualification requirements. Native reproduction is a later measurement, not a Phase 1 release gate. Full Task 16 resume/reconciliation remains deferred. @@ -62,10 +101,10 @@ qualified evidence. | Run/source binding | Same approved source, caller/signer revision and repository run/attempt (R-EVIDENCE) | Same run, no custom evidence joins | Preserve invocation shape now; full graph joins deferred, not substituted by metadata claims. | | Exact four raw executable assets | `trusted-server-fastly.wasm`, `ts-aarch64-apple-darwin`, `ts-x86_64-apple-darwin`, `ts-x86_64-unknown-linux-gnu` | Same | Preserve exact names and unarchived executable subjects. | | Cohort identity / selection | `phase1-fastly-cli`, schema 1, exact package/bin/target/default-feature/variant/role/capability combinations | Names/packages/targets duplicated in YAML/shell; no cohort metadata | Carry original cohort unchanged with no reader. Checked declaration-based selection is additive later work, never an arbitrary command interface. | -| SPDX document / predicate | SPDX 2.3; `https://spdx.dev/Document/v2.3` | CycloneDX 1.5 / `https://cyclonedx.org/bom` | User selected SPDX: generate real SPDX 2.3 and restore the source predicate before publication. | +| SPDX document / predicate | SPDX 2.3; `https://spdx.dev/Document/v2.3` | CycloneDX 1.5 / `https://cyclonedx.org/bom` | User selected SPDX: generate real SPDX 2.3 and restore the source predicate before publication. | | SBOM companion name | `.spdx.json` (distribution/consumer hardwired suffix) | `.cdx.json` | Align now; never rename CycloneDX bytes to SPDX. | | SBOM root/binary join | `SPDXRef-DOCUMENT`; exactly one described root package and one `SHA256` checksum equal to executable bytes | CycloneDX metadata hash | Validate real converted SPDX against the existing root/hash contract. Root ID spelling itself is not hardwired. | -| SBOM predicate/document equality | Executable subject; predicate structurally equals distributed parsed SPDX; file bytes independently hashed | Executable subject is correct; no signed-predicate/file comparison | Preserve subject semantics, reject malformed/duplicate JSON before signing, and verify the shipped SPDX against the attested predicate. | +| SBOM predicate/document equality | Executable subject; predicate structurally equals distributed parsed SPDX; file bytes independently hashed | Executable subject is correct; no signed-predicate/file comparison | Preserve subject semantics, reject malformed JSON before signing (duplicate-key rejection is not claimed), and verify the shipped SPDX against the attested predicate. | | Provenance bundle name | `.provenance.sigstore.json` | Not shipped | Save action bundle and ship under the existing name now. | | SBOM bundle name | `.sbom.sigstore.json` | Not shipped | Save and ship now; do not introduce a competing suffix. | | Provenance subject set | Executable, SPDX, build-evidence, build-recipe, rebuild-check (Task 15/R-EVIDENCE) | Executable only | Sign executable + real SPDX in this slice. Add only the real evidence/recipe/rebuild subjects when their owning slices land; do not fabricate companions. | @@ -82,24 +121,24 @@ qualified evidence. ### Build/policy contracts and explicit scope limits -| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Policy file paths | `scripts/release/release-cohort.json`, `scripts/release/release-tools.json`, `crates/trusted-server-cli/release-policy.json` (`policy.rs:23–27`) | All absent | Carry all three unchanged with no Phase 1 readers. Later checked adoption must not execute source-supplied commands. | -| Policy qualification | Tools, environments and recipes remain `pending`, acquisition/digest fields null | Ordinary hosted build | Preserve all pending/null state. A Phase 1 checksum is not a claim of authenticated Phase 2 qualification. | -| Existing tool versions | Rust 1.98.1, Node 24.20.0, npm 11.19.0, gh 2.97.0; attest 4.2.2 at `1e69f48acb82d1966a394da916b4c1698aa569d6` | Rust/Node match; npm unchecked; ambient gh; attest matches | Preserve current tracked Rust/Node/action pins. Additional policy readers and exact gh/npm enforcement remain additive later work; original tool policy is unchanged. | -| New SBOM tool acquisition | No cargo-cyclonedx/converter entries; strict producer rejects unknown tool names | Cargo generator version/hash hardcoded | Supplemental tool-policy adoption is deferred. Keep existing Phase 1 acquisition checks and carry the original strict tools records unchanged. | -| Package/bin/target/features | Exact cohort entries, default features, no extras | Package/target agree; bin implicit | Preserve current fixed selections. A checked cohort reader and explicit-bin reconciliation are additive later work. | -| Profile and Cargo flags | Release, debug 1, strip none; `--frozen`, structured messages, fixed jobs (`recipe/plan.rs:175–197`) | Release/debug agree; `--locked`, online build, implicit bin/jobs | Keep online locked acquisition/build as the current Phase 1 boundary. Structured output/jobs, explicit bin and source normalization are additive later work; frozen confined execution follows its reviewed owner. | -| Normalized environment | C locale, UTC, source-commit epoch, immutable source facts, controlled AWS-LC/SQLite selection (`preflight/child.rs:82–115`) | Full SHA source fact only | Environment normalization and checked commit epoch are additive later work; no Phase 1 reproducibility claim. | -| Rust/C path remapping | Stable `/trusted-server`, `/cargo-vendor`, `/cargo-home`, `/rust-tools`, `/cargo-target` roots (`recipe/remap.rs`) | None | All additional remapping follows later owned inputs; it does not change names or verification identities. | -| Fresh/private inputs | Private prepared state and target; denied network; authenticated paths | Fresh target; hosted HOME/CARGO_HOME/PATH and network | Keep fresh target and scrubbed environment; prepared/offline/sandbox/tool closure remains the reviewed later slice. | -| JS preparation | Checked prebuilt inventory, shared twice-built modules, no Vite env/config loading | Fresh npm per artifact; private Cargo output; no shared comparison | Keep fresh npm/private Cargo outputs. Additional environment controls, authenticated inventory, two builds and shared contribution evidence remain later work; never invent equality claims. | -| macOS baseline | Deployment target **13.0**, hosted `macos-15` / `macos-15-intel` | Same runners, deployment target **15.0** | Keep current 15.0 setting. Lowering to the destination 13.0 floor and older-host runtime qualification are additive later work. | -| Linux baseline | GNU builder maximum **glibc 2.34**, UBI9/Debian12/Ubuntu22 compatibility evidence | Ordinary Ubuntu24 build; 2.39 environment, older compatibility unestablished | Explicit temporary limitation if source GNU builder/sysroot cannot be adopted without deferred platform machinery. Do not claim 2.34 merely by copying policy. Lowering the future requirement to 2.34 broadens support without renaming assets or breaking Phase 1 users. | -| Runtime/startup/file inspection | Owning runtime/startup acceptance and exact target classification | Build-only + config canaries | Additional binary type/architecture checks and full runtime qualification remain additive later work with the source inspection owner. | -| CLI immutable build facts | Source-controlled release/source/target/variant facts in CLI output (Task 4) | Existing package-version CLI output | Mark as additive later CLI metadata, preserving existing commands; do not substitute a different source-identity schema. | -| SBOM precision | Complete Cargo/native/JS composition and retained-byte joins | Conservative Cargo target/npm build-tree inventory; native/std/runtime gaps | Keep gaps explicit in SPDX and docs. Enrich the same SPDX companion later; do not label the smaller inventory complete. | -| Assurance profiles | Fastly `provenance-sbom-repeatable`; native `provenance-sbom` with full evidence | No repeatability/full evidence | Preserve destination profiles in cohort; do not emit an index/profile claim until its gates hold. Native reproduction remains a measurement; Fastly reproducibility is a later gate. | +| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Policy file paths | `scripts/release/release-cohort.json`, `scripts/release/release-tools.json`, `crates/trusted-server-cli/release-policy.json` (`policy.rs:23–27`) | All absent | Carry all three unchanged with no Phase 1 readers. Later checked adoption must not execute source-supplied commands. | +| Policy qualification | Tools, environments and recipes remain `pending`, acquisition/digest fields null | Ordinary hosted build | Preserve all pending/null state. A Phase 1 checksum is not a claim of authenticated Phase 2 qualification. | +| Existing tool versions | Rust 1.98.1, Node 24.20.0, npm 11.19.0, gh 2.97.0; attest 4.2.2 at `1e69f48acb82d1966a394da916b4c1698aa569d6` | Rust/Node match; npm unchecked; ambient gh; attest matches | Preserve current tracked Rust/Node/action pins. Additional policy readers and exact gh/npm enforcement remain additive later work; original tool policy is unchanged. | +| New SBOM tool acquisition | No cargo-cyclonedx/converter entries; strict producer rejects unknown tool names | Cargo generator version/hash hardcoded | Supplemental tool-policy adoption is deferred. Keep existing Phase 1 acquisition checks and carry the original strict tools records unchanged. | +| Package/bin/target/features | Exact cohort entries, default features, no extras | Package/target agree; bin implicit | Preserve current fixed selections. A checked cohort reader and explicit-bin reconciliation are additive later work. | +| Profile and Cargo flags | Release, debug 1, strip none; `--frozen`, structured messages, fixed jobs (`recipe/plan.rs:175–197`) | Release/debug agree; `--locked`, online build, implicit bin/jobs | Keep online locked acquisition/build as the current Phase 1 boundary. Structured output/jobs, explicit bin and source normalization are additive later work; frozen confined execution follows its reviewed owner. | +| Normalized environment | C locale, UTC, source-commit epoch, immutable source facts, controlled AWS-LC/SQLite selection (`preflight/child.rs:82–115`) | Full SHA source fact only | Environment normalization and checked commit epoch are additive later work; no Phase 1 reproducibility claim. | +| Rust/C path remapping | Stable `/trusted-server`, `/cargo-vendor`, `/cargo-home`, `/rust-tools`, `/cargo-target` roots (`recipe/remap.rs`) | None | All additional remapping follows later owned inputs; it does not change names or verification identities. | +| Fresh/private inputs | Private prepared state and target; denied network; authenticated paths | Fresh target; hosted HOME/CARGO_HOME/PATH and network | Keep fresh target and scrubbed environment; prepared/offline/sandbox/tool closure remains the reviewed later slice. | +| JS preparation | Checked prebuilt inventory, shared twice-built modules, no Vite env/config loading | Fresh npm per artifact; private Cargo output; no shared comparison | Keep fresh npm/private Cargo outputs. Additional environment controls, authenticated inventory, two builds and shared contribution evidence remain later work; never invent equality claims. | +| macOS baseline | Deployment target **13.0**, hosted `macos-15` / `macos-15-intel` | Same runners, deployment target **15.0** | This corrective batch targets 13.0 and checks Mach-O CPU/load-command minimum OS. Actual older-host runtime qualification remains later work. | +| Linux baseline | GNU builder maximum **glibc 2.34**, UBI9/Debian12/Ubuntu22 compatibility evidence | Ordinary Ubuntu24 build; 2.39 environment, older compatibility unestablished | Implemented: UBI 9.6 GNU builds, final-byte GLIBC ceiling 2.34, and digest-pinned Debian 12/Ubuntu 22.04/UBI 9 startup checks. Full compiler/native composition qualification remains deferred. | +| Runtime/startup/file inspection | Owning runtime/startup acceptance and exact target classification | Build-only + config canaries | Additional binary type/architecture checks and full runtime qualification remain additive later work with the source inspection owner. | +| CLI immutable build facts | Source-controlled release/source/target/variant facts in CLI output (Task 4) | Existing package-version CLI output | Port the reviewed slice 2.3 immutable facts now, preserving the labelled source-identity schema. | +| SBOM precision | Complete Cargo/native/JS composition and retained-byte joins | Conservative Cargo target/npm build-tree inventory; native/std/runtime gaps | Keep gaps explicit in SPDX and docs. Enrich the same SPDX companion later; do not label the smaller inventory complete. | +| Assurance profiles | Fastly `provenance-sbom-repeatable`; native `provenance-sbom` with full evidence | No repeatability/full evidence | Preserve destination profiles in cohort; do not emit an index/profile claim until its gates hold. Native reproduction remains a measurement; Fastly reproducibility is a later gate. | ### Publication contract and nonbreaking recovery plan @@ -137,8 +176,7 @@ extra features, `variant: default`, `assurance_profile: provenance-sbom`, This adds production Axum distributions by explicit scope decision. `static` means no dynamic libc/loader requirement, not qualification of every Linux -kernel. The historical cohort identifier `phase1-fastly-cli` and its original -four entries remain unchanged. **No Phase 1 reader** enforces these records or +kernel. The historical cohort identifier `phase1-fastly-cli` is a destination-only label for the original contract; it is not the current file count or a qualification claim. The Fastly `provenance-sbom-repeatable` profile is destination-only: Phase 1 makes no repeatability claim. Its original four entries remain unchanged. **No Phase 1 reader** enforces these records or their destination assurance profiles/capability labels. `release-tools.json` and consumer `release-policy.json` remain byte-for-byte unchanged. The workflow's explicit seven selections own the Phase 1 builds; no supplemental tool file or @@ -158,12 +196,10 @@ The builder's system packages are acquired through DNF and are not a fully qualified or pinned native tool closure. Musl uses Ubuntu's exact `1.2.4-2` `musl`, `musl-dev` and `musl-tools` packages, the explicit `musl-gcc` linker and GCC 13 driver, whose actual version is logged. Target-specific C/linker/static -flags leave host SBOM-tool builds unchanged. No allocator or Rust source changes -are included; consider mimalloc/jemalloc for musl under load later. +flags leave host SBOM-tool builds unchanged. No allocator changes are included; allocator/load qualification remains later work. Builds scan the final bytes for config canaries and check locked inputs are -unchanged. Before upload they check wasm magic/version, each CLI's existing -`ts 0.1.0` package-version output, GNU symbol ceilings and static-musl loader +unchanged. Before upload they check wasm magic/version, each CLI's labelled package/source/target/variant output, GNU symbol ceilings and static-musl loader requirements. GNU x86 binaries start on Debian 12-slim, Ubuntu 22.04 and UBI 9.6 minimal; ARM Axum starts on Debian 12-slim on the ARM runner; musl starts on pinned Alpine and a scratch-like root filesystem with a read-only runtime CA @@ -172,7 +208,7 @@ so an entirely empty trust store fails even a health request. Every Axum smoke m the runner's system certificates at `/ca.pem` and sets `SSL_CERT_FILE`; operators must supply runtime trust data independently. No dynamic libraries are added. Acquired builder/runtime image IDs -are logged. Axum has no help/version mode; its isolated, read-only, nonroot +are logged. Axum has no help/version mode; its bridge-networked, read-only, nonroot container receives only a fictional runtime envelope/secrets and must return `200 ok` from its configured health router. This does not establish publisher proxy/load qualification or complete native composition. @@ -233,3 +269,17 @@ workflows, scripts, tests or release bytes. The immutable `.2`, `.3`, `.4` and `.5` releases remain intact; no drafts remain from these rehearsals. No upstream tags, releases or repository settings were changed. + +### Phase 1 review boundary notes + +The reusable caller declares the maximum signing permissions required by its +called workflow. GitHub does not allow a read-only caller to invoke a nested +workflow that declares greater permissions, even for conditionally skipped jobs. +Actual build/check jobs narrow permissions to contents-read; signing remains +tag-only. Splitting a new build-only workflow is deferred to preserve this slice. + +The configuration scanner covers literal/raw/concatenated macros and literal +build-script deployment prefixes. It is a best-effort source regression audit, +not Rust expansion or dataflow analysis: computed names, procedural macros and +third-party build scripts are not completely analyzed. Planted canary scans and +clean inherited environments provide additional checks, not hermeticity. diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json index 0e2e63b6d0..84f58a1e8e 100644 --- a/docs/superpowers/plans/binary-attestations-slice-map.json +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -5,7 +5,8 @@ "notes": [ "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." + "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." ], "groups": { "phase-1-contract-and-linked-evidence": { @@ -665,5 +666,16 @@ "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index.", "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json": "Update for Axum: Candidate companions and digest joins expand alongside the seven-artifact candidate index." } + }, + "phase1_interim_producers": { + "scripts/release/phase1-build.sh": { + "replaced_by": ["2.5"] + }, + "scripts/release/phase1.mjs": { + "replaced_by": ["2.5", "2.6"] + }, + ".github/workflows/release.yml inline publisher": { + "replaced_by": ["2.8"] + } } } diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 34df724e33..1ef9a167a6 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -4,7 +4,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. -**Phase 1 closes #1235:** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the reusable `release-build.yml` signer and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +**Phase 1 contributes to #1235 (D7 settings pending):** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the reusable `release-build.yml` signer and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. The phase boundaries follow [aram356's round-1 architecture review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857): @@ -33,7 +33,7 @@ and [CLI version output](https://github.com/IABTechLab/trusted-server/blob/f9281 - Rust is pinned to **1.98.1**. The tracked `Cargo.toml`, `Cargo.lock`, `.cargo/config.toml`, tool pins, source tree and release profile remain inputs. -- The two workspace build scripts are core's `build.rs` and JS's `build.rs`. +- Workspace build scripts include core and JS plus the new CLI and Fastly owner `build.rs` files. Core also reads `build_support/git_version.rs`, its source tree, the root manifest/lockfile and tracked `edgezero.toml`. The latter supplies the logical `trusted_server_config` store ID, not a physical deployment binding. @@ -65,7 +65,7 @@ with independently obtained roots and source pins. Offline success is build proof, not current official membership or deployment authorization. For #161, release Fastly `x-ts-version` is the full 40-character source commit; -`ts --version` currently exposes the command name `ts` and Cargo package version. The executable +`ts --version` labels package version, source commit, target and variant. The executable digest is verified externally; exposing it at runtime remains with #161. The [verification guide](../../guide/release-verification.md) records these facts and the maintainer settings gates. Local Viceroy execution proves a local route, @@ -84,6 +84,46 @@ and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementa `937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. Rehearsal `.4` remains historical evidence for the original four assets only. +### Phase 1 review notes — rehearsal .6 pending + +Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede +historical phase applicability, not the retained destination requirements. +Goals, Non-goals, R-COHORT and R-BOUNDS refer to the original four-artifact design; +this slice explicitly includes seven binaries / 29 files, including Axum and +Linux ARM64. Its original four public names stay unchanged; no v1 index is emitted. +The Node producer helpers and inline publisher are interim exceptions to the +retained Rust-producer constraint, replaced by slices 2.5 (recipes/inspection), +2.6 (composition/SPDX) and 2.8 (publication). The frozen Status and tool versions +below describe #1262, not current Phase 1 implementation status. + +Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; +verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. +Rehearsal .5 is historical evidence only; this corrective batch needs .6. + +Slice 2.3 immutable build facts are reused from introducing commit +`bfb697404551a28217af8906024506095bcd9559` and revision +`6d0efda7210983021ab3c9efe8a0a3190e9eb81b`, as retained at the frozen source. +Core's resolver and new CLI/Fastly owner build scripts validate +`TRUSTED_SERVER__RELEASE_BUILD=1` and the full lowercase source SHA. +Cargo supplies the owning target/package; features supply the variant. Tags and +run IDs stay external. CLI now labels package/source/target/variant; Fastly queues +the same immutable facts through existing startup diagnostics. The source audit +permits exactly this release control, GIT_VERSION and Cargo-owned BUILD_TARGET, +not deployment values. CLI source-resource cleanup remains deferred. + +Axum binding is runtime-only: `EDGEZERO__ADAPTER__HOST` defaults to loopback; +containers can set `0.0.0.0`, with the existing `PORT` override. The smoke uses +bridge networking and reaches the process through a published host port. +TLS termination, graceful shutdown and request/resource limits remain follow-up. + +Publication requires `RELEASE_PUBLICATION_ENABLED=true` only after maintainers +confirm immutable releases and the v\* tag ruleset. There is no settings token or +prepublication settings API call, following the reviewed signing boundary; +postpublication immutable=true readback fails loudly. Hyphenated tags are +prereleases and never Latest. Stable tags are normal releases. Eligibility of +rc-branch tags is an unresolved maintainer decision; Phase 1 does not implement +the destination branch-ancestry/ruleset validator or authorize rc tags. + The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. **Issue:** [#1235 — Publish attested release binaries with build provenance and SBOMs](https://github.com/IABTechLab/trusted-server/issues/1235) From e22d7005205497984de3641497d013f4ceb02a0d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 13:28:39 +0530 Subject: [PATCH 021/104] Reduce startup logging and align runtime port aliases --- .../trusted-server-adapter-axum/src/main.rs | 91 +++++++++++++++---- .../trusted-server-adapter-fastly/src/main.rs | 49 +++++++--- 2 files changed, 108 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 337399974f..269b3ccde9 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -29,10 +29,12 @@ fn main() { // The default is loopback; runtime overrides are passed directly to EdgeZero. let host = std::env::var_os("EDGEZERO__ADAPTER__HOST"); let port = std::env::var_os("PORT"); - let addr = parse_bind_address(host.as_deref(), port.as_deref()).unwrap_or_else(|error| { - log::error!("Invalid runtime bind address: {error}"); - std::process::exit(1); - }); + let adapter_port = std::env::var_os("EDGEZERO__ADAPTER__PORT"); + let addr = parse_bind_address(host.as_deref(), port.as_deref(), adapter_port.as_deref()) + .unwrap_or_else(|error| { + log::error!("Invalid runtime bind address: {error}"); + std::process::exit(1); + }); let config = AxumDevServerConfig { addr, enable_ctrl_c: true, @@ -74,6 +76,7 @@ fn parse_log_level(configured: Option<&str>) -> Result { fn parse_bind_address( host: Option<&std::ffi::OsStr>, port: Option<&std::ffi::OsStr>, + adapter_port: Option<&std::ffi::OsStr>, ) -> Result { let host = match host { None => std::net::IpAddr::from([127, 0, 0, 1]), @@ -82,13 +85,25 @@ fn parse_bind_address( .and_then(|value| value.parse().ok()) .ok_or("EDGEZERO__ADAPTER__HOST must be an IP address")?, }; - let port = match port { - None => 8787, - Some(value) => value - .to_str() - .and_then(|value| value.parse().ok()) - .ok_or("PORT must be an integer from 0 to 65535")?, + let parse_port = |value: Option<&std::ffi::OsStr>, error| { + value + .map(|value| { + value + .to_str() + .and_then(|value| value.parse::().ok()) + .ok_or(error) + }) + .transpose() }; + let port = parse_port(port, "PORT must be an integer from 0 to 65535")?; + let adapter_port = parse_port( + adapter_port, + "EDGEZERO__ADAPTER__PORT must be an integer from 0 to 65535", + )?; + if matches!((port, adapter_port), (Some(port), Some(adapter)) if port != adapter) { + return Err("PORT and EDGEZERO__ADAPTER__PORT must agree when both are set"); + } + let port = adapter_port.or(port).unwrap_or(8787); Ok(std::net::SocketAddr::new(host, port)) } @@ -158,17 +173,57 @@ mod tests { ); } + #[test] + fn adapter_port_alias_is_validated_alongside_port() { + let port = std::ffi::OsStr::new("8080"); + for (legacy, adapter) in [ + (None, Some(port)), + (Some(port), None), + (Some(port), Some(port)), + ] { + assert_eq!( + parse_bind_address(None, legacy, adapter) + .expect("should accept matching runtime ports") + .port(), + 8080, + "should resolve either runtime port name" + ); + } + assert!( + parse_bind_address(None, Some(port), Some(std::ffi::OsStr::new("8081"))).is_err(), + "should reject conflicting port names" + ); + for invalid in ["", " ", " 80", "invalid", "65536"] { + let invalid = std::ffi::OsStr::new(invalid); + assert!( + parse_bind_address(None, Some(port), Some(invalid)).is_err(), + "should reject invalid adapter port even with valid PORT" + ); + assert!( + parse_bind_address(None, Some(invalid), Some(port)).is_err(), + "should reject invalid PORT even with valid adapter port" + ); + } + #[cfg(unix)] + assert!( + parse_bind_address(None, Some(port), Some(std::ffi::OsStr::from_bytes(b"\xff"))) + .is_err(), + "should reject non-Unicode adapter port" + ); + } + #[test] fn bind_address_defaults_and_runtime_overrides() { assert_eq!( - parse_bind_address(None, None).expect("should default"), + parse_bind_address(None, None, None).expect("should default"), "127.0.0.1:8787".parse().expect("should parse fixture"), "should retain loopback default" ); assert_eq!( parse_bind_address( Some(std::ffi::OsStr::new("0.0.0.0")), - Some(std::ffi::OsStr::new("8080")) + Some(std::ffi::OsStr::new("8080")), + None ) .expect("should override") .to_string(), @@ -176,14 +231,14 @@ mod tests { "should bind all interfaces at runtime" ); assert_eq!( - parse_bind_address(Some(std::ffi::OsStr::new("::1")), None) + parse_bind_address(Some(std::ffi::OsStr::new("::1")), None, None) .expect("should accept IPv6") .to_string(), "[::1]:8787", "should accept IPv6" ); assert_eq!( - parse_bind_address(None, Some(std::ffi::OsStr::new("9000"))) + parse_bind_address(None, Some(std::ffi::OsStr::new("9000")), None) .expect("should preserve PORT") .port(), 9000, @@ -195,24 +250,24 @@ mod tests { fn bind_address_rejects_invalid_explicit_values() { for host in ["", "localhost", "0.0.0.0:8080", " 0.0.0.0"] { assert!( - parse_bind_address(Some(std::ffi::OsStr::new(host)), None).is_err(), + parse_bind_address(Some(std::ffi::OsStr::new(host)), None, None).is_err(), "should reject invalid host" ); } for port in ["", "invalid", "65536", " 80"] { assert!( - parse_bind_address(None, Some(std::ffi::OsStr::new(port))).is_err(), + parse_bind_address(None, Some(std::ffi::OsStr::new(port)), None).is_err(), "should reject invalid port" ); } #[cfg(unix)] { assert!( - parse_bind_address(Some(std::ffi::OsStr::from_bytes(b"\xff")), None).is_err(), + parse_bind_address(Some(std::ffi::OsStr::from_bytes(b"\xff")), None, None).is_err(), "should reject non-Unicode host" ); assert!( - parse_bind_address(None, Some(std::ffi::OsStr::from_bytes(b"\xff"))).is_err(), + parse_bind_address(None, Some(std::ffi::OsStr::from_bytes(b"\xff")), None).is_err(), "should reject non-Unicode port" ); } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index c3e8090f43..cfb4fe7fd9 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -109,7 +109,7 @@ fn main() { // Held outside the sandbox: `serve_custom` owns the `Sandbox` and exposes // no slot for application state that is not the retained payload. - let mut startup = startup_diagnostics(diagnostics); + let mut startup = startup_diagnostics(mode, diagnostics); match mode { ServeMode::Single => { @@ -125,15 +125,17 @@ fn main() { } } -fn startup_diagnostics(diagnostics: Vec) -> StartupDiagnostics { +fn startup_diagnostics(mode: ServeMode, diagnostics: Vec) -> StartupDiagnostics { let mut startup = StartupDiagnostics::default(); - startup.push(format!( - "Build facts: package={} source={} target={} variant={}", - BUILD_INFO.package_version(), - BUILD_INFO.source().unwrap_or("unknown"), - BUILD_INFO.target(), - BUILD_INFO.variant() - )); + if matches!(mode, ServeMode::Reuse(_)) { + startup.push(format!( + "Build facts: package={} source={} target={} variant={}", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target(), + BUILD_INFO.variant() + )); + } for message in diagnostics { startup.push(message); } @@ -966,18 +968,37 @@ mod tests { } #[test] - fn startup_queues_one_build_fact_message_for_existing_log_flush() { - let startup = startup_diagnostics(Vec::new()); + fn single_mode_does_not_queue_build_facts() { + let startup = startup_diagnostics(ServeMode::Single, Vec::new()); + assert_eq!( + startup.pending(), + 0, + "should omit build facts in single mode" + ); + let startup = startup_diagnostics(ServeMode::Single, vec!["example diagnostic".to_owned()]); assert_eq!( startup.pending(), 1, - "should queue exactly one build fact message" + "should preserve existing startup diagnostics" + ); + } + + #[test] + fn reuse_mode_retains_one_build_fact_message() { + let limits = crate::sandbox::SandboxLimits { + max_requests: 2, + max_memory_mib: 128, + max_lifetime: std::time::Duration::from_secs(60), + timeout: std::time::Duration::from_secs(1), + }; + let startup = startup_diagnostics( + ServeMode::Reuse(limits), + vec!["example diagnostic".to_owned()], ); - let startup = startup_diagnostics(vec!["example diagnostic".to_owned()]); assert_eq!( startup.pending(), 2, - "should preserve existing startup diagnostics" + "should retain build facts and supplied diagnostics in reuse mode" ); } From 2cc39ecb24ebe552c9362aacfe7c17e1a7c6cb2d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 13:28:39 +0530 Subject: [PATCH 022/104] Harden release verification and preflight boundaries --- .github/workflows/format.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/release.yml | 51 ++++- AGENTS.md | 3 +- scripts/release/phase1-build.sh | 4 +- scripts/release/phase1.test.mjs | 319 ++++++++++++++++++++++++---- 6 files changed, 327 insertions(+), 54 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index f458572eb7..4c9f085b39 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -189,7 +189,7 @@ jobs: node-version: ${{ steps.node-version.outputs.node-version }} - name: Run shellcheck - run: shellcheck scripts/docs-proposal/*.sh + run: shellcheck scripts/docs-proposal/*.sh scripts/release/phase1-build.sh - name: Test hunk review builder run: node --test scripts/docs-proposal/hunks.test.mjs diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 6641adbf70..251840eaf4 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -95,7 +95,7 @@ jobs: retention-days: 7 sign: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.RELEASE_PUBLICATION_ENABLED == 'true' needs: build runs-on: ubuntu-24.04 timeout-minutes: 15 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b92b072d4b..9f6cb4dfaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,6 +114,7 @@ jobs: const fs = require('node:fs'); const { createHash } = require('node:crypto'); const { execFileSync } = require('node:child_process'); + const { isDeepStrictEqual } = require('node:util'); const { GH_REPO: repo, RELEASE_TAG: tag, GH_TOKEN: token, SOURCE_COMMIT: source } = process.env; if (!/^[\w-]+\/[\w.-]+$/.test(repo) || !/^v[\w.+-]+$/.test(tag)) throw Error('Invalid repository or tag'); if (process.env.RELEASE_PUBLICATION_ENABLED !== 'true') throw Error('Release publication is not enabled by maintainers'); @@ -139,8 +140,8 @@ jobs: files.push('SHA256SUMS'); // Close API connections before synchronous gh checks can leave pooled sockets idle. const api = async (path, method = 'GET', body) => { - const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(method === 'GET' && path.startsWith('releases/') ? {signal:AbortSignal.timeout(10000)} : {}), ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com - if (!response.ok) throw Object.assign(Error(`GitHub API failed: ${method} ${path} ${response.status}`), {status:response.status, retryAfter:response.headers.get('retry-after')}); + const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(method === 'GET' && (path.startsWith('releases/') || path.startsWith('attestations/')) ? {signal:AbortSignal.timeout(10000)} : {}), ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com + if (!response.ok || path.startsWith('attestations/') && response.status !== 200) throw Object.assign(Error(`GitHub API failed: ${method} ${path} ${response.status}`), {status:response.status, retryAfter:response.headers.get('retry-after')}); return response.json(); }; // List releases, including drafts. Never resume, overwrite or delete a matching tag. @@ -204,7 +205,51 @@ jobs: if ((await retryRead(() => api(`releases/tags/${encodeURIComponent(tag)}`))).id !== id) throw Error('Published tag identity mismatch'); }; await verifyIdentity(); - console.log('Verified release:', (await retryRead(() => execFileSync('gh', ['release','verify',tag,'--repo',repo], {timeout:10000}))).toString()); + // The release attestation can be created after the immutable release becomes visible. + const verifyBundle = record => { + if (Date.now() >= retryDeadline) throw Error('Post-publish verification window exhausted'); + const proof = JSON.parse(execFileSync('gh', ['release','verify','--repo',repo,'--format','json','--',record.statement.predicate.tag], {timeout:10000, stdio:'pipe'})); + if (!isDeepStrictEqual(proof.attestation?.bundle, record.bundle)) throw Error('Verified release attestation bundle mismatch'); + }; + for (let attempt = 1; ; attempt++) { + if (Date.now() >= retryDeadline) throw Error('Post-publish verification window exhausted'); + // No transport/status retry here: only a valid HTTP 200 empty result may wait. + const page = await api(`attestations/sha1:${source}?per_page=100&predicate_type=release`); + if (!Array.isArray(page.attestations) || page.attestations.length >= 100) throw Error('Invalid or truncated release attestation response'); + const records = []; + for (const record of page.attestations) { + if (!record || typeof record.initiator !== 'string') throw Error('Malformed release attestation record'); + if (record.initiator !== 'github') continue; + const envelope = record.bundle?.dsseEnvelope; + if (envelope?.payloadType !== 'application/vnd.in-toto+json' || typeof envelope.payload !== 'string' || !envelope.payload || !Array.isArray(envelope.signatures) || !envelope.signatures.length) throw Error('Malformed release attestation bundle'); + const bytes = Buffer.from(envelope.payload, 'base64'); + if (bytes.toString('base64') !== envelope.payload) throw Error('Malformed release attestation payload'); + const statement = JSON.parse(bytes.toString()); + const predicate = statement.predicate; + if (statement._type !== 'https://in-toto.io/Statement/v1' || statement.predicateType !== 'https://in-toto.io/attestation/release/v0.2' || predicate?.repository !== repo || typeof predicate.tag !== 'string' || !predicate.tag || !/^[1-9][0-9]*$/.test(predicate.databaseId) || !Array.isArray(statement.subject)) throw Error('Malformed release attestation statement'); + const sources = statement.subject.filter(subject => subject.uri === `pkg:github/${repo}@${predicate.tag}`); + const subjects = statement.subject.filter(subject => !sources.includes(subject)); + if (sources.length !== 1 || sources[0].digest?.sha1 !== source || Object.keys(sources[0].digest).length !== 1 || !subjects.length || subjects.some(subject => typeof subject.name !== 'string' || !subject.name || !/^[a-f0-9]{64}$/.test(subject.digest?.sha256) || Object.keys(subject.digest).length !== 1) || new Set(subjects.map(subject => subject.name)).size !== subjects.length) throw Error('Invalid release attestation subjects'); + records.push({...record, statement, subjects}); + } + const current = records.filter(record => record.statement.predicate.tag === tag); + if (current.length > 1) throw Error('Multiple release attestations for current tag'); + if (current.length && (current[0].statement.predicate.databaseId !== String(id) || current[0].subjects.length !== files.length || current[0].subjects.some(subject => !files.includes(subject.name) || subject.digest.sha256 !== hash(regular(`release-assets/${subject.name}`))))) throw Error('Current release attestation identity or assets mismatch'); + for (const record of records.filter(record => record.statement.predicate.tag !== tag)) { + verifyBundle(record); + console.log('Ignored verified release attestation for other tag:', JSON.stringify(record.statement.predicate.tag)); + } + if (current.length) { + verifyBundle(current[0]); + console.log('Verified release:', tag, 'signature and exact attestation bundle match'); + break; + } + if (attempt === 3) throw Error('Release already published and immutable; release attestation unavailable after three checks'); + const delay = [2000, 5000][attempt - 1]; + if (Date.now() + delay >= retryDeadline) throw Error('Post-publish verification window exhausted'); + console.warn(`Release attestation availability: attempt ${attempt + 1}/3, no current-tag record, wait ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + } for (const file of files) console.log('Verified asset:', file, (await retryRead(() => execFileSync('gh', ['release','verify-asset',tag,`release-assets/${file}`,'--repo',repo], {timeout:10000}))).toString()); await verifyIdentity(); // END PUBLICATION diff --git a/AGENTS.md b/AGENTS.md index 3d5b8d85a8..662915095b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,11 +373,10 @@ Every PR must pass: 9. Native core build-digest test and lint (`cargo test -p trusted-server-core --test template_build_digest` and `cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings`) 10. Wasm clock lint: `scripts/lint-wasm-clock.sh` (crates built into the Cloudflare Worker must use `web_time`, not the `std::time::{Instant,SystemTime}` `now`/`elapsed` methods, which panic there) 11. Documentation proposal scripts: `shellcheck scripts/docs-proposal/*.sh && node --test scripts/docs-proposal/hunks.test.mjs && scripts/docs-proposal/test.sh` +12. Release producer checks: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh` --- -12. Release producer checks: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh` - ## Standard Workflow 1. **Read & plan** — understand the request, explore relevant code. diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh index b27769b65e..52784593ad 100644 --- a/scripts/release/phase1-build.sh +++ b/scripts/release/phase1-build.sh @@ -12,8 +12,8 @@ case "$build_package/$build_target/$release_asset" in esac : "${RUNNER_TEMP:?}" : "${SOURCE_COMMIT:?}" -[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] -[[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] +[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] || { echo 'Invalid source commit' >&2; exit 1; } +[[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] || { echo 'Source commit mismatch' >&2; exit 1; } for variable in TSJS_SKIP_BUILD TSJS_PREBUILT_DIR TSJS_TEST; do if printenv "$variable" >/dev/null; then echo "Refusing $variable in the release build" >&2 diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index b9c5ec9966..76ba2e2907 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -14,6 +14,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import * as fs from 'node:fs' import vm from 'node:vm' +import { isDeepStrictEqual } from 'node:util' import { checkBinary, checkSource, mergeSboms, sentinels } from './phase1.mjs' test('rejects every planted operator value, including inside binary data', () => { for (const marker of sentinels) { @@ -164,10 +165,36 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'upload-error', 'mutable', 'wrong-id', + 'attestation-after-one-wait', + 'attestation-after-two-waits', + 'attestation-never', + 'attestation-non-200', + 'attestation-201', + 'attestation-auth-error', + 'attestation-malformed', + 'attestation-wrong-tag-shape', + 'attestation-wrong-id', + 'attestation-wrong-repository', + 'attestation-wrong-source', + 'attestation-wrong-assets', + 'attestation-missing-asset', + 'attestation-extra-asset', + 'attestation-duplicate', + 'attestation-other-tag', + 'attestation-other-tag-before-current', + 'attestation-other-malformed', + 'attestation-other-unverifiable', + 'attestation-substituted-bundle', + 'attestation-verification-failure', ]) { const root = mkdtempSync(join(tmpdir(), 'phase1-publish-')) const calls = [] const uploaded = [] + const stable = ['stable', 'attestation-other-tag'].includes(mode) + const waits = [], + ignored = [] + let polls = 0, + observed = [] try { for (const asset of assets) { const build = join(root, 'build', asset) @@ -203,12 +230,58 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ) const release = (draft) => ({ id: 42, - tag_name: mode === 'stable' ? 'v1.0.0' : 'v0.0.0-example', - prerelease: mode !== 'stable' && mode !== 'prerelease-mismatch', + tag_name: stable ? 'v1.0.0' : 'v0.0.0-example', + prerelease: !stable && mode !== 'prerelease-mismatch', draft, immutable: mode !== 'mutable', assets: uploaded, }) + const attestation = (tag = release(false).tag_name) => { + const statement = { + _type: 'https://in-toto.io/Statement/v1', + predicateType: 'https://in-toto.io/attestation/release/v0.2', + predicate: { databaseId: '42', repository: 'example/project', tag }, + subject: [ + { + uri: `pkg:github/example/project@${tag}`, + digest: { sha1: 'a'.repeat(40) }, + }, + ...uploaded.map((a) => ({ + name: a.name, + digest: { sha256: a.digest.slice(7) }, + })), + ], + } + if (mode === 'attestation-wrong-id') + statement.predicate.databaseId = '43' + if (mode === 'attestation-wrong-repository') + statement.predicate.repository = 'example/other' + if (mode === 'attestation-wrong-source') + statement.subject[0].digest.sha1 = 'b'.repeat(40) + if (mode === 'attestation-wrong-assets') + statement.subject[1].digest.sha256 = '0'.repeat(64) + if (mode === 'attestation-missing-asset') statement.subject.pop() + if (mode === 'attestation-extra-asset') + statement.subject.push({ + name: 'unexpected', + digest: { sha256: '0'.repeat(64) }, + }) + if (mode === 'attestation-wrong-tag-shape') statement.predicate.tag = 42 + return { + initiator: 'github', + bundle: { + mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json', + verificationMaterial: {}, + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)).toString( + 'base64' + ), + signatures: [{ sig: 'fixture' }], + }, + }, + } + } const fetch = async (url, options = {}) => { const u = new URL(url) const method = options.method ?? 'GET' @@ -226,17 +299,42 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an : JSON.stringify(body), { status } ) + if (u.pathname.includes('/attestations/')) { + polls++ + assert.equal(method, 'GET') + assert.equal(u.searchParams.get('predicate_type'), 'release') + if (mode === 'attestation-non-200') return response({}, 503) + if (mode === 'attestation-201') return response({}, 201) + if (mode === 'attestation-auth-error') return response({}, 403) + if (mode === 'attestation-malformed') + return response({ attestations: null }) + const other = attestation('v1.0.0-rc.1') + if (mode === 'attestation-other-malformed') + other.bundle.dsseEnvelope.payload = '!invalid!' + const delay = + mode === 'attestation-after-one-wait' + ? 1 + : [ + 'attestation-after-two-waits', + 'attestation-other-tag-before-current', + ].includes(mode) + ? 2 + : mode === 'attestation-never' + ? 3 + : 0 + observed = polls <= delay ? [] : [attestation()] + if (mode === 'attestation-duplicate') observed.push(attestation()) + if (mode.startsWith('attestation-other-')) observed.unshift(other) + return response({ attestations: observed }) + } if (u.pathname.endsWith('/releases') && method === 'GET') return mode === 'api-error' ? response('denied', 503) : response(mode === 'draft' ? [release(true)] : []) if (u.pathname.endsWith('/releases') && method === 'POST') { const creation = JSON.parse(options.body) - assert.equal(creation.prerelease, mode !== 'stable') - assert.equal( - creation.make_latest, - mode === 'stable' ? 'true' : 'false' - ) + assert.equal(creation.prerelease, !stable) + assert.equal(creation.make_latest, stable ? 'true' : 'false') const body = creation.body assert.match(body, /--signer-workflow.*release-build\.yml/) assert.match(body, /--cert-identity.*release-build\.yml@refs\/tags/) @@ -273,14 +371,27 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an } const context = { Buffer, + JSON, AbortSignal, URL, - console, + console: { + log: (...args) => { + if ( + args[0] === 'Ignored verified release attestation for other tag:' + ) + ignored.push(args[1]) + }, + warn: () => {}, + }, + setTimeout: (resolve, delay) => { + waits.push(delay) + resolve() + }, fetch, process: { env: { GH_REPO: 'example/project', - RELEASE_TAG: mode === 'stable' ? 'v1.0.0' : 'v0.0.0-example', + RELEASE_TAG: stable ? 'v1.0.0' : 'v0.0.0-example', RELEASE_PUBLICATION_ENABLED: mode === 'publication-disabled' ? 'false' : 'true', SOURCE_COMMIT: 'a'.repeat(40), @@ -303,14 +414,51 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an fs.copyFileSync(join(root, a), join(root, b)), } if (name === 'node:crypto') return { createHash } + if (name === 'node:util') return { isDeepStrictEqual } if (name === 'node:child_process') - return { execFileSync: (...args) => calls.push(['gh', args]) } + return { + execFileSync: (...args) => { + calls.push(['gh', args]) + if (args[1][1] !== 'verify') + return Buffer.from('verified asset') + const tag = args[1].at(-1) + if ( + mode === 'attestation-verification-failure' || + (mode === 'attestation-other-unverifiable' && + tag === 'v1.0.0-rc.1') + ) + throw Error('Signature verification failed') + assert.ok( + polls > 0, + 'should read release-attestation availability before verification' + ) + const record = observed.find( + (r) => + JSON.parse( + Buffer.from(r.bundle.dsseEnvelope.payload, 'base64') + ).predicate.tag === tag + ) + const bundle = structuredClone(record.bundle) + if (mode === 'attestation-substituted-bundle') + bundle.dsseEnvelope.signatures[0].sig = 'different' + return Buffer.from(JSON.stringify({ attestation: { bundle } })) + }, + } throw Error(name) }, } const execute = () => vm.runInNewContext(`(async()=>{${script}\n})()`, context) - if (['success', 'idle-socket', 'stable'].includes(mode)) await execute() + const successful = [ + 'success', + 'idle-socket', + 'stable', + 'attestation-after-one-wait', + 'attestation-after-two-waits', + 'attestation-other-tag', + 'attestation-other-tag-before-current', + ].includes(mode) + if (successful) await execute() else await assert.rejects(execute, undefined, mode) if ( [ @@ -328,9 +476,58 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an assert.equal(calls.length, mode === 'api-error' ? 1 : 3) if (['upload-error'].includes(mode)) assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) - if (['success', 'idle-socket', 'stable'].includes(mode)) { + if (successful) { assert.equal(uploaded.length, 29) - assert.equal(calls.filter(([method]) => method === 'gh').length, 30) + assert.equal( + calls.filter(([method]) => method === 'gh').length, + mode === 'attestation-other-tag' + ? 31 + : mode === 'attestation-other-tag-before-current' + ? 33 + : 30 + ) + } + if (mode.startsWith('attestation-')) { + const expectedWaits = + mode === 'attestation-after-one-wait' + ? [2000] + : [ + 'attestation-after-two-waits', + 'attestation-never', + 'attestation-other-tag-before-current', + ].includes(mode) + ? [2000, 5000] + : [] + assert.deepEqual(waits, expectedWaits, mode) + assert.equal(polls, expectedWaits.length + 1, mode) + assert.equal( + calls.filter( + ([method, path]) => method === 'POST' && path.endsWith('/releases') + ).length, + 1, + mode + ) + assert.equal( + calls.filter( + ([method, path]) => method === 'POST' && path.endsWith('/assets') + ).length, + 29, + mode + ) + assert.equal( + calls.filter(([method]) => method === 'PATCH').length, + 1, + mode + ) + assert.deepEqual( + ignored, + mode === 'attestation-other-tag' + ? ['"v1.0.0-rc.1"'] + : mode === 'attestation-other-tag-before-current' + ? Array(3).fill('"v1.0.0-rc.1"') + : [], + mode + ) } } finally { rmSync(root, { recursive: true, force: true }) @@ -602,52 +799,84 @@ test('workflows select exactly the descriptive cohort and restrict actual build const actualBuild = build.split('\n build:')[1].split('\n sign:')[0] assert.doesNotMatch(actualBuild, /id-token|attestations:/) assert.match(release, /vars\.RELEASE_PUBLICATION_ENABLED == 'true'/) + assert.match( + build.split('\n sign:')[1], + /vars\.RELEASE_PUBLICATION_ENABLED == 'true'/ + ) }) -test('release preflight rejects inherited JS controls and mismatched source before tools run', () => { - const script = readFileSync('scripts/release/phase1-build.sh', 'utf8') +test('release preflight rejects inherited controls in an isolated script fixture', () => { const root = mkdtempSync(join(tmpdir(), 'phase1-preflight-')) - const packageArgs = [ + const script = join(root, 'scripts/release/phase1-build.sh') + const bin = join(root, 'bin'), + marker = join(root, 'tools-ran') + const source = 'a'.repeat(40) + const args = [ 'trusted-server-cli', 'x86_64-unknown-linux-gnu', 'ts-x86_64-unknown-linux-gnu', ] - const actual = execFileSync('git', ['rev-parse', 'HEAD'], { - encoding: 'utf8', - }).trim() try { + mkdirSync(join(root, 'scripts/release'), { recursive: true }) + mkdirSync(bin) + fs.copyFileSync('scripts/release/phase1-build.sh', script) + const stub = (name, body) => + writeFileSync(join(bin, name), '#!/bin/sh\n' + body + '\n', { + mode: 0o755, + }) + stub( + 'git', + `test "$*" = "rev-parse HEAD" || exit 96; printf '%s\\n' "$PREFLIGHT_FIXTURE_COMMIT"` + ) + stub('printenv', 'exec /usr/bin/printenv "$@"') + for (const tool of [ + 'node', + 'npm', + 'docker', + 'cargo', + 'rustup', + 'curl', + 'sudo', + ]) + stub(tool, `printf '%s\\n' "$0" >> "$PREFLIGHT_TOOL_LOG"; exit 97`) + const execute = (extra) => { + try { + execFileSync('/bin/bash', [script, ...args], { + cwd: root, + stdio: 'pipe', + env: { + PATH: bin, + RUNNER_TEMP: root, + SOURCE_COMMIT: source, + PREFLIGHT_FIXTURE_COMMIT: source, + PREFLIGHT_TOOL_LOG: marker, + ...extra, + }, + }) + assert.fail('should stop before any real tool executes') + } catch (error) { + return error + } + } for (const variable of [ 'TSJS_SKIP_BUILD', 'TSJS_PREBUILT_DIR', 'TSJS_TEST', ]) { - assert.throws( - () => - execFileSync('bash', ['-c', script, 'phase1', ...packageArgs], { - env: { - PATH: process.env.PATH, - HOME: process.env.HOME, - RUNNER_TEMP: root, - SOURCE_COMMIT: actual, - [variable]: '', - }, - stdio: 'pipe', - }), - (error) => error.stderr.toString().includes('Refusing ' + variable) - ) + const error = execute({ [variable]: '' }) + assert.equal(error.status, 1) + assert.match(error.stderr.toString(), new RegExp('Refusing ' + variable)) + assert.equal(fs.existsSync(marker), false) } - assert.throws( - () => - execFileSync('bash', ['-c', script, 'phase1', ...packageArgs], { - env: { - PATH: process.env.PATH, - HOME: process.env.HOME, - RUNNER_TEMP: root, - SOURCE_COMMIT: '0'.repeat(40), - }, - stdio: 'pipe', - }), - (error) => error.status !== 0 + for (const invalid of ['0'.repeat(40), 'A'.repeat(40), 'invalid']) + assert.equal(execute({ SOURCE_COMMIT: invalid }).status, 1) + assert.equal(fs.existsSync(marker), false) + assert.equal( + execute({}).status, + 97, + 'should reach the safe node stub when preflight passes' ) + assert.equal(readFileSync(marker, 'utf8').trim(), join(bin, 'node')) + assert.equal(fs.existsSync(join(root, 'trusted-server.toml')), false) } finally { rmSync(root, { recursive: true, force: true }) } From 7a05940e2f7b10c459bdd2d3949f265b755ce42c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 13:28:39 +0530 Subject: [PATCH 023/104] Record rehearsal failure and release recovery checks --- docs/guide/release-verification.md | 33 +++++++++++++------ ...6-10-13-attested-release-implementation.md | 8 ++--- ...inary-attestations-phase1-compatibility.md | 23 ++++++++----- ...6-10-09-1235-release-attestation-design.md | 8 ++--- 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index d928ea4b6c..652fb9f130 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -33,8 +33,9 @@ The musl asset needs no dynamic loader or libraries; Alpine and a scratch-like container are required smoke images, while distroless-static is another deployment option without a separate Phase 1 runtime test. All acquired smoke images are digest-pinned; build logs record their image IDs. These required startup checks do not establish complete platform/tool qualification. Axum defaults to `127.0.0.1:8787`. Set runtime `EDGEZERO__ADAPTER__HOST=0.0.0.0` -and `PORT=8080` for containers and publish that port. Invalid explicit values -fail startup. This runtime setting is never compiled into the executable. +and `EDGEZERO__ADAPTER__PORT=8080` for containers and publish that port. `PORT` +is a compatible alias. When both are set, they must agree; both values are +validated and any invalid explicit value fails startup. This runtime setting is never compiled into the executable. The smoke reaches a bridge-networked container through a host-published port; loopback-only binding cannot pass it. TLS termination, graceful shutdown and request/resource limits remain follow-up production hardening. @@ -44,11 +45,13 @@ request initialization. Every Axum smoke mounts the runner's system CA bundle read-only at `/ca.pem` with `SSL_CERT_FILE=/ca.pem`; production operators must supply their own runtime trust store. This is runtime data, not an embedded configuration or dynamically linked library. -The seven-binary implementation passed hosted CI and [rehearsal `.5`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438) +The historical seven-binary implementation passed hosted CI and [rehearsal `.5`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438) at `937214a632f9387601dba94f996de78b2e6ee424`; `.4` proves only the original four binaries. Native libraries and provider tools needed by individual `ts` commands retain their existing prerequisites. Cloudflare, Spin, Windows and Linux ARM64 CLI releases follow separately. +Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. + Each `.spdx.json` is a **conservative build dependency inventory** of the selected Cargo target and the installed npm build tree used for that build. It records the final executable's SHA-256. SPDX 2.3 is the published format; @@ -93,6 +96,8 @@ ASSETS=(trusted-server-fastly.wasm ts-x86_64-unknown-linux-gnu \ gh release download "$TAG" --repo "$REPO" --dir . test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.immutable')" = true +gh api "repos/$REPO/attestations/sha1:$COMMIT?per_page=100&predicate_type=release" \ + --jq '[.attestations[] | {initiator}]' gh release verify "$TAG" --repo "$REPO" for asset in "${ASSETS[@]}"; do for suffix in '' .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do @@ -255,10 +260,11 @@ For example, after verifying the x86-64 GNU asset and supplying those variables: ```bash chmod u+x ./trusted-server-axum-x86_64-unknown-linux-gnu -PORT=8080 ./trusted-server-axum-x86_64-unknown-linux-gnu +EDGEZERO__ADAPTER__PORT=8080 ./trusted-server-axum-x86_64-unknown-linux-gnu ``` -`PORT` binds Axum to loopback (`127.0.0.1`); use your reviewed reverse proxy and +With `EDGEZERO__ADAPTER__HOST` unset, either port setting retains loopback +(`127.0.0.1`); use your reviewed reverse proxy and TLS configuration for public traffic. Axum has no help/version or dry-run mode. Its build smoke starts an isolated server with a fictional external config envelope and requires `/health` to return `200` and `ok`, distinguishing a @@ -379,7 +385,7 @@ inputs. Before publication, a maintainer must enable immutable releases, protect `v*` tags and approve tag-push authority and required checks. Immutability remains a -human precondition. Set repository variable `RELEASE_PUBLICATION_ENABLED=true` only after confirming those settings with the read-only commands below. Without it, publication is skipped. There is no repository-administration token or prepublication settings API call, following the reviewed decision to check the published release instead. A +human precondition. Set repository variable `RELEASE_PUBLICATION_ENABLED=true` only after confirming those settings with the read-only commands below. Without it, both signing and publication are skipped; an early tag push creates no public attestations. There is no repository-administration token or prepublication settings API call, following the reviewed decision to check the published release instead. A violation can expose a mutable release before the workflow reports failure. Tags containing `-` publish as prereleases with Latest disabled; stable tags publish as normal releases. Whether to authorize tags from rc branches is a maintainer decision; the Phase 2 ancestry/ruleset validator is not implemented here. @@ -393,7 +399,7 @@ and the reviewed publication owner remain additive later work. There are no automatic mutation retries or draft-resume claims. Only post-publish reads may retry: release/asset metadata GETs and the GitHub -CLI release/asset verification calls. Each check has at most three attempts, +CLI asset verification calls. Each check has at most three attempts, with 2s then 5s backoff. A supplied `Retry-After` can increase a delay, capped at 10s (at most 20s waiting per check). A shared three-minute window bounds retry scheduling across the job, and individual read requests/commands time out @@ -407,6 +413,10 @@ membership or digests, and definitive verification failures stop immediately. Release listing before creation and every create/upload/publish mutation remain single-shot. +Before cryptographic release verification, a separate availability check makes at most three release-attestation GETs, with 2s then 5s backoff: at most 7s total waiting, inside the same three-minute scheduling window. Only HTTP 200 with no current-tag GitHub release attestation may wait. Any other status, malformed record, duplicate current-tag record, identity/source/asset mismatch or cryptographic verification failure stops immediately. Well-formed other-tag records on the same commit are ignored only after their exact bundles verify; logs name only the ignored tags. They never satisfy the current-tag check. Each GET/verification command has a 10s timeout; no mutation is retried. + +If the availability check exhausts its bound, the release is already published and immutable (verified before availability polling). Keep the tag and release unchanged and use the read-only recovery commands below. + If these retries are exhausted, the release has already been published; a failure does not undo publication or establish that immutability was verified. Confirm its state using the recorded release ID. For an immutable release, keep @@ -420,6 +430,8 @@ gh api "repos/$REPO/releases/$RELEASE_ID" \ --jq '{id, tag_name, draft, immutable, assets: [.assets[] | {name, size, digest}]}' test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" = "$RELEASE_ID" test "$(gh api "repos/$REPO/releases/$RELEASE_ID" --jq '.immutable')" = true +gh api "repos/$REPO/attestations/sha1:$COMMIT?per_page=100&predicate_type=release" \ + --jq '[.attestations[] | select(.initiator == "github") | .bundle.dsseEnvelope.payload | @base64d | fromjson | .predicate.tag]' gh release verify "$TAG" --repo "$REPO" for asset in "${ASSETS[@]}"; do for suffix in '' .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do @@ -492,10 +504,11 @@ The reusable caller declares the maximum signing permissions required by its called workflow. GitHub does not allow a read-only caller to invoke a nested workflow that declares greater permissions, even for conditionally skipped jobs. Actual build/check jobs narrow permissions to contents-read; signing remains -tag-only. Splitting a new build-only workflow is deferred to preserve this slice. +tag-only and requires the publication enablement variable. Splitting a new build-only workflow is deferred to preserve this slice. The configuration scanner covers literal/raw/concatenated macros and literal build-script deployment prefixes. It is a best-effort source regression audit, -not Rust expansion or dataflow analysis: computed names, procedural macros and -third-party build scripts are not completely analyzed. Planted canary scans and +not Rust expansion or dataflow analysis: `include!`, raw byte strings (`br"…"` / +`rb"…"`), computed names, procedural macros and third-party build scripts are not +completely analyzed. Planted canary scans and clean inherited environments provide additional checks, not hermeticity. diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 43cb758771..3759726fc5 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -21,7 +21,7 @@ and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementa `937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. Rehearsal `.4` remains historical evidence for the original four assets only. -### Phase 1 review notes — rehearsal .6 pending +### Phase 1 review notes — rehearsal .7 pending Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -35,7 +35,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -Rehearsal .5 is historical evidence only; this corrective batch needs .6. +Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -44,12 +44,12 @@ Core's resolver and new CLI/Fastly owner build scripts validate `TRUSTED_SERVER__RELEASE_BUILD=1` and the full lowercase source SHA. Cargo supplies the owning target/package; features supply the variant. Tags and run IDs stay external. CLI now labels package/source/target/variant; Fastly queues -the same immutable facts through existing startup diagnostics. The source audit +the same immutable facts through existing startup diagnostics in reuse mode only; single mode retains the source response header without per-request build-fact logs. The source audit permits exactly this release control, GIT_VERSION and Cargo-owned BUILD_TARGET, not deployment values. CLI source-resource cleanup remains deferred. Axum binding is runtime-only: `EDGEZERO__ADAPTER__HOST` defaults to loopback; -containers can set `0.0.0.0`, with the existing `PORT` override. The smoke uses +containers can set `0.0.0.0`, with runtime `EDGEZERO__ADAPTER__PORT` or the compatible `PORT` override. Both values are validated, and conflicting values fail startup. The smoke uses bridge networking and reaches the process through a published host port. TLS termination, graceful shutdown and request/resource limits remain follow-up. diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index a1ab3dcb77..f2d76d0fd2 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -1,6 +1,6 @@ # Phase 1 compatibility audit and approved alignment -**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Rehearsal `.5` passed at `937214a632f9387601dba94f996de78b2e6ee424`; the rehearsal history below records earlier outcomes. +**Audit baseline:** reviewed #1262 at `122b78664b1235c717e9208fd200f9a9b0c335df` versus #1435 at `4dd217125c6e38055e4bcb2cba32c0b95e739914`. The table records the audit of those commits. The approved status below supersedes earlier implementation suggestions in the audit; work in progress is not a claim of completed release qualification. The published SBOM format is **SPDX 2.3**. Historical rehearsal `.5` passed at `937214a632f9387601dba94f996de78b2e6ee424`; the rehearsal history below records earlier outcomes. **Conclusion:** the original Phase 1 signer path and CycloneDX public format would have required a later breaking policy replacement. Fix those now. Phase 1 is the first implementation slice of the existing contract: retain its public names, signer/caller distinction, SPDX semantics and original policy files. Introduce no competing release-index schema or weakened consumer policy. @@ -11,7 +11,7 @@ Frozen source references below mean: - [Cohort](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-cohort.json), [tools](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-tools.json), [consumer policy](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/release-policy.json). - [Index v1 schema](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/schemas/release-index-v1.schema.json), [distribution names](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-release/src/distribution.rs#L55), [consumer companion rules](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/documents.rs#L236), [SPDX joins](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/evidence/joins.rs#L188). -### Phase 1 review notes — rehearsal .6 pending +### Phase 1 review notes — rehearsal .7 pending Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -25,7 +25,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -Rehearsal .5 is historical evidence only; this corrective batch needs .6. +Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -34,12 +34,12 @@ Core's resolver and new CLI/Fastly owner build scripts validate `TRUSTED_SERVER__RELEASE_BUILD=1` and the full lowercase source SHA. Cargo supplies the owning target/package; features supply the variant. Tags and run IDs stay external. CLI now labels package/source/target/variant; Fastly queues -the same immutable facts through existing startup diagnostics. The source audit +the same immutable facts through existing startup diagnostics in reuse mode only; single mode retains the source response header without per-request build-fact logs. The source audit permits exactly this release control, GIT_VERSION and Cargo-owned BUILD_TARGET, not deployment values. CLI source-resource cleanup remains deferred. Axum binding is runtime-only: `EDGEZERO__ADAPTER__HOST` defaults to loopback; -containers can set `0.0.0.0`, with the existing `PORT` override. The smoke uses +containers can set `0.0.0.0`, with runtime `EDGEZERO__ADAPTER__PORT` or the compatible `PORT` override. Both values are validated, and conflicting values fail startup. The smoke uses bridge networking and reaches the process through a published host port. TLS termination, graceful shutdown and request/resource limits remain follow-up. @@ -276,10 +276,17 @@ The reusable caller declares the maximum signing permissions required by its called workflow. GitHub does not allow a read-only caller to invoke a nested workflow that declares greater permissions, even for conditionally skipped jobs. Actual build/check jobs narrow permissions to contents-read; signing remains -tag-only. Splitting a new build-only workflow is deferred to preserve this slice. +tag-only and requires the publication enablement variable. Splitting a new build-only workflow is deferred to preserve this slice. The configuration scanner covers literal/raw/concatenated macros and literal build-script deployment prefixes. It is a best-effort source regression audit, -not Rust expansion or dataflow analysis: computed names, procedural macros and -third-party build scripts are not completely analyzed. Planted canary scans and +not Rust expansion or dataflow analysis: `include!`, raw byte strings (`br"…"` / +`rb"…"`), computed names, procedural macros and third-party build scripts are not +completely analyzed. Planted canary scans and clean inherited environments provide additional checks, not hermeticity. + +### Rehearsal .6 outcome + +Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. + +The failed publisher check ran at 07:19:53.744 UTC; the independently verified release attestation has a trusted timestamp of 07:19:58 UTC. The pinned CLI hides underlying lookup errors, so the original HTTP status is unknown. The immutable prerelease and its 29 assets remain unchanged. The new availability check waits only for an HTTP 200 result without a current-tag GitHub release attestation: three checks, 2s then 5s, at most 7s total waiting within the existing scheduling window. Other-tag records on the same commit must be well-formed and their exact bundles cryptographically verified before ignoring them; their tag names alone are logged. Current-tag duplicates or identity/source/asset mismatches and every verification failure stop immediately. diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 1ef9a167a6..28ac925abe 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -84,7 +84,7 @@ and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementa `937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. Rehearsal `.4` remains historical evidence for the original four assets only. -### Phase 1 review notes — rehearsal .6 pending +### Phase 1 review notes — rehearsal .7 pending Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -98,7 +98,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -Rehearsal .5 is historical evidence only; this corrective batch needs .6. +Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -107,12 +107,12 @@ Core's resolver and new CLI/Fastly owner build scripts validate `TRUSTED_SERVER__RELEASE_BUILD=1` and the full lowercase source SHA. Cargo supplies the owning target/package; features supply the variant. Tags and run IDs stay external. CLI now labels package/source/target/variant; Fastly queues -the same immutable facts through existing startup diagnostics. The source audit +the same immutable facts through existing startup diagnostics in reuse mode only; single mode retains the source response header without per-request build-fact logs. The source audit permits exactly this release control, GIT_VERSION and Cargo-owned BUILD_TARGET, not deployment values. CLI source-resource cleanup remains deferred. Axum binding is runtime-only: `EDGEZERO__ADAPTER__HOST` defaults to loopback; -containers can set `0.0.0.0`, with the existing `PORT` override. The smoke uses +containers can set `0.0.0.0`, with runtime `EDGEZERO__ADAPTER__PORT` or the compatible `PORT` override. Both values are validated, and conflicting values fail startup. The smoke uses bridge networking and reaches the process through a published host port. TLS termination, graceful shutdown and request/resource limits remain follow-up. From f84ab219c70d3333b2176901cc084b5c8ce83170 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 15:28:25 +0530 Subject: [PATCH 024/104] Record the successful binary attestation rehearsal --- docs/guide/release-verification.md | 12 ++++---- ...6-10-13-attested-release-implementation.md | 29 +++++++++---------- ...inary-attestations-phase1-compatibility.md | 29 +++++++++++-------- ...6-10-09-1235-release-attestation-design.md | 8 ++--- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 652fb9f130..5947dc74be 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -45,12 +45,12 @@ request initialization. Every Axum smoke mounts the runner's system CA bundle read-only at `/ca.pem` with `SSL_CERT_FILE=/ca.pem`; production operators must supply their own runtime trust store. This is runtime data, not an embedded configuration or dynamically linked library. -The historical seven-binary implementation passed hosted CI and [rehearsal `.5`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438) -at `937214a632f9387601dba94f996de78b2e6ee424`; `.4` proves only the original four binaries. Native +The seven-binary implementation passed hosted CI and [rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) +at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`; `.4` and `.5` remain historical evidence. Native libraries and provider tools needed by individual `ts` commands retain their existing prerequisites. Cloudflare, Spin, Windows and Linux ARM64 CLI releases follow separately. -Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. +[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. Each `.spdx.json` is a **conservative build dependency inventory** of the selected Cargo target and the installed npm build tree used for that build. @@ -331,15 +331,15 @@ EdgeZero prepared-payload deployment and operation recovery are later phases. ### Local runtime evidence -The [rehearsal runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357352104) +The [rehearsal runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) records local execution of the verified Fastly wasm at -`937214a632f9387601dba94f996de78b2e6ee424` using pinned Viceroy 0.21.0. +`7a05940e2f7b10c459bdd2d3949f265b755ce42c` using pinned Viceroy 0.21.0. It uses the example configuration with loopback publisher/origin values, public development secrets and a local Node origin fixture. A real page request returned HTTP 200, the fixture marker, injected JS and the full source SHA in `x-ts-version`. Both the wasm and the CLI used to seed local config were verified before execution. No Fastly service was deployed. This proves the local prebuilt -execution route; provider submission and production operation remain separate. +execution route; default single mode also emitted no Build facts log. Provider submission and production operation remain separate. ## Build inputs, policy and release administration diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 3759726fc5..927334f949 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -4,7 +4,7 @@ This is the phased rework of [#1262](https://github.com/IABTechLab/trusted-server/pull/1262), frozen at `122b78664b1235c717e9208fd200f9a9b0c335df`. The reviewed contract below is retained as the destination design. This section controls phase applicability; historical statements about first-release gates and PR ownership below refer to the original combined proposal. No new spec approval cycle is introduced by reducing scope. -**Phase 1 contributes to #1235 (D7 settings pending):** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the release workflow and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. +**Phase 1 contributes to #1235 (D7 settings pending):** tag-triggered builds of Fastly wasm, three native CLI binaries and three production Axum binaries; SLSA provenance and a shipped, attested SBOM for each binary; draft/upload/publish of an immutable release with `SHA256SUMS`; GitHub CLI verification pinned to the reusable `release-build.yml` signer and predicate; and CI enforcement of configuration independence. Operators deploy the verified release bytes. Reproducibility is not a Phase 1 claim or gate. The source-version and fresh JS mechanisms already on main are reused. Phase 1 publishes SPDX 2.3 for a conservative selected-Cargo-target and installed-npm-build-tree inventory, with the final binary hash. CycloneDX is only an intermediate generator format. Complete composition qualification below remains Phase 2+; Phase 1 does not claim exact retained-code or native-source closure. @@ -17,11 +17,11 @@ The cohort records this explicit extension, but tools/policy and the frozen v1 index schema remain unchanged. Phase 1 emits no index. The later index/consumer slices need a reviewed seven-artifact amendment, including new target/capability forms; see the [compatibility audit](binary-attestations-phase1-compatibility.md) -and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementation at -`937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. +and slice-map annotations. Rehearsal `.8` passed for the seven-binary implementation at +`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; the compatibility audit records the complete evidence. Rehearsal `.4` remains historical evidence for the original four assets only. -### Phase 1 review notes — rehearsal .7 pending +### Phase 1 review notes — rehearsal .8 passed Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -35,7 +35,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. +[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -63,14 +63,13 @@ the destination branch-ancestry/ruleset validator or authorize rc tags. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. -Rehearsal `.5` passed at exact implementation SHA -`937214a632f9387601dba94f996de78b2e6ee424`; see the -[full online transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438), -[offline proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357351810) -and [local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357352104). -All seven builds/signing jobs, 29 immutable release assets, 28 checksum entries, -both predicates/signer checks, seven SPDX comparisons and startup smokes passed. -Later documentation-only heads retain this exact rehearsed implementation SHA. +Rehearsal `.8` passed at exact implementation SHA +`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; see the [full verification/runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). +All seven builds/signing jobs, 29 immutable prerelease assets, 28 checksum entries, +both predicates/signer checks, bundle and online-store checks, networking-denied +offline verification, SPDX comparisons and startup smokes passed. The pinned +manual Fastly route packed, validated, inspected and byte-compared the wasm; +no deployment occurred. Later review heads are docs-only since rehearsed `7a05940e2`. ### Phase 1 implementation checklist @@ -84,8 +83,8 @@ Later documentation-only heads retain this exact rehearsed implementation SHA. | #1235 acceptance item | Phase 1 evidence | | ----------------------------------------------------- | -------------------------------------------------------------------------------- | | Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | -| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.5` passed | -| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.5` verification passed | +| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.8` passed | +| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.8` verification passed | | Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | | Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | | Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index f2d76d0fd2..ef410c0bf1 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -11,7 +11,7 @@ Frozen source references below mean: - [Cohort](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-cohort.json), [tools](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-tools.json), [consumer policy](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/release-policy.json). - [Index v1 schema](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/schemas/release-index-v1.schema.json), [distribution names](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-release/src/distribution.rs#L55), [consumer companion rules](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/documents.rs#L236), [SPDX joins](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/evidence/joins.rs#L188). -### Phase 1 review notes — rehearsal .7 pending +### Phase 1 review notes — rehearsal .8 passed Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -25,7 +25,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. +[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -58,8 +58,8 @@ unchanged; the phasing sections describe this slice and its rehearsal outcome. T Phase 1 audit baseline is `4dd217125c6e38055e4bcb2cba32c0b95e739914`; the following corrections are implemented in this slice, with verification and rehearsal tracked in the PR description at its current commit. Signing and -immutable publication rehearsal `.5` passed at `937214a632f9387601dba94f996de78b2e6ee424`; see the -[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357357438). +immutable publication rehearsal `.8` passed at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`; see the +[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). The current correction aligns the local input-free `release-build.yml` signer with the `release.yml` caller/publisher, real SPDX 2.3 documents, fourteen @@ -160,8 +160,8 @@ qualified evidence. This implementation extends the frozen cohort from four to **seven binaries / 29 release files**, retaining all four original names. Rehearsal `.4` at `922c2211c` proves only the original four-binary/17-file -implementation. Rehearsal `.5` passed for the seven-binary implementation at -`937214a632f9387601dba94f996de78b2e6ee424`; the `.4` evidence is not reused for the expanded cohort. +implementation. Rehearsal `.8` passed for the seven-binary implementation at +`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; the `.4` evidence is not reused for the expanded cohort. The exact cohort diff is three appended records, all default features, empty extra features, `variant: default`, `assurance_profile: provenance-sbom`, @@ -263,12 +263,17 @@ valid. [offline proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357351810) and [build/local runtime proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6357352104). -The exact rehearsed implementation SHA above is retained even when later -commits change documentation only. This documentation update does not change -workflows, scripts, tests or release bytes. +- `v0.0.0-rehearsal.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e`: failed after immutable prerelease publication at the release-attestation lookup. Independent verification passed all 29 assets, online/offline predicates and signer pins, SPDX equality and the manual Fastly route. [Failure and independent proof](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578). -The immutable `.2`, `.3`, `.4` and `.5` releases remain intact; no drafts remain from -these rehearsals. No upstream tags, releases or repository settings were changed. +- `v0.0.0-rehearsal.7` at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`: cancelled after the macOS ARM job could not acquire a hosted runner. The remaining build was cancelled; signing/publication executed no steps and no release was created. Its lightweight tag remains unchanged. [Failure record](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6361386927). + +- `v0.0.0-rehearsal.8` at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`: all seven builds/signing jobs, immutable prerelease publication and complete independent online/offline/runtime verification passed. All 29 assets, 28 checksum entries, both predicates and signer forms, online store lookup without bundles, SPDX equality and the pinned Fastly pack/validate/inspect/cmp route passed. [Full transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). + +The latest rehearsed implementation is `7a05940e2f7b10c459bdd2d3949f265b755ce42c` from `.8`. +This review-head update is docs-only since rehearsed `7a05940e2`; workflows, +scripts, tests and release bytes retain the rehearsed implementation. + +The immutable `.2`, `.3`, `.4`, `.5`, `.6` and `.8` releases remain intact; no drafts remain. The `.7` tag has no release. No upstream tags, releases or repository settings were changed. ### Phase 1 review boundary notes @@ -287,6 +292,6 @@ clean inherited environments provide additional checks, not hermeticity. ### Rehearsal .6 outcome -Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. +Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. Fresh `.8` subsequently passed at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`; see the [full transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). The failed publisher check ran at 07:19:53.744 UTC; the independently verified release attestation has a trusted timestamp of 07:19:58 UTC. The pinned CLI hides underlying lookup errors, so the original HTTP status is unknown. The immutable prerelease and its 29 assets remain unchanged. The new availability check waits only for an HTTP 200 result without a current-tag GitHub release attestation: three checks, 2s then 5s, at most 7s total waiting within the existing scheduling window. Other-tag records on the same commit must be well-formed and their exact bundles cryptographically verified before ignoring them; their tag names alone are logged. Current-tag duplicates or identity/source/asset mismatches and every verification failure stop immediately. diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 28ac925abe..f775c2d799 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -80,11 +80,11 @@ The cohort records this explicit extension, but tools/policy and the frozen v1 index schema remain unchanged. Phase 1 emits no index. The later index/consumer slices need a reviewed seven-artifact amendment, including new target/capability forms; see the [compatibility audit](../plans/binary-attestations-phase1-compatibility.md) -and slice-map annotations. Rehearsal `.5` passed for the seven-binary implementation at -`937214a632f9387601dba94f996de78b2e6ee424`; the compatibility audit records the complete evidence. +and slice-map annotations. Rehearsal `.8` passed for the seven-binary implementation at +`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; the compatibility audit records the complete evidence. Rehearsal `.4` remains historical evidence for the original four assets only. -### Phase 1 review notes — rehearsal .7 pending +### Phase 1 review notes — rehearsal .8 passed Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -98,7 +98,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. This corrective batch requires a fresh `.7` rehearsal. +[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision From 149754d357901ec874eab6140e92bce3d8613085 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 16:41:21 +0530 Subject: [PATCH 025/104] Handle attestation availability and verify published bytes --- .github/workflows/release.yml | 67 ++++--- CHANGELOG.md | 2 +- .../trusted-server-adapter-axum/src/main.rs | 78 +++++--- docs/guide/release-verification.md | 99 +++------- ...6-10-13-attested-release-implementation.md | 38 ++-- ...inary-attestations-phase1-compatibility.md | 19 +- ...6-10-09-1235-release-attestation-design.md | 10 +- scripts/release/phase1.mjs | 36 +++- scripts/release/phase1.test.mjs | 174 +++++++++++++++--- 9 files changed, 335 insertions(+), 188 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f6cb4dfaf..ed8efdfb98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,7 +118,7 @@ jobs: const { GH_REPO: repo, RELEASE_TAG: tag, GH_TOKEN: token, SOURCE_COMMIT: source } = process.env; if (!/^[\w-]+\/[\w.-]+$/.test(repo) || !/^v[\w.+-]+$/.test(tag)) throw Error('Invalid repository or tag'); if (process.env.RELEASE_PUBLICATION_ENABLED !== 'true') throw Error('Release publication is not enabled by maintainers'); - const prerelease = tag.includes('-'), make_latest = prerelease ? 'false' : 'true'; + const prerelease = tag.includes('-'), make_latest = prerelease ? 'false' : 'legacy'; const names = ['trusted-server-axum-x86_64-unknown-linux-gnu', 'trusted-server-axum-x86_64-unknown-linux-musl', 'trusted-server-axum-aarch64-unknown-linux-gnu', 'trusted-server-fastly.wasm', 'ts-aarch64-apple-darwin', 'ts-x86_64-apple-darwin', 'ts-x86_64-unknown-linux-gnu']; const hash = bytes => createHash('sha256').update(bytes).digest('hex'); const regular = path => { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.size === 0) throw Error(`Invalid file: ${path}`); return fs.readFileSync(path); }; @@ -139,8 +139,8 @@ jobs: fs.writeFileSync('release-assets/SHA256SUMS', files.map(file => `${hash(regular(`release-assets/${file}`))} ${file}\n`).join('')); files.push('SHA256SUMS'); // Close API connections before synchronous gh checks can leave pooled sockets idle. - const api = async (path, method = 'GET', body) => { - const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(method === 'GET' && (path.startsWith('releases/') || path.startsWith('attestations/')) ? {signal:AbortSignal.timeout(10000)} : {}), ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com + const api = async (path, method = 'GET', body, timeout = 10000) => { + const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {method, headers:{Connection:'close', Authorization:`Bearer ${token}`, Accept:'application/vnd.github+json', 'Content-Type':'application/json'}, ...(method === 'GET' && (path.startsWith('releases/') || path.startsWith('attestations/')) ? {signal:AbortSignal.timeout(timeout)} : {}), ...(body ? {body:JSON.stringify(body)} : {})}); // allow-domain: api.github.com if (!response.ok || path.startsWith('attestations/') && response.status !== 200) throw Object.assign(Error(`GitHub API failed: ${method} ${path} ${response.status}`), {status:response.status, retryAfter:response.headers.get('retry-after')}); return response.json(); }; @@ -155,7 +155,7 @@ jobs: const body = [ `[Verification guide](https://github.com/${repo}/blob/${tag}/docs/guide/release-verification.md)`, '', '```bash', 'set -euo pipefail', `REPO=${repo}`, `TAG=${tag}`, `COMMIT=${source}`, `ASSETS=(${names.join(' ')})`, - 'gh release download "$TAG" --repo "$REPO"', 'test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq .immutable)" = true', 'gh release verify "$TAG" --repo "$REPO"', + 'gh release download "$TAG" --repo "$REPO" --dir .', 'test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq .immutable)" = true', 'gh release verify "$TAG" --repo "$REPO"', 'for asset in "${ASSETS[@]}"; do', ' for suffix in "" .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO"; done', ' for kind in provenance sbom; do', @@ -180,21 +180,26 @@ jobs: const published = await api(`releases/${id}`, 'PATCH', {draft:false, prerelease, make_latest}); // BEGIN READ RETRIES const retryDeadline = Date.now() + 180000; - const retryRead = async operation => { - for (let attempt = 1; ; attempt++) try { return await operation(); } catch (error) { + const retryRead = async (operation, deadline = retryDeadline) => { + for (let attempt = 1; ; attempt++) try { + if (Date.now() >= deadline) throw Error('Post-publish verification window exhausted'); + const result = await operation(); + if (Date.now() >= deadline) throw Error('Post-publish verification window exhausted'); + return result; + } catch (error) { const stderr = error.stderr?.toString().trim() ?? ''; const transient = !error.stdout?.length && (error.name === 'TimeoutError' || error.status === 429 || error.status >= 500 && error.status <= 599 || /^(ECONNRESET|ETIMEDOUT|EAI_AGAIN|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)$/.test(error.cause?.code ?? error.code) || /^HTTP (429|5\d\d): [^\r\n]+$/.test(stderr)); if (!transient || attempt === 3) throw error; const after = Number(error.retryAfter) * 1000 || Date.parse(error.retryAfter) - Date.now(); const delay = Math.min(10000, Math.max([2000, 5000][attempt - 1], Number.isFinite(after) ? after : 0)); - if (Date.now() + delay >= retryDeadline) throw Error('Post-publish retry window exhausted'); + if (Date.now() + delay >= deadline) throw Error('Post-publish retry window exhausted'); console.warn(`Post-publish read retry: attempt ${attempt + 1}/3, transient ${error.status ?? 'transport/CLI'} failure, wait ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } }; // END READ RETRIES const verifyIdentity = async () => { - const release = await retryRead(() => api(`releases/${id}`)); + const release = await retryRead(() => api(`releases/${id}`, 'GET', undefined, Math.max(1, Math.min(10000, retryDeadline - Date.now())))); if (published.id !== id || release.id !== id || release.tag_name !== tag || release.draft !== false || release.immutable !== true || release.prerelease !== prerelease) throw Error('Published release identity or immutability mismatch'); if (!Array.isArray(release.assets) || release.assets.length !== files.length || new Set(release.assets.map(asset => asset.name)).size !== files.length) throw Error('Published asset set mismatch'); for (const asset of release.assets) { @@ -202,19 +207,25 @@ jobs: const bytes = regular(`release-assets/${asset.name}`); if (asset.size !== bytes.length || asset.digest !== `sha256:${hash(bytes)}`) throw Error('Published asset digest mismatch'); } - if ((await retryRead(() => api(`releases/tags/${encodeURIComponent(tag)}`))).id !== id) throw Error('Published tag identity mismatch'); + if ((await retryRead(() => api(`releases/tags/${encodeURIComponent(tag)}`, 'GET', undefined, Math.max(1, Math.min(10000, retryDeadline - Date.now()))))).id !== id) throw Error('Published tag identity mismatch'); }; await verifyIdentity(); // The release attestation can be created after the immutable release becomes visible. - const verifyBundle = record => { + const availabilityDeadline = Math.min(retryDeadline, Date.now() + 60000); + const verifyBundle = async (record, otherTag = false) => { if (Date.now() >= retryDeadline) throw Error('Post-publish verification window exhausted'); - const proof = JSON.parse(execFileSync('gh', ['release','verify','--repo',repo,'--format','json','--',record.statement.predicate.tag], {timeout:10000, stdio:'pipe'})); + const deadline = otherTag ? availabilityDeadline : retryDeadline; + const read = () => execFileSync('gh', ['release','verify','--repo',repo,'--format','json','--',record.statement.predicate.tag], {timeout:Math.max(1, Math.min(10000, deadline - Date.now())), stdio:'pipe'}); + const proof = JSON.parse(otherTag ? await retryRead(read, deadline) : read()); + if (Date.now() >= deadline) throw Error('Post-publish verification window exhausted'); if (!isDeepStrictEqual(proof.attestation?.bundle, record.bundle)) throw Error('Verified release attestation bundle mismatch'); }; for (let attempt = 1; ; attempt++) { - if (Date.now() >= retryDeadline) throw Error('Post-publish verification window exhausted'); - // No transport/status retry here: only a valid HTTP 200 empty result may wait. - const page = await api(`attestations/sha1:${source}?per_page=100&predicate_type=release`); + if (Date.now() >= availabilityDeadline) throw Error('Release attestation availability window exhausted'); + // Only this endpoint maps HTTP 404 to an unavailable record. Other statuses fail. + let page, missing = false; + try { page = await api(`attestations/sha1:${source}?per_page=100&predicate_type=release`, 'GET', undefined, Math.min(4000, availabilityDeadline - Date.now())); } + catch (error) { if (error.status !== 404) throw error; page = {attestations:[]}; missing = true; } if (!Array.isArray(page.attestations) || page.attestations.length >= 100) throw Error('Invalid or truncated release attestation response'); const records = []; for (const record of page.attestations) { @@ -236,21 +247,35 @@ jobs: if (current.length > 1) throw Error('Multiple release attestations for current tag'); if (current.length && (current[0].statement.predicate.databaseId !== String(id) || current[0].subjects.length !== files.length || current[0].subjects.some(subject => !files.includes(subject.name) || subject.digest.sha256 !== hash(regular(`release-assets/${subject.name}`))))) throw Error('Current release attestation identity or assets mismatch'); for (const record of records.filter(record => record.statement.predicate.tag !== tag)) { - verifyBundle(record); + await verifyBundle(record, true); console.log('Ignored verified release attestation for other tag:', JSON.stringify(record.statement.predicate.tag)); } if (current.length) { - verifyBundle(current[0]); + await verifyBundle(current[0]); + console.log(attempt === 1 ? 'Release attestation available on first check' : `Release attestation available on check ${attempt}/5`); console.log('Verified release:', tag, 'signature and exact attestation bundle match'); break; } - if (attempt === 3) throw Error('Release already published and immutable; release attestation unavailable after three checks'); - const delay = [2000, 5000][attempt - 1]; - if (Date.now() + delay >= retryDeadline) throw Error('Post-publish verification window exhausted'); - console.warn(`Release attestation availability: attempt ${attempt + 1}/3, no current-tag record, wait ${delay}ms`); + if (attempt === 5) throw Error('Release already published and immutable; release attestation unavailable after five checks'); + const delay = [2000, 5000, 10000, 20000][attempt - 1]; + if (Date.now() + delay >= availabilityDeadline) throw Error('Release attestation availability window exhausted'); + console.warn(`Release attestation availability: attempt ${attempt + 1}/5, ${missing ? 'HTTP 404' : 'HTTP 200, no current-tag record'}, wait ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } - for (const file of files) console.log('Verified asset:', file, (await retryRead(() => execFileSync('gh', ['release','verify-asset',tag,`release-assets/${file}`,'--repo',repo], {timeout:10000}))).toString()); + for (const file of files) console.log('Verified asset:', file, (await retryRead(() => execFileSync('gh', ['release','verify-asset',tag,`release-assets/${file}`,'--repo',repo], {timeout:Math.max(1, Math.min(10000, retryDeadline - Date.now()))}))).toString()); + // Re-download into a fresh directory on every transport retry; verify bytes outside retries. + let download; + try { + await retryRead(() => { + if (download) fs.rmSync(download, {recursive:true, force:true}); + download = fs.mkdtempSync('published-download-'); + return execFileSync('gh', ['release','download',tag,'--repo',repo,'--dir',download], {timeout:Math.max(1, Math.min(60000, retryDeadline - Date.now())), stdio:'pipe'}); + }); + exact(download, files); + if (!regular(`${download}/SHA256SUMS`).equals(regular('release-assets/SHA256SUMS'))) throw Error('Downloaded SHA256SUMS mismatch'); + for (const file of files) if (!regular(`${download}/${file}`).equals(regular(`release-assets/${file}`))) throw Error(`Downloaded asset mismatch: ${file}`); + console.log('Verified post-publish re-download: all', files.length, 'assets byte-identical to signed checksums and handoff'); + } finally { if (download) fs.rmSync(download, {recursive:true, force:true}); } await verifyIdentity(); // END PUBLICATION })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f428737e..44407886b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Attested release binaries report `x-ts-version` as the full 40-character source commit, rather than the release tag. CLI output labels package/source/target/variant, and Axum supports runtime `EDGEZERO__ADAPTER__HOST` binding with a loopback default. +- Attested release binaries report `x-ts-version` as the full 40-character source commit, rather than the release tag. CLI output labels package/source/target/variant, and Axum supports runtime `EDGEZERO__ADAPTER__HOST` and primary `EDGEZERO__ADAPTER__PORT` (legacy `PORT` must agree when both are set) binding with a loopback default. - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 269b3ccde9..0c0e84a176 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,4 +1,5 @@ use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_core::addr::resolve_bind_addr; use edgezero_core::app::Hooks as _; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; @@ -68,43 +69,52 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Parse runtime binding without compiling deployment configuration into the executable. +/// Resolve runtime binding using `EdgeZero` without embedding deployment configuration. +/// +/// `EDGEZERO__ADAPTER__PORT` is primary; legacy `PORT` must agree when both +/// are set. Ports must be 1–65535; the default is loopback port 8787. /// /// # Errors /// -/// Rejects explicitly supplied non-Unicode or malformed host/port values. +/// Rejects non-Unicode values, conflicting ports and every `EdgeZero` resolver +/// warning, including malformed addresses and port zero, rather than falling back. fn parse_bind_address( host: Option<&std::ffi::OsStr>, port: Option<&std::ffi::OsStr>, adapter_port: Option<&std::ffi::OsStr>, -) -> Result { - let host = match host { - None => std::net::IpAddr::from([127, 0, 0, 1]), - Some(value) => value - .to_str() - .and_then(|value| value.parse().ok()) - .ok_or("EDGEZERO__ADAPTER__HOST must be an IP address")?, - }; - let parse_port = |value: Option<&std::ffi::OsStr>, error| { +) -> Result { + let [host, port, adapter_port] = [host, port, adapter_port].map(|value| { value .map(|value| { value .to_str() - .and_then(|value| value.parse::().ok()) - .ok_or(error) + .ok_or_else(|| "Runtime bind values must be Unicode".to_owned()) }) .transpose() - }; - let port = parse_port(port, "PORT must be an integer from 0 to 65535")?; - let adapter_port = parse_port( - adapter_port, - "EDGEZERO__ADAPTER__PORT must be an integer from 0 to 65535", - )?; - if matches!((port, adapter_port), (Some(port), Some(adapter)) if port != adapter) { - return Err("PORT and EDGEZERO__ADAPTER__PORT must agree when both are set"); + }); + let host = host?; + let port = port?; + let adapter_port = adapter_port?; + let primary = resolve_bind_addr(host, adapter_port.or(port), None, None); + if !primary.warnings.is_empty() { + return Err(format!( + "Runtime bind address must use a valid IP and port 1–65535: {}", + primary.warnings.join("; ") + )); } - let port = adapter_port.or(port).unwrap_or(8787); - Ok(std::net::SocketAddr::new(host, port)) + if let Some(port) = port { + let legacy = resolve_bind_addr(None, Some(port), None, None); + if !legacy.warnings.is_empty() { + return Err(format!( + "PORT must be 1–65535: {}", + legacy.warnings.join("; ") + )); + } + if legacy.addr.port() != primary.addr.port() { + return Err("PORT and EDGEZERO__ADAPTER__PORT must agree when both are set".to_owned()); + } + } + Ok(primary.addr) } #[cfg(test)] @@ -193,7 +203,7 @@ mod tests { parse_bind_address(None, Some(port), Some(std::ffi::OsStr::new("8081"))).is_err(), "should reject conflicting port names" ); - for invalid in ["", " ", " 80", "invalid", "65536"] { + for invalid in ["0", "", " ", " 80", "invalid", "65536"] { let invalid = std::ffi::OsStr::new(invalid); assert!( parse_bind_address(None, Some(port), Some(invalid)).is_err(), @@ -212,6 +222,26 @@ mod tests { ); } + #[test] + fn bind_address_rejects_zero_and_accepts_port_boundaries() { + let zero = std::ffi::OsStr::new("0"); + assert!( + parse_bind_address(None, Some(zero), None).is_err(), + "should reject legacy port zero" + ); + assert!( + parse_bind_address(None, None, Some(zero)).is_err(), + "should reject primary port zero" + ); + for value in ["1", "65535"] { + let port = std::ffi::OsStr::new(value); + assert!( + parse_bind_address(None, Some(port), Some(port)).is_ok(), + "should accept port bounds" + ); + } + } + #[test] fn bind_address_defaults_and_runtime_overrides() { assert_eq!( diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index 5947dc74be..cb102ff228 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -34,7 +34,7 @@ container are required smoke images, while distroless-static is another deployme without a separate Phase 1 runtime test. All acquired smoke images are digest-pinned; build logs record their image IDs. These required startup checks do not establish complete platform/tool qualification. Axum defaults to `127.0.0.1:8787`. Set runtime `EDGEZERO__ADAPTER__HOST=0.0.0.0` and `EDGEZERO__ADAPTER__PORT=8080` for containers and publish that port. `PORT` -is a compatible alias. When both are set, they must agree; both values are +is a legacy alias. Ports must be 1–65535; zero is rejected. When both are set, they must agree; both values are validated and any invalid explicit value fails startup. This runtime setting is never compiled into the executable. The smoke reaches a bridge-networked container through a host-published port; loopback-only binding cannot pass it. TLS termination, graceful shutdown and @@ -45,12 +45,7 @@ request initialization. Every Axum smoke mounts the runner's system CA bundle read-only at `/ca.pem` with `SSL_CERT_FILE=/ca.pem`; production operators must supply their own runtime trust store. This is runtime data, not an embedded configuration or dynamically linked library. -The seven-binary implementation passed hosted CI and [rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) -at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`; `.4` and `.5` remain historical evidence. Native -libraries and provider tools needed by individual `ts` commands retain their -existing prerequisites. Cloudflare, Spin, Windows and Linux ARM64 CLI releases follow separately. - -[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. +Native libraries and provider tools needed by individual `ts` commands retain their existing prerequisites. Cloudflare, Spin, Windows and Linux ARM64 CLI releases follow separately. Each `.spdx.json` is a **conservative build dependency inventory** of the selected Cargo target and the installed npm build tree used for that build. @@ -83,84 +78,42 @@ Run this in a new private directory, using Bash. Replace the fictional tag and commit. For rehearsal, change `REPO` to the approved public sandbox and use the individually approved, never-reused rehearsal tag. + + ```bash set -euo pipefail REPO=IABTechLab/trusted-server TAG=v1.2.3 COMMIT=0123456789abcdef0123456789abcdef01234567 -ASSETS=(trusted-server-fastly.wasm ts-x86_64-unknown-linux-gnu \ - ts-aarch64-apple-darwin ts-x86_64-apple-darwin \ - trusted-server-axum-x86_64-unknown-linux-gnu \ - trusted-server-axum-x86_64-unknown-linux-musl \ - trusted-server-axum-aarch64-unknown-linux-gnu) - +ASSETS=(trusted-server-axum-x86_64-unknown-linux-gnu trusted-server-axum-x86_64-unknown-linux-musl trusted-server-axum-aarch64-unknown-linux-gnu trusted-server-fastly.wasm ts-aarch64-apple-darwin ts-x86_64-apple-darwin ts-x86_64-unknown-linux-gnu) gh release download "$TAG" --repo "$REPO" --dir . -test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.immutable')" = true -gh api "repos/$REPO/attestations/sha1:$COMMIT?per_page=100&predicate_type=release" \ - --jq '[.attestations[] | {initiator}]' +test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq .immutable)" = true gh release verify "$TAG" --repo "$REPO" for asset in "${ASSETS[@]}"; do - for suffix in '' .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do - gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO" + for suffix in "" .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO"; done + for kind in provenance sbom; do + predicate=https://slsa.dev/provenance/v1; if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi + checks=(--repo "$REPO" --bundle "$asset.$kind.sigstore.json" --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type "$predicate") + gh attestation verify "$asset" "${checks[@]}" --signer-workflow "$REPO/.github/workflows/release-build.yml" --format json > "$asset.$kind.verified.json" + if [[ "$kind" == sbom ]]; then jq -e --slurpfile sbom "$asset.spdx.json" '[.[].verificationResult.statement.predicate] | length == 1 and .[0] == $sbom[0]' "$asset.$kind.verified.json"; fi + gh attestation verify "$asset" "${checks[@]}" --cert-identity "https://github.com/$REPO/.github/workflows/release-build.yml@refs/tags/$TAG" done done gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" shasum -a 256 --check SHA256SUMS ``` -The checksums cover the other 28 assets, never `SHA256SUMS` itself. Immutable -release verification establishes membership; provenance verification establishes -the reviewed signer and source. Both are required. + -First verify both published bundles for each binary using the reusable signer -workflow. Provenance binds the binary and its SPDX document; the SBOM -attestation's subject is the **binary**, and its predicate is the SPDX document. -Do not pass the JSON asset as the SBOM attestation's subject. +Both predicates use the binary as their subject. The verified SBOM predicate must equal the shipped SPDX document. `--signer-workflow` matches a prefix; the separate `--cert-identity` check pins the exact reusable workflow at the tag. GitHub CLI makes those two identity options mutually exclusive. -```bash -WORKFLOW="$REPO/.github/workflows/release-build.yml" -for asset in "${ASSETS[@]}"; do - gh attestation verify "$asset" --repo "$REPO" \ - --bundle "$asset.provenance.sigstore.json" \ - --signer-workflow "$WORKFLOW" \ - --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --signer-digest "$COMMIT" --deny-self-hosted-runners \ - --predicate-type https://slsa.dev/provenance/v1 - gh attestation verify "$asset" --repo "$REPO" \ - --bundle "$asset.sbom.sigstore.json" \ - --signer-workflow "$WORKFLOW" \ - --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --signer-digest "$COMMIT" --deny-self-hosted-runners \ - --predicate-type https://spdx.dev/Document/v2.3 -done -``` +The checksums cover the other 28 assets, never `SHA256SUMS` itself. Immutable release verification establishes membership; provenance establishes the reviewed signer and source. Both are required. -`--signer-workflow` matches a prefix. Enforce the exact certificate identity, -including the tag ref, in separate checks: GitHub CLI makes `--signer-workflow` -and `--cert-identity` mutually exclusive. These checks use the same bundles -and independently approved source commit. Compare the verified SBOM predicate -to the published SPDX document as well. +WORKFLOW and IDENTITY below refer to the same reusable signer: ```bash +WORKFLOW="$REPO/.github/workflows/release-build.yml" IDENTITY="https://github.com/$WORKFLOW@refs/tags/$TAG" -for asset in "${ASSETS[@]}"; do - gh attestation verify "$asset" --repo "$REPO" \ - --bundle "$asset.provenance.sigstore.json" \ - --cert-identity "$IDENTITY" \ - --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --signer-digest "$COMMIT" --deny-self-hosted-runners \ - --predicate-type https://slsa.dev/provenance/v1 - gh attestation verify "$asset" --repo "$REPO" \ - --bundle "$asset.sbom.sigstore.json" \ - --cert-identity "$IDENTITY" \ - --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ - --signer-digest "$COMMIT" --deny-self-hosted-runners \ - --predicate-type https://spdx.dev/Document/v2.3 \ - --format json > "$asset.sbom-verification.json" - jq -e --slurpfile document "$asset.spdx.json" \ - 'length == 1 and .[0].verificationResult.statement.predicate == $document[0]' \ - "$asset.sbom-verification.json" -done ``` Stop if any check fails. Keep the outputs, reviewed source identity, bundles @@ -331,15 +284,7 @@ EdgeZero prepared-payload deployment and operation recovery are later phases. ### Local runtime evidence -The [rehearsal runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) -records local execution of the verified Fastly wasm at -`7a05940e2f7b10c459bdd2d3949f265b755ce42c` using pinned Viceroy 0.21.0. -It uses the example configuration with loopback publisher/origin values, -public development secrets and a local Node origin fixture. A real page request -returned HTTP 200, the fixture marker, injected JS and the full source SHA in -`x-ts-version`. Both the wasm and the CLI used to seed local config were verified -before execution. No Fastly service was deployed. This proves the local prebuilt -execution route; default single mode also emitted no Build facts log. Provider submission and production operation remain separate. +Run verified Fastly bytes with pinned Viceroy and external example configuration for local testing. A page request can check the origin response, injected JavaScript and source SHA in `x-ts-version`. Default single mode emits no Build facts log. Local execution does not establish provider submission or production operation. ## Build inputs, policy and release administration @@ -403,7 +348,7 @@ CLI asset verification calls. Each check has at most three attempts, with 2s then 5s backoff. A supplied `Retry-After` can increase a delay, capped at 10s (at most 20s waiting per check). A shared three-minute window bounds retry scheduling across the job, and individual read requests/commands time out -after 10s. `Connection: close` remains enabled for API calls. +after 10s, except bulk asset downloads, which have up to 60s capped by the remaining shared deadline. `Connection: close` remains enabled for API calls. Retries require a recognized transport error, timeout, HTTP 5xx or 429. GitHub CLI errors are classified conservatively: an otherwise empty-output @@ -413,7 +358,9 @@ membership or digests, and definitive verification failures stop immediately. Release listing before creation and every create/upload/publish mutation remain single-shot. -Before cryptographic release verification, a separate availability check makes at most three release-attestation GETs, with 2s then 5s backoff: at most 7s total waiting, inside the same three-minute scheduling window. Only HTTP 200 with no current-tag GitHub release attestation may wait. Any other status, malformed record, duplicate current-tag record, identity/source/asset mismatch or cryptographic verification failure stops immediately. Well-formed other-tag records on the same commit are ignored only after their exact bundles verify; logs name only the ignored tags. They never satisfy the current-tag check. Each GET/verification command has a 10s timeout; no mutation is retried. +Before cryptographic release verification, the availability check makes at most five release-attestation GETs, with 2s/5s/10s/20s backoff (37s total waiting). Only this endpoint treats HTTP 404 as no record yet; a valid HTTP 200 with no current-tag record may also wait. Each GET times out after at most 4s, and a separate 60s deadline bounds availability checking, including other-tag verification, within the shared three-minute scheduling window. Every other non-200 status fails immediately. Malformed records, duplicate current-tag records, identity/source/asset mismatches and definitive cryptographic failures stop immediately. Other-tag records are ignored only after their exact bundles verify; those read-only verification calls may retry recognized transient failures within the same bounds. Current-tag cryptographic verification is single-shot. Logs show every wait or availability on the first check. No mutation is retried. + +The publisher re-downloads all 29 assets into a fresh temporary directory, checks the exact file set and byte-compares them with the validated signed handoff, including identical SHA256SUMS. Only transport download failures may retry; any missing, extra or different file fails immediately. Temporary downloads are removed after each attempt. If the availability check exhausts its bound, the release is already published and immutable (verified before availability polling). Keep the tag and release unchanged and use the read-only recovery commands below. diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 927334f949..3f0b2cb702 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -17,11 +17,9 @@ The cohort records this explicit extension, but tools/policy and the frozen v1 index schema remain unchanged. Phase 1 emits no index. The later index/consumer slices need a reviewed seven-artifact amendment, including new target/capability forms; see the [compatibility audit](binary-attestations-phase1-compatibility.md) -and slice-map annotations. Rehearsal `.8` passed for the seven-binary implementation at -`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; the compatibility audit records the complete evidence. -Rehearsal `.4` remains historical evidence for the original four assets only. +and slice-map annotations. -### Phase 1 review notes — rehearsal .8 passed +### Phase 1 review notes Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -35,8 +33,6 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. - Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision `6d0efda7210983021ab3c9efe8a0a3190e9eb81b`, as retained at the frozen source. @@ -57,20 +53,12 @@ Publication requires `RELEASE_PUBLICATION_ENABLED=true` only after maintainers confirm immutable releases and the v\* tag ruleset. There is no settings token or prepublication settings API call, following the reviewed signing boundary; postpublication immutable=true readback fails loudly. Hyphenated tags are -prereleases and never Latest. Stable tags are normal releases. Eligibility of +prereleases and never Latest. Stable tags are normal releases with `make_latest: legacy`, allowing GitHub to choose Latest by version/date rather than forcing backports to Latest. Eligibility of rc-branch tags is an unresolved maintainer decision; Phase 1 does not implement the destination branch-ancestry/ruleset validator or authorize rc tags. The phase labels on requirement headings below apply only to the reduced Phase 1 subset described above; all additional clauses and requirements marked Phase 2+ remain deferred. The original assurance profiles, custom verifier, retained evidence graph and reproduction gates do not block the first Phase 1 release. The [implementation plan](../plans/2026-10-13-attested-release-implementation.md#phasing--2026-10-22) contains the acceptance mapping and exhaustive slice inventory. -Rehearsal `.8` passed at exact implementation SHA -`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; see the [full verification/runtime transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). -All seven builds/signing jobs, 29 immutable prerelease assets, 28 checksum entries, -both predicates/signer checks, bundle and online-store checks, networking-denied -offline verification, SPDX comparisons and startup smokes passed. The pinned -manual Fastly route packed, validated, inspected and byte-compared the wasm; -no deployment occurred. Later review heads are docs-only since rehearsed `7a05940e2`. - ### Phase 1 implementation checklist - [x] Copy reviewed spec, plan, feasibility and schemas from `122b78664b1235c717e9208fd200f9a9b0c335df`; preserve linked historical evidence. @@ -80,15 +68,15 @@ no deployment occurred. Later review heads are docs-only since rehearsed `7a0594 - [x] Document exact GitHub CLI provenance/SBOM and immutable-asset verification, native baselines and deployment of verified Fastly bytes; update CLI guide, navigation and changelog. - [x] Run pure local tests, actionlint, format/domain checks, touched build gates, independent and security review; push this branch and open only the requested draft PR. -| #1235 acceptance item | Phase 1 evidence | -| ----------------------------------------------------- | -------------------------------------------------------------------------------- | -| Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | -| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer; rehearsal `.8` passed | -| Provenance, SBOM predicate and asset verification | release-verification.md; rehearsal `.8` verification passed | -| Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | -| Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | -| Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | -| Operator documentation | CLI guide, release-verification, CHANGELOG | +| #1235 acceptance item | Phase 1 evidence | +| ----------------------------------------------------- | ---------------------------------------------------------------------- | +| Reviewed spec and compile-time input list | Retained R-CONFIG/R-INPUTS, this phasing section; #1262 review history | +| Tag publishes immutable binaries, SBOMs and checksums | release.yml caller/publisher and release-build.yml signer | +| Provenance, SBOM predicate and asset verification | release-verification.md; exact predicate and asset checks | +| Fresh JS; fail missing npm/install/skip | Existing main build.rs plus clean npm ci and release preflight | +| Deploy exact bytes | Manual verified Fastly package route; no reproducibility claim | +| Immutable setting and protected tags | Maintainer gate; no setting changes by this PR | +| Operator documentation | CLI guide, release-verification, CHANGELOG | ### Complete remaining-work slice map @@ -108,7 +96,7 @@ The [machine-readable inventory](binary-attestations-slice-map.json) assigns eve | 2.10 | consumer cli | | 2.11 | docs and format misc | -All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare and Spin distribution, plus full Axum consumer/platform qualification, follow these slices; Phase 1 already distributes the seven binaries described above. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection and explicit authorization of the first real tag; historical signing/publication evidence is recorded above; the corrective batch needs rehearsal `.6`. +All PR titles begin `Binary attestations: phase 2.N –`; all branches begin `feature/binary-attestations-phase2-N-`. Stack slices in order except the consumer CLI, which may branch directly from Phase 1. Keep every later slice closed: no signing triggers, production selector or qualification bypass. The source PR remains frozen and open until the user decides it is superseded. Cloudflare and Spin distribution, plus full Axum consumer/platform qualification, follow these slices; Phase 1 already distributes the seven binaries described above. Task 14 remains Phase 2.9. Human gates are immutable releases/tag protection and explicit authorization of the first real tag; signing/publication evidence is retained in the compatibility audit and release PR. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index ef410c0bf1..b755c3e5de 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -11,7 +11,7 @@ Frozen source references below mean: - [Cohort](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-cohort.json), [tools](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-tools.json), [consumer policy](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/release-policy.json). - [Index v1 schema](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/schemas/release-index-v1.schema.json), [distribution names](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-release/src/distribution.rs#L55), [consumer companion rules](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/documents.rs#L236), [SPDX joins](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/evidence/joins.rs#L188). -### Phase 1 review notes — rehearsal .8 passed +### Phase 1 review notes — corrective batch for rehearsal .9 Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -24,8 +24,8 @@ retained Rust-producer constraint, replaced by slices 2.5 (recipes/inspection), below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; -verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. +verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. Rehearsals use GitHub CLI 2.102.0, Fastly CLI 16.0.0 and Viceroy 0.21.0; the carried destination spec/plan still mention GitHub CLI 2.97.0 and Fastly CLI 15.1.0. Those carried versions are unchanged. +[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. That rehearsal applies to its exact historical implementation; the current corrective batch requires fresh rehearsal `.9`. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -47,7 +47,7 @@ Publication requires `RELEASE_PUBLICATION_ENABLED=true` only after maintainers confirm immutable releases and the v\* tag ruleset. There is no settings token or prepublication settings API call, following the reviewed signing boundary; postpublication immutable=true readback fails loudly. Hyphenated tags are -prereleases and never Latest. Stable tags are normal releases. Eligibility of +prereleases and never Latest. Stable tags are normal releases with `make_latest: legacy`; GitHub selects Latest by version/date, so backports do not force Latest. Eligibility of rc-branch tags is an unresolved maintainer decision; Phase 1 does not implement the destination branch-ancestry/ruleset validator or authorize rc tags. @@ -176,7 +176,7 @@ extra features, `variant: default`, `assurance_profile: provenance-sbom`, This adds production Axum distributions by explicit scope decision. `static` means no dynamic libc/loader requirement, not qualification of every Linux -kernel. The historical cohort identifier `phase1-fastly-cli` is a destination-only label for the original contract; it is not the current file count or a qualification claim. The Fastly `provenance-sbom-repeatable` profile is destination-only: Phase 1 makes no repeatability claim. Its original four entries remain unchanged. **No Phase 1 reader** enforces these records or +kernel. The cohort’s `eligible_branches: ["main"]` is a destination value, not enforced by Phase 1; rc-branch eligibility remains a maintainer decision. Phase 1 ships seven artifacts. The historical cohort identifier `phase1-fastly-cli` is a destination-only label for the original contract; it is not the current file count or a qualification claim. The Fastly `provenance-sbom-repeatable` profile is destination-only: Phase 1 makes no repeatability claim. Its original four entries remain unchanged. **No Phase 1 reader** enforces these records or their destination assurance profiles/capability labels. `release-tools.json` and consumer `release-policy.json` remain byte-for-byte unchanged. The workflow's explicit seven selections own the Phase 1 builds; no supplemental tool file or @@ -270,8 +270,7 @@ valid. - `v0.0.0-rehearsal.8` at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`: all seven builds/signing jobs, immutable prerelease publication and complete independent online/offline/runtime verification passed. All 29 assets, 28 checksum entries, both predicates and signer forms, online store lookup without bundles, SPDX equality and the pinned Fastly pack/validate/inspect/cmp route passed. [Full transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). The latest rehearsed implementation is `7a05940e2f7b10c459bdd2d3949f265b755ce42c` from `.8`. -This review-head update is docs-only since rehearsed `7a05940e2`; workflows, -scripts, tests and release bytes retain the rehearsed implementation. +The current corrective batch changes publication checks and Axum binding; `.8` is historical evidence and does not qualify these changes. The immutable `.2`, `.3`, `.4`, `.5`, `.6` and `.8` releases remain intact; no drafts remain. The `.7` tag has no release. No upstream tags, releases or repository settings were changed. @@ -294,4 +293,8 @@ clean inherited environments provide additional checks, not hermeticity. Rehearsal `.6` at `39a769c3e3d6476395fa7a215bd0a5e74a760f3e` failed after immutable publication at the release-attestation lookup. [Independent verification](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6360012578) passed for all seven binaries / 29 assets, online and offline checks, SPDX equality and the manual Fastly pack/validate/inspect/cmp route; this does not turn the failed workflow green. Fresh `.8` subsequently passed at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`; see the [full transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). -The failed publisher check ran at 07:19:53.744 UTC; the independently verified release attestation has a trusted timestamp of 07:19:58 UTC. The pinned CLI hides underlying lookup errors, so the original HTTP status is unknown. The immutable prerelease and its 29 assets remain unchanged. The new availability check waits only for an HTTP 200 result without a current-tag GitHub release attestation: three checks, 2s then 5s, at most 7s total waiting within the existing scheduling window. Other-tag records on the same commit must be well-formed and their exact bundles cryptographically verified before ignoring them; their tag names alone are logged. Current-tag duplicates or identity/source/asset mismatches and every verification failure stop immediately. +The failed publisher check ran at 07:19:53.744 UTC; the independently verified release attestation has a trusted timestamp of 07:19:58 UTC. The pinned CLI hides underlying lookup errors, so the original HTTP status is unknown. The immutable prerelease and its 29 assets remain unchanged. Before cryptographic release verification, the availability check makes at most five release-attestation GETs, with 2s/5s/10s/20s backoff (37s total waiting). Only this endpoint treats HTTP 404 as no record yet; a valid HTTP 200 with no current-tag record may also wait. Each GET times out after at most 4s, and a separate 60s deadline bounds availability checking, including other-tag verification, within the shared three-minute scheduling window. Every other non-200 status fails immediately. Malformed records, duplicate current-tag records, identity/source/asset mismatches and definitive cryptographic failures stop immediately. Other-tag records are ignored only after their exact bundles verify; those read-only verification calls may retry recognized transient failures within the same bounds. Current-tag cryptographic verification is single-shot. Logs show every wait or availability on the first check. No mutation is retried. + +### Corrective batch publication checks + +Post-publish downloads use fresh temporary directories and bounded transient read retries. The complete 29-file inventory and every byte, including SHA256SUMS, must match the cryptographically verified release/handoff. Missing, extra or changed files fail immediately. The release-body verification commands are tested against the operator guide’s canonical block. Axum uses the pinned EdgeZero resolver with every warning fatal; ports must be 1–65535, and primary EDGEZERO**ADAPTER**PORT and legacy PORT must agree. Container smokes use 0.0.0.0:8080 with host-side reachability. diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index f775c2d799..c517710c63 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -80,11 +80,9 @@ The cohort records this explicit extension, but tools/policy and the frozen v1 index schema remain unchanged. Phase 1 emits no index. The later index/consumer slices need a reviewed seven-artifact amendment, including new target/capability forms; see the [compatibility audit](../plans/binary-attestations-phase1-compatibility.md) -and slice-map annotations. Rehearsal `.8` passed for the seven-binary implementation at -`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; the compatibility audit records the complete evidence. -Rehearsal `.4` remains historical evidence for the original four assets only. +and slice-map annotations. -### Phase 1 review notes — rehearsal .8 passed +### Phase 1 review notes Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -98,8 +96,6 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. -[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. Subsequent review-head updates are **docs-only since rehearsed `7a05940e2`**. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. - Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision `6d0efda7210983021ab3c9efe8a0a3190e9eb81b`, as retained at the frozen source. @@ -120,7 +116,7 @@ Publication requires `RELEASE_PUBLICATION_ENABLED=true` only after maintainers confirm immutable releases and the v\* tag ruleset. There is no settings token or prepublication settings API call, following the reviewed signing boundary; postpublication immutable=true readback fails loudly. Hyphenated tags are -prereleases and never Latest. Stable tags are normal releases. Eligibility of +prereleases and never Latest. Stable tags are normal releases with `make_latest: legacy`, allowing GitHub to choose Latest by version/date rather than forcing backports to Latest. Eligibility of rc-branch tags is an unresolved maintainer decision; Phase 1 does not implement the destination branch-ancestry/ruleset validator or authorize rc tags. diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs index ae1d020d8b..5f0d21fc54 100644 --- a/scripts/release/phase1.mjs +++ b/scripts/release/phase1.mjs @@ -536,6 +536,36 @@ async function smokeRelease(file, target) { ) continue } + for (const setting of ['EDGEZERO__ADAPTER__PORT', 'PORT']) { + let rejected = false + try { + docker([ + 'run', + '--rm', + '--network=none', + ...restrictions, + '--env', + setting + '=0', + image, + ]) + } catch (error) { + if ( + error.status !== 1 || + !(String(error.stdout ?? '') + String(error.stderr ?? '')).includes( + '1–65535' + ) + ) + throw error + rejected = true + } + if (!rejected) throw Error('Axum accepted port zero: ' + setting) + console.log( + 'SUCCESS: Axum rejects ' + + setting + + '=0; exit 1, ports must be 1–65535:', + image + ) + } const reservation = createServer().listen(0, '127.0.0.1') await once(reservation, 'listening') const port = reservation.address().port @@ -564,7 +594,7 @@ async function smokeRelease(file, target) { version: 1, } const environment = [ - 'PORT=8787', + 'EDGEZERO__ADAPTER__PORT=8080', 'EDGEZERO__ADAPTER__HOST=0.0.0.0', `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=${JSON.stringify(envelope)}`, 'TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE=smoke-edge-cookie-passphrase-32-bytes', @@ -577,7 +607,7 @@ async function smokeRelease(file, target) { container, '-d', '--publish', - `127.0.0.1:${port}:8787`, + `127.0.0.1:${port}:8080`, ...restrictions, ...environment.flatMap((value) => ['--env', value]), image, @@ -612,7 +642,7 @@ async function smokeRelease(file, target) { } if (!healthy) throw Error('Axum startup deadline exhausted') console.log( - 'SUCCESS: Axum bound to 0.0.0.0; host reached published container port, HTTP 200 ok:', + 'SUCCESS: Axum bound to 0.0.0.0:8080; host reached published container port, HTTP 200 ok:', reference, image ) diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index 76ba2e2907..e53bedbea3 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -153,6 +153,12 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'success', 'idle-socket', 'stable', + 'download-corrupt', + 'download-missing', + 'download-extra', + 'download-manifest', + 'download-transient', + 'download-never', 'publication-disabled', 'prerelease-mismatch', 'post-read-error', @@ -165,9 +171,9 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'upload-error', 'mutable', 'wrong-id', - 'attestation-after-one-wait', + 'attestation-404-then-present', 'attestation-after-two-waits', - 'attestation-never', + 'attestation-404-never', 'attestation-non-200', 'attestation-201', 'attestation-auth-error', @@ -184,6 +190,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'attestation-other-tag-before-current', 'attestation-other-malformed', 'attestation-other-unverifiable', + 'attestation-other-transient', 'attestation-substituted-bundle', 'attestation-verification-failure', ]) { @@ -193,6 +200,9 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an const stable = ['stable', 'attestation-other-tag'].includes(mode) const waits = [], ignored = [] + let downloads = 0, + otherReads = 0, + now = 0 let polls = 0, observed = [] try { @@ -312,16 +322,18 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an if (mode === 'attestation-other-malformed') other.bundle.dsseEnvelope.payload = '!invalid!' const delay = - mode === 'attestation-after-one-wait' + mode === 'attestation-404-then-present' ? 1 : [ 'attestation-after-two-waits', 'attestation-other-tag-before-current', ].includes(mode) ? 2 - : mode === 'attestation-never' - ? 3 + : mode === 'attestation-404-never' + ? 5 : 0 + if (polls <= delay && mode !== 'attestation-other-tag-before-current') + return response({ message: 'Not Found' }, 404) observed = polls <= delay ? [] : [attestation()] if (mode === 'attestation-duplicate') observed.push(attestation()) if (mode.startsWith('attestation-other-')) observed.unshift(other) @@ -334,12 +346,26 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an if (u.pathname.endsWith('/releases') && method === 'POST') { const creation = JSON.parse(options.body) assert.equal(creation.prerelease, !stable) - assert.equal(creation.make_latest, stable ? 'true' : 'false') + assert.equal(creation.make_latest, stable ? 'legacy' : 'false') const body = creation.body assert.match(body, /--signer-workflow.*release-build\.yml/) assert.match(body, /--cert-identity.*release-build\.yml@refs\/tags/) assert.ok(body.includes('COMMIT=' + 'a'.repeat(40))) for (const asset of assets) assert.ok(body.includes(asset)) + const normalize = (code) => + code.replace(/^(REPO|TAG|COMMIT|ASSETS)=.*$/gm, '$1=').trim() + const guide = readFileSync( + 'docs/guide/release-verification.md', + 'utf8' + ).match( + /\n\n```bash\n([\s\S]*?)\n```\n\n/ + ) + assert.ok(guide, 'should mark the canonical verification block') + assert.equal( + normalize(body.match(/```bash\n([\s\S]*?)\n```/)[1]), + normalize(guide[1]), + 'release commands should match the guide' + ) execFileSync('bash', ['-n'], { input: body.match(/```bash\n([\s\S]*?)\n```/)[1], }) @@ -371,6 +397,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an } const context = { Buffer, + Date: { now: () => now, parse: Date.parse }, JSON, AbortSignal, URL, @@ -385,6 +412,7 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an }, setTimeout: (resolve, delay) => { waits.push(delay) + now += delay resolve() }, fetch, @@ -401,6 +429,9 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an if (name === 'node:fs') return { ...fs, + mkdtempSync: (prefix) => + fs.mkdtempSync(join(root, prefix)).slice(root.length + 1), + rmSync: (path, options) => fs.rmSync(join(root, path), options), readdirSync: (path, ...rest) => fs.readdirSync(join(root, path), ...rest), lstatSync: (path) => fs.lstatSync(join(root, path)), @@ -419,9 +450,39 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an return { execFileSync: (...args) => { calls.push(['gh', args]) + if (args[1][1] === 'download') { + downloads++ + assert.ok( + args[2].timeout > 10000 && args[2].timeout <= 60000, + 'should allow bulk transfers within the shared deadline' + ) + const dir = args[1][args[1].indexOf('--dir') + 1] + if ( + mode === 'download-never' || + (mode === 'download-transient' && downloads === 1) + ) + throw Object.assign(Error('timeout'), { code: 'ETIMEDOUT' }) + for (const item of uploaded) + fs.writeFileSync(join(root, dir, item.name), item.bytes) + if (mode === 'download-corrupt') + fs.writeFileSync(join(root, dir, assets[0]), 'corrupt') + if (mode === 'download-manifest') + fs.writeFileSync(join(root, dir, 'SHA256SUMS'), 'corrupt') + if (mode === 'download-missing') + fs.unlinkSync(join(root, dir, assets[0])) + if (mode === 'download-extra') + fs.writeFileSync(join(root, dir, 'extra'), 'extra') + return Buffer.from('downloaded') + } if (args[1][1] !== 'verify') return Buffer.from('verified asset') const tag = args[1].at(-1) + if ( + mode === 'attestation-other-transient' && + tag === 'v1.0.0-rc.1' && + ++otherReads === 1 + ) + throw Object.assign(Error('timeout'), { code: 'ETIMEDOUT' }) if ( mode === 'attestation-verification-failure' || (mode === 'attestation-other-unverifiable' && @@ -453,13 +514,24 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an 'success', 'idle-socket', 'stable', - 'attestation-after-one-wait', + 'download-transient', + 'attestation-other-transient', + 'attestation-404-then-present', 'attestation-after-two-waits', 'attestation-other-tag', 'attestation-other-tag-before-current', ].includes(mode) if (successful) await execute() - else await assert.rejects(execute, undefined, mode) + else + await assert.rejects( + execute, + mode === 'attestation-404-never' + ? /unavailable after five checks/ + : mode.startsWith('download-') && mode !== 'download-never' + ? /mismatch|Unexpected artifact contents|ENOENT/ + : undefined, + mode + ) if ( [ 'draft', @@ -479,27 +551,60 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an if (successful) { assert.equal(uploaded.length, 29) assert.equal( - calls.filter(([method]) => method === 'gh').length, - mode === 'attestation-other-tag' - ? 31 - : mode === 'attestation-other-tag-before-current' - ? 33 - : 30 + calls.filter( + ([method, args]) => method === 'gh' && args[1][1] !== 'download' + ).length, + mode === 'attestation-other-transient' + ? 32 + : mode === 'attestation-other-tag' + ? 31 + : mode === 'attestation-other-tag-before-current' + ? 33 + : 30 + ) + } + assert.equal( + fs + .readdirSync(root) + .some((name) => name.startsWith('published-download-')), + false, + 'should clean every download attempt' + ) + if (mode.startsWith('download-')) { + assert.equal( + downloads, + mode === 'download-transient' ? 2 : mode === 'download-never' ? 3 : 1 + ) + assert.deepEqual( + waits, + mode === 'download-transient' + ? [2000] + : mode === 'download-never' + ? [2000, 5000] + : [] ) } if (mode.startsWith('attestation-')) { const expectedWaits = - mode === 'attestation-after-one-wait' - ? [2000] + mode === 'attestation-404-never' + ? [2000, 5000, 10000, 20000] : [ - 'attestation-after-two-waits', - 'attestation-never', - 'attestation-other-tag-before-current', + 'attestation-404-then-present', + 'attestation-other-transient', ].includes(mode) - ? [2000, 5000] - : [] + ? [2000] + : [ + 'attestation-after-two-waits', + 'attestation-other-tag-before-current', + ].includes(mode) + ? [2000, 5000] + : [] assert.deepEqual(waits, expectedWaits, mode) - assert.equal(polls, expectedWaits.length + 1, mode) + assert.equal( + polls, + mode === 'attestation-other-transient' ? 1 : expectedWaits.length + 1, + mode + ) assert.equal( calls.filter( ([method, path]) => method === 'POST' && path.endsWith('/releases') @@ -521,7 +626,9 @@ test('publication binds exact signing handoffs and refuses drafts, corruption an ) assert.deepEqual( ignored, - mode === 'attestation-other-tag' + ['attestation-other-tag', 'attestation-other-transient'].includes( + mode + ) ? ['"v1.0.0-rc.1"'] : mode === 'attestation-other-tag-before-current' ? Array(3).fill('"v1.0.0-rc.1"') @@ -930,3 +1037,24 @@ test('audit handles ordinary char literals and generated include prefixes', () = ) ) }) + +test('post-publish reads cannot succeed after the shared deadline', async () => { + const code = publicationScript() + .split('// BEGIN READ RETRIES')[1] + .split('// END READ RETRIES')[0] + let now = 0 + const retry = vm.runInNewContext(`${code}; retryRead`, { + Date: { now: () => now, parse: Date.parse }, + console: { + warn: () => assert.fail('should not retry deadline exhaustion'), + }, + }) + await assert.rejects( + () => + retry(() => { + now = 180000 + return 'late success' + }), + /window exhausted/ + ) +}) From f66da48a743714940bb8c30af5550783a8dc6bdf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 17:57:36 +0530 Subject: [PATCH 026/104] Record the successful ninth attestation rehearsal --- ...inary-attestations-phase1-compatibility.md | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index b755c3e5de..b12ef83f01 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -11,7 +11,7 @@ Frozen source references below mean: - [Cohort](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-cohort.json), [tools](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/scripts/release/release-tools.json), [consumer policy](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/release-policy.json). - [Index v1 schema](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/schemas/release-index-v1.schema.json), [distribution names](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-release/src/distribution.rs#L55), [consumer companion rules](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/documents.rs#L236), [SPDX joins](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/crates/trusted-server-cli/src/release/evidence/joins.rs#L188). -### Phase 1 review notes — corrective batch for rehearsal .9 +### Phase 1 review notes — rehearsal .9 passed Phase 1 is **part of #1235**, pending D7 maintainer settings. These notes supersede historical phase applicability, not the retained destination requirements. @@ -25,7 +25,7 @@ below describe #1262, not current Phase 1 implementation status. Current tool pins: Rust 1.98.1, Node 24.20.0, Fastly CLI 16.0.0, Viceroy 0.21.0; verification uses GitHub CLI 2.102.0. The reusable release-build.yml is the signer. Rehearsals use GitHub CLI 2.102.0, Fastly CLI 16.0.0 and Viceroy 0.21.0; the carried destination spec/plan still mention GitHub CLI 2.97.0 and Fastly CLI 15.1.0. Those carried versions are unchanged. -[Rehearsal `.8`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955) passed at exact implementation SHA `7a05940e2f7b10c459bdd2d3949f265b755ce42c`. That rehearsal applies to its exact historical implementation; the current corrective batch requires fresh rehearsal `.9`. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. +[Rehearsal `.9`](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6364827112) passed at exact implementation SHA `149754d357901ec874eab6140e92bce3d8613085`. Subsequent review-head updates are **docs-only since rehearsed `149754d35`**. Rehearsal `.8` remains historical evidence for its own implementation. Rehearsal `.6` at `39a769c3e` failed after immutable publication despite successful independent verification; `.7` at `7a05940e2` was cancelled before signing/publication because GitHub could not assign a macOS ARM runner. The compatibility audit retains those failed outcomes. Slice 2.3 immutable build facts are reused from introducing commit `bfb697404551a28217af8906024506095bcd9559` and revision @@ -58,8 +58,8 @@ unchanged; the phasing sections describe this slice and its rehearsal outcome. T Phase 1 audit baseline is `4dd217125c6e38055e4bcb2cba32c0b95e739914`; the following corrections are implemented in this slice, with verification and rehearsal tracked in the PR description at its current commit. Signing and -immutable publication rehearsal `.8` passed at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`; see the -[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). +immutable publication rehearsal `.9` passed at `149754d357901ec874eab6140e92bce3d8613085`; see the +[full verification transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6364827112). The current correction aligns the local input-free `release-build.yml` signer with the `release.yml` caller/publisher, real SPDX 2.3 documents, fourteen @@ -160,8 +160,8 @@ qualified evidence. This implementation extends the frozen cohort from four to **seven binaries / 29 release files**, retaining all four original names. Rehearsal `.4` at `922c2211c` proves only the original four-binary/17-file -implementation. Rehearsal `.8` passed for the seven-binary implementation at -`7a05940e2f7b10c459bdd2d3949f265b755ce42c`; the `.4` evidence is not reused for the expanded cohort. +implementation. Rehearsal `.9` passed for the seven-binary implementation at +`149754d357901ec874eab6140e92bce3d8613085`; the `.4` evidence is not reused for the expanded cohort. The exact cohort diff is three appended records, all default features, empty extra features, `variant: default`, `assurance_profile: provenance-sbom`, @@ -269,10 +269,12 @@ valid. - `v0.0.0-rehearsal.8` at `7a05940e2f7b10c459bdd2d3949f265b755ce42c`: all seven builds/signing jobs, immutable prerelease publication and complete independent online/offline/runtime verification passed. All 29 assets, 28 checksum entries, both predicates and signer forms, online store lookup without bundles, SPDX equality and the pinned Fastly pack/validate/inspect/cmp route passed. [Full transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6362236955). -The latest rehearsed implementation is `7a05940e2f7b10c459bdd2d3949f265b755ce42c` from `.8`. -The current corrective batch changes publication checks and Axum binding; `.8` is historical evidence and does not qualify these changes. +- `v0.0.0-rehearsal.9` at `149754d357901ec874eab6140e92bce3d8613085`: all seven builds/signing jobs, immutable prerelease publication and complete independent verification passed. The publisher found the release attestation available on its first check (no live availability wait), then re-downloaded and byte-compared all 29 assets. All bundle/online-store/offline predicates and signer pins, SPDX comparisons, checksums, macOS floor and CLI source checks passed. Axum smokes proved 0.0.0.0:8080 from outside each container and rejected primary/legacy port zero. Pinned Fastly pack/validate/inspect/cmp and Viceroy runtime checks passed, with visible no-Build-facts check/result. [Full transcript](https://github.com/IABTechLab/trusted-server/pull/1435#issuecomment-6364827112). -The immutable `.2`, `.3`, `.4`, `.5`, `.6` and `.8` releases remain intact; no drafts remain. The `.7` tag has no release. No upstream tags, releases or repository settings were changed. +The latest rehearsed implementation is `149754d357901ec874eab6140e92bce3d8613085` from `.9`. +This review-head update is docs-only since rehearsed `149754d35`; workflows, scripts, tests and runtime code retain the rehearsed implementation. + +The immutable `.2`, `.3`, `.4`, `.5`, `.6`, `.8` and `.9` releases remain intact; no drafts remain. The `.7` tag has no release. No upstream tags, releases or repository settings were changed. ### Phase 1 review boundary notes From a501d1ba6bde2db82546d7e16c93facab8c613e6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 23 Oct 2026 19:25:46 +0530 Subject: [PATCH 027/104] Clarify final release verification notes (docs-only) --- docs/guide/release-verification.md | 5 ++-- ...inary-attestations-phase1-compatibility.md | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index cb102ff228..c01e05780e 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -333,14 +333,13 @@ tags and approve tag-push authority and required checks. Immutability remains a human precondition. Set repository variable `RELEASE_PUBLICATION_ENABLED=true` only after confirming those settings with the read-only commands below. Without it, both signing and publication are skipped; an early tag push creates no public attestations. There is no repository-administration token or prepublication settings API call, following the reviewed decision to check the published release instead. A violation can expose a mutable release before the workflow reports failure. -Tags containing `-` publish as prereleases with Latest disabled; stable tags publish as normal releases. Whether to authorize tags from rc branches is a maintainer decision; the Phase 2 ancestry/ruleset validator is not implemented here. +Tags containing `-` publish as prereleases and never become Latest. Stable tags publish as normal releases with `make_latest: legacy`; GitHub chooses Latest by version/date rather than forcing a backport release to Latest. Whether to authorize tags from rc branches is a maintainer decision; the Phase 2 ancestry/ruleset validator is not implemented here. Publication lists releases, including drafts, and refuses every existing matching tag. It creates one draft, retains its release ID, and binds uploads and the final publish mutation to that ID. It checks the exact signing-approved asset set, validates each upload response's size and SHA-256 digest before publication, -and checks immutable membership after publication. Re-downloading uploaded bytes -and the reviewed publication owner remain additive later work. There are no +and checks immutable membership after publication. Published assets are then re-downloaded and byte-compared against the signed `SHA256SUMS` and validated handoff. The reviewed publication owner remains additive later work. There are no automatic mutation retries or draft-resume claims. Only post-publish reads may retry: release/asset metadata GETs and the GitHub diff --git a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md index b12ef83f01..7cd8d2e67f 100644 --- a/docs/superpowers/plans/binary-attestations-phase1-compatibility.md +++ b/docs/superpowers/plans/binary-attestations-phase1-compatibility.md @@ -75,8 +75,7 @@ retries and verifies immutable membership after publication. The tools and consumer policy files remain unchanged. The cohort adds the three descriptive Axum entries below; all three policies still have **no Phase 1 readers**. Checked cohort selection, a supplemental tool policy, environment -normalization/remapping, stricter tag/source validation, uploaded-byte re-download, -full platform qualification remains additive later work. This corrective batch adds immutable build facts and a verified macOS 13.0 deployment target; UBI GNU glibc 2.34 checks and +normalization/remapping, stricter tag/source validation and full platform qualification remain additive later work. Post-publication asset re-download and byte comparison against signed `SHA256SUMS` shipped and passed in rehearsal `.9`. This corrective batch adds immutable build facts and a verified macOS 13.0 deployment target; UBI GNU glibc 2.34 checks and startup smokes remain. Copying a destination policy alone never establishes its qualification requirements. Native reproduction is a later measurement, not a Phase 1 release gate. Full Task 16 resume/reconciliation remains deferred. @@ -142,16 +141,16 @@ qualified evidence. ### Publication contract and nonbreaking recovery plan -| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | -| ------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Publication owner / permissions | `release.yml`, contents-write only (Task 16) | Matches | Preserve; no signer/OIDC/settings credential in publisher. | -| Artifact transfer | Validated current-run outputs, exact bytes/sets (Tasks 12/15/16) | Wildcard `release-*` merge | Fix now: exact named downloads in separate directories, signing-owned digest manifests, reject missing/extra/changed files before creation. | -| Draft lookup/recovery | Published refused; matching draft resumable only after source/assets match | Tag lookup + refuse existing; no full draft listing | List all releases including drafts and refuse every matching tag in Phase 1. Capture created ID for mutations and identity-bound verification. Later matching-draft resume is an additive recovery capability. | -| Prepublication validation | Upload, re-download and validate intended set | Upload then publish | Upload responses already validate size and SHA-256 digest before publication. Re-downloading uploaded bytes remains additive later work. Exact signing-approved local assets and mutations are bound to the created release ID. | -| Checks after publish | Immutable=true; signed release/membership and exact intended source/bytes | Immutable check and normal gh verification | Preserve and bind checks to created release ID. `gh release verify` accepts tags, so bracket it with created-ID/tag correspondence checks rather than invent an ID attestation endpoint. | -| Mutable/lost/unknown outcomes | Controlled cleanup for confirmed mutable; read-only reconciliation otherwise | Loud failure, manual recovery | Planned narrower Phase 1 behavior: leave failed/unknown state for explicit inspection, no blind retry/deletion. Later owner adds safe reconciliation without changing immutable assets or user verification commands. | -| Concurrency | Tag scoped; never cancel publishing run | Same rule also applies to PR builds | Preserve tags; cancel superseded PR builds only. | -| Public deployment route | Verified raw Fastly bytes; prepared EdgeZero context/recovery later | Manual clean package/member comparison | Preserve manual verification/deployment while later adding prepared-payload APIs; #1235 does not wait for boundaries 5–6. | +| Contract point | Frozen #1262 value | #1435 value at audit | Alignment / adoption | +| ------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Publication owner / permissions | `release.yml`, contents-write only (Task 16) | Matches | Preserve; no signer/OIDC/settings credential in publisher. | +| Artifact transfer | Validated current-run outputs, exact bytes/sets (Tasks 12/15/16) | Wildcard `release-*` merge | Fix now: exact named downloads in separate directories, signing-owned digest manifests, reject missing/extra/changed files before creation. | +| Draft lookup/recovery | Published refused; matching draft resumable only after source/assets match | Tag lookup + refuse existing; no full draft listing | List all releases including drafts and refuse every matching tag in Phase 1. Capture created ID for mutations and identity-bound verification. Later matching-draft resume is an additive recovery capability. | +| Prepublication validation | Upload, re-download and validate intended set | Upload then publish | Upload responses already validate size and SHA-256 digest before publication. Re-downloading bytes before publication remains destination work; post-publication asset re-download and byte comparison against signed `SHA256SUMS` are implemented and proven in rehearsal `.9`. Exact signing-approved local assets and mutations are bound to the created release ID. | +| Checks after publish | Immutable=true; signed release/membership and exact intended source/bytes | Immutable check and normal gh verification | Preserve and bind checks to created release ID. `gh release verify` accepts tags, so bracket it with created-ID/tag correspondence checks rather than invent an ID attestation endpoint. | +| Mutable/lost/unknown outcomes | Controlled cleanup for confirmed mutable; read-only reconciliation otherwise | Loud failure, manual recovery | Planned narrower Phase 1 behavior: leave failed/unknown state for explicit inspection, no blind retry/deletion. Later owner adds safe reconciliation without changing immutable assets or user verification commands. | +| Concurrency | Tag scoped; never cancel publishing run | Same rule also applies to PR builds | Preserve tags; cancel superseded PR builds only. | +| Public deployment route | Verified raw Fastly bytes; prepared EdgeZero context/recovery later | Manual clean package/member comparison | Preserve manual verification/deployment while later adding prepared-payload APIs; #1235 does not wait for boundaries 5–6. | **Nonbreaking adoption rule:** existing `gh` checks remain valid when later releases add real companions, index and richer evidence. The full custom verifier is introduced for index-bearing releases; it must not reinterpret the first slice as having complete graph/reproducibility/qualification evidence. No fake index or placeholder successful companions will be published. The unavoidable missing-evidence/recovery/platform-qualification limits are explicit; signer path, cohort, SPDX predicate/names and reserved companion names are aligned before the next publication attempt. @@ -299,4 +298,8 @@ The failed publisher check ran at 07:19:53.744 UTC; the independently verified r ### Corrective batch publication checks -Post-publish downloads use fresh temporary directories and bounded transient read retries. The complete 29-file inventory and every byte, including SHA256SUMS, must match the cryptographically verified release/handoff. Missing, extra or changed files fail immediately. The release-body verification commands are tested against the operator guide’s canonical block. Axum uses the pinned EdgeZero resolver with every warning fatal; ports must be 1–65535, and primary EDGEZERO**ADAPTER**PORT and legacy PORT must agree. Container smokes use 0.0.0.0:8080 with host-side reachability. +Post-publish downloads use fresh temporary directories and bounded transient read retries. The complete 29-file inventory and every byte, including SHA256SUMS, must match the cryptographically verified release/handoff. Missing, extra or changed files fail immediately. The release-body verification commands are tested against the operator guide’s canonical block. Axum uses the pinned EdgeZero resolver with every warning fatal; ports must be 1–65535, and primary `EDGEZERO__ADAPTER__PORT` and legacy `PORT` must agree. Container smokes use 0.0.0.0:8080 with host-side reachability. + +### 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. From 26d69ce959898859927742cd84e53f2c91e9c8ab Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 26 Oct 2026 16:17:55 +0530 Subject: [PATCH 028/104] 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 0000000000..885e90a056 --- /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 0000000000..eb5d7efb6a --- /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 84f58a1e8e..b5e3dc0b8d 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 029/104] 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 609c950531..1ad5d605b1 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 eb5d7efb6a..1f71b33cb5 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 030/104] 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 0000000000..7bb3a5c50c --- /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 031/104] 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 7cd8d2e67f..9de3d1a1c0 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 7bb3a5c50c..357d79e082 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 032/104] 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 4c9f085b39..de6f87e2da 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 b27173dd66..d3eac8e490 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 7ebc0b08ea..9ef504bb25 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 72fecdf6b5..b8525f032d 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 5b6df96154..2881a8291d 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 0000000000..48bafce521 --- /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 0000000000..44a22d370b --- /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 0000000000..ee61e6686e --- /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 0000000000..433dd3517e --- /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 0000000000..e3d48ebe85 --- /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 0000000000..358f70a7f4 --- /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 0000000000..7002bdb584 --- /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 0000000000..abdb21ee5f --- /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 0000000000..f5c7bfb69d --- /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 0000000000..4783ad4d27 --- /dev/null +++ b/crates/trusted-server-release/src/process/environment.rs @@ -0,0 +1,227 @@ +//! Environment validation for [`BoundedCommand`]. +//! +//! The denylists are a backstop; the Task 7 boundary and preparation callers +//! supply per-tool allowlists. + +use std::ffi::{OsStr, OsString}; +use std::os::unix::ffi::OsStrExt as _; + +use error_stack::Report; + +use super::{BoundedCommand, invalid}; +use crate::error::ReleaseError; + +/// Uppercased name prefixes that inject code or configuration into children. +const INJECTION_PREFIXES: [&str; 5] = [ + "DYLD_", + "GIT_CONFIG_KEY_", + "GIT_CONFIG_VALUE_", + "LD_", + "NPM_CONFIG_", +]; +/// Uppercased `CARGO_TARGET__` suffixes that select tools. +const CARGO_TARGET_TOOL_SUFFIXES: [&str; 2] = ["_LINKER", "_RUNNER"]; +/// Uppercased exact names that inject code or configuration into children. +const INJECTION_EXACT: [&str; 42] = [ + "BASH_ENV", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_ENCODED_RUSTFLAGS", + "EDITOR", + "ENV", + "GCONV_PATH", + "GIT_ASKPASS", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_SYSTEM", + "GIT_EDITOR", + "GIT_EXEC_PATH", + "GIT_EXTERNAL_DIFF", + "GIT_PAGER", + "GIT_PROXY_COMMAND", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_TEMPLATE_DIR", + "JAVA_TOOL_OPTIONS", + "NODE_OPTIONS", + "NODE_PATH", + "PAGER", + "PERL5LIB", + "PERL5OPT", + "PYTHONPATH", + "PYTHONSTARTUP", + "RUBYLIB", + "RUBYOPT", + "RUSTC", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTC_WRAPPER", + "RUSTDOC", + "RUSTFLAGS", + "SSH_ASKPASS", + "VISUAL", + "_JAVA_OPTIONS", +]; +/// Uppercased credential name prefixes accepted only as secrets. +const CREDENTIAL_PREFIXES: [&str; 2] = ["AWS_", "FASTLY_"]; +/// Uppercased `aws-lc-sys` system-library switch, also per target as +/// `_`. It shares the `AWS_` prefix but carries no credential. +const BUILD_SWITCH: &str = "AWS_LC_SYS_USE_SYSTEM"; +/// Uppercased `CARGO_REGISTRIES__TOKEN` registry credential affixes. +const CARGO_REGISTRIES_TOKEN: (&str, &str) = ("CARGO_REGISTRIES_", "_TOKEN"); +/// Uppercased credential names accepted only as secrets. +const CREDENTIAL_EXACT: [&str; 8] = [ + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "CARGO_REGISTRY_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "NODE_AUTH_TOKEN", + "NPM_TOKEN", + "SIGSTORE_ID_TOKEN", +]; + +/// One explicit child environment entry. +#[derive(Clone)] +pub(super) struct EnvironmentValue { + pub(super) value: OsString, + pub(super) secret: bool, +} + +fn upper_name(name: &OsStr) -> String { + name.to_string_lossy().to_ascii_uppercase() +} + +fn is_injection(name: &OsStr) -> bool { + let upper = upper_name(name); + let cargo_target_tool = upper.starts_with("CARGO_TARGET_") + && CARGO_TARGET_TOOL_SUFFIXES + .iter() + .any(|suffix| upper.ends_with(suffix)); + cargo_target_tool + || INJECTION_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix)) + || INJECTION_EXACT.contains(&upper.as_str()) +} + +pub(super) fn is_credential(name: &OsStr) -> bool { + let upper = upper_name(name); + let (registries, token) = CARGO_REGISTRIES_TOKEN; + let registry_token = upper.starts_with(registries) && upper.ends_with(token); + let build_switch = upper == BUILD_SWITCH + || upper + .strip_prefix(BUILD_SWITCH) + .and_then(|suffix| suffix.strip_prefix('_')) + .is_some_and(|triple| { + !triple.is_empty() + && triple + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }); + registry_token + || (!build_switch + && upper != "AWS_LC_SYS_CMAKE_BUILDER" + && upper != "AWS_LC_SYS_CMAKE_BUILDER_X86_64_UNKNOWN_LINUX_GNU" + && CREDENTIAL_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix))) + || CREDENTIAL_EXACT.contains(&upper.as_str()) +} + +/// Exact names an orchestrator may set explicitly through +/// [`BoundedCommand::tool_selector_environment`] despite the injection denylist. +const TOOL_SELECTORS: [&str; 1] = ["RUSTC"]; + +impl BoundedCommand { + pub(super) fn insert_environment( + self, + name: &OsStr, + value: &OsStr, + secret: bool, + ) -> Result> { + self.insert_checked(name, value, secret, false) + } + + /// Adds one explicitly selected tool path, such as the compiler. + /// + /// The generic [`Self::environment`] keeps rejecting every tool selector, + /// because an inherited or attacker-chosen `RUSTC` swaps the compiler. + /// This method accepts only the names in [`TOOL_SELECTORS`], only with an + /// absolute path, and only once. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] for any other name, a relative or + /// empty value, or a repeated name. + pub(crate) fn tool_selector_environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + let (name, value) = (name.as_ref(), value.as_ref()); + let selectable = TOOL_SELECTORS + .iter() + .any(|selector| name == OsStr::new(selector)); + if !selectable || !value.as_bytes().starts_with(b"/") { + return Err(invalid(format!( + "{name:?} is not an explicitly selectable tool with an absolute path" + ))); + } + self.insert_checked(name, value, false, true) + } + + fn insert_checked( + mut self, + name: &OsStr, + value: &OsStr, + secret: bool, + tool_selector: bool, + ) -> Result> { + let name_bytes = name.as_bytes(); + if name_bytes.is_empty() || name_bytes.contains(&b'=') || name_bytes.contains(&0) { + return Err(invalid(format!( + "environment variable name {name:?} is malformed" + ))); + } + if value.as_bytes().contains(&0) { + return Err(invalid(format!( + "environment variable {name:?} value contains NUL" + ))); + } + if !tool_selector && is_injection(name) { + return Err(invalid(format!( + "environment variable {name:?} injects code or configuration" + ))); + } + if matches!( + upper_name(name).as_str(), + "AWS_LC_SYS_CMAKE_BUILDER" | "AWS_LC_SYS_CMAKE_BUILDER_X86_64_UNKNOWN_LINUX_GNU" + ) && value != OsStr::new("0") + { + return Err(invalid( + "only the reviewed AWS-LC cc backend switch is permitted", + )); + } + if self.environment.contains_key(name) { + return Err(invalid(format!( + "environment variable {name:?} is set twice" + ))); + } + self.environment.insert( + name.to_os_string(), + EnvironmentValue { + value: value.to_os_string(), + secret, + }, + ); + Ok(self) + } +} + +#[cfg(test)] +mod selector_tests; diff --git a/crates/trusted-server-release/src/process/environment/selector_tests.rs b/crates/trusted-server-release/src/process/environment/selector_tests.rs new file mode 100644 index 0000000000..6f7c025bac --- /dev/null +++ b/crates/trusted-server-release/src/process/environment/selector_tests.rs @@ -0,0 +1,74 @@ +//! Pure tests of the explicit tool-selector allowance; no process is started. + +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::time::Duration; + +use super::*; +use crate::process::ProcessLimits; + +fn fresh() -> BoundedCommand { + BoundedCommand::new( + Path::new("/example/tool"), + Path::new("/example"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe a command") +} + +#[test] +fn generic_environment_still_rejects_every_tool_selector() { + for name in [ + "RUSTC", + "RUSTC_WRAPPER", + "RUSTFLAGS", + "RUSTDOC", + "CARGO_BUILD_RUSTC", + ] { + assert!( + fresh().environment(name, "/example/rustc").is_err(), + "should keep rejecting {name} through the generic environment" + ); + } +} + +#[test] +fn only_rustc_with_an_absolute_path_is_a_selectable_tool() { + let selected = fresh() + .tool_selector_environment("RUSTC", "/example/bin/rustc") + .expect("should accept an explicit absolute RUSTC"); + assert_eq!( + selected + .environment + .get(OsStr::new("RUSTC")) + .map(|entry| entry.value.clone()), + Some(OsString::from("/example/bin/rustc")), + "should carry the selected compiler" + ); + for (name, value) in [ + ("RUSTC_WRAPPER", "/example/wrapper"), + ("RUSTFLAGS", "/example/flags"), + ("RUSTDOC", "/example/rustdoc"), + ("CARGO_BUILD_RUSTC", "/example/rustc"), + ("LD_PRELOAD", "/example/lib.so"), + ("rustc", "/example/rustc"), + ("RUSTC", "relative/rustc"), + ("RUSTC", ""), + ] { + assert!( + fresh().tool_selector_environment(name, value).is_err(), + "should reject {name}={value:?} as a tool selector" + ); + } + assert!( + fresh() + .tool_selector_environment("RUSTC", "/example/bin/rustc") + .and_then(|command| command.tool_selector_environment("RUSTC", "/example/other")) + .is_err(), + "should reject setting RUSTC twice" + ); +} diff --git a/crates/trusted-server-release/src/process/pipes.rs b/crates/trusted-server-release/src/process/pipes.rs new file mode 100644 index 0000000000..7d2df2e256 --- /dev/null +++ b/crates/trusted-server-release/src/process/pipes.rs @@ -0,0 +1,159 @@ +//! Bounded concurrent draining of child pipes. + +use std::io::{self, Read, Write as _}; +use std::process::Child; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::Instant; + +use error_stack::Report; + +use super::supervise::{KILL_GRACE, POLL_INTERVAL}; +use super::{ProcessFailure, ProcessLimits, Stream, process_error}; +use crate::error::ReleaseError; + +/// Pipe read size for the drain threads. +const READ_CHUNK: usize = 64 * 1024; + +/// Bytes read from one stream before EOF or the cap. +struct Drained { + bytes: Vec, + overflowed: bool, +} + +/// Captured output of both streams after every pipe thread finished. +#[derive(Default)] +pub(super) struct DrainedOutput { + pub(super) stdout: Vec, + pub(super) stderr: Vec, + pub(super) overflowed: Option, + pub(super) stderr_truncated: bool, +} + +/// Drain and stdin-writer threads attached to one child. +pub(super) struct Pipes { + stdout: JoinHandle>, + stderr: JoinHandle>, + stdin: Option>>, +} + +impl Pipes { + pub(super) fn start( + child: &mut Child, + stdin_bytes: Option>, + limits: &ProcessLimits, + ) -> Option<(Self, Receiver)> { + let stdout = child.stdout.take()?; + let stderr = child.stderr.take()?; + let stdin = match stdin_bytes { + Some(bytes) => Some((child.stdin.take()?, bytes)), + None => None, + }; + let (sender, overflow) = mpsc::channel(); + let pipes = Self { + stdout: drain(stdout, Stream::Stdout, limits.stdout_max, sender.clone()), + stderr: drain(stderr, Stream::Stderr, limits.stderr_max, sender), + stdin: stdin.map(|(mut pipe, bytes)| { + thread::spawn(move || match pipe.write_all(&bytes) { + // A child that stops reading early is not a pipe failure. + Err(source) if source.kind() == io::ErrorKind::BrokenPipe => Ok(()), + other => other, + }) + }), + }; + Some((pipes, overflow)) + } + + fn finished(&self) -> bool { + self.stdout.is_finished() + && self.stderr.is_finished() + && self.stdin.as_ref().is_none_or(JoinHandle::is_finished) + } + + /// Joins every pipe thread within [`KILL_GRACE`]. + /// + /// Threads still blocked after the grace stay detached: they hold only a + /// pipe end that the escaped descendant keeps open. + pub(super) fn join(self) -> Result> { + let until = Instant::now() + KILL_GRACE; + while !self.finished() { + if Instant::now() >= until { + return Err(process_error( + ProcessFailure::DescendantEscaped, + "child pipes stayed open after the process group was gone; \ + output is rejected", + )); + } + thread::sleep(POLL_INTERVAL); + } + if let Some(stdin) = self.stdin { + joined(stdin)?; + } + let stdout = joined(self.stdout)?; + let stderr = joined(self.stderr)?; + let overflowed = if stdout.overflowed { + Some(Stream::Stdout) + } else if stderr.overflowed { + Some(Stream::Stderr) + } else { + None + }; + Ok(DrainedOutput { + stdout: stdout.bytes, + stderr: stderr.bytes, + overflowed, + stderr_truncated: stderr.overflowed, + }) + } +} + +fn joined(handle: JoinHandle>) -> Result> { + handle + .join() + .map_err(|_| process_error(ProcessFailure::Pipe { errno: None }, "pipe thread panicked"))? + .map_err(|source| { + process_error( + ProcessFailure::Pipe { + errno: source.raw_os_error(), + }, + format!("child pipe failed: {source}"), + ) + }) +} + +fn drain( + mut reader: R, + stream: Stream, + limit: u64, + overflow: Sender, +) -> JoinHandle> { + thread::spawn(move || { + let mut bytes = Vec::new(); + let mut buffer = vec![0; READ_CHUNK]; + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(source) if source.kind() == io::ErrorKind::Interrupted => continue, + Err(source) => return Err(source), + }; + if count == 0 { + return Ok(Drained { + bytes, + overflowed: false, + }); + } + let captured = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let remaining = usize::try_from(limit.saturating_sub(captured)).unwrap_or(usize::MAX); + if count > remaining { + bytes.extend_from_slice(buffer.get(..remaining).unwrap_or_default()); + // Dropping the reader closes the pipe; the supervisor kills the group. + let _ = overflow.send(stream); + return Ok(Drained { + bytes, + overflowed: true, + }); + } + bytes.extend_from_slice(buffer.get(..count).unwrap_or_default()); + } + }) +} diff --git a/crates/trusted-server-release/src/process/redact.rs b/crates/trusted-server-release/src/process/redact.rs new file mode 100644 index 0000000000..c14b3fe07c --- /dev/null +++ b/crates/trusted-server-release/src/process/redact.rs @@ -0,0 +1,63 @@ +//! Secret redaction for diagnostics. + +/// Replacement text for secret values in diagnostics. +pub(super) const REDACTED: &[u8] = b"[REDACTED]"; + +/// Replaces the union of every secret occurrence with one marker per span. +/// +/// Matches are found on the original bytes and merged before replacement, so +/// overlapping or adjacent secrets cannot leave fragments behind. +pub(super) fn redact(bytes: &[u8], secrets: &[Vec]) -> Vec { + let mut ranges: Vec<(usize, usize)> = secrets + .iter() + .filter(|secret| !secret.is_empty()) + .flat_map(|secret| { + bytes + .windows(secret.len()) + .enumerate() + .filter(move |(_, window)| window == secret) + .map(move |(start, _)| (start, start + secret.len())) + }) + .collect(); + ranges.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + let mut output = Vec::with_capacity(bytes.len()); + let mut cursor = 0; + for (start, end) in merged { + output.extend_from_slice(bytes.get(cursor..start).unwrap_or_default()); + output.extend_from_slice(REDACTED); + cursor = end; + } + output.extend_from_slice(bytes.get(cursor..).unwrap_or_default()); + output +} + +/// Replaces a trailing proper prefix of any secret, as left by a byte cap +/// that cut the stream inside the secret. +pub(super) fn redact_cut_tail(mut bytes: Vec, secrets: &[Vec]) -> Vec { + let cut = secrets + .iter() + .flat_map(|secret| { + (1..secret.len()).filter(|&length| { + secret + .get(..length) + .is_some_and(|prefix| bytes.ends_with(prefix)) + }) + }) + .max(); + if let Some(length) = cut { + bytes.truncate(bytes.len() - length); + bytes.extend_from_slice(REDACTED); + } + bytes +} + +pub(super) fn redacted_text(bytes: &[u8], secrets: &[Vec]) -> String { + String::from_utf8_lossy(&redact(bytes, secrets)).into_owned() +} diff --git a/crates/trusted-server-release/src/process/supervise.rs b/crates/trusted-server-release/src/process/supervise.rs new file mode 100644 index 0000000000..c356bfdca1 --- /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 0000000000..70fd1d5a4f --- /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 0000000000..55b00011d6 --- /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 0000000000..5a38ccba27 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/outcomes.rs @@ -0,0 +1,237 @@ +//! Outcome classification, output capture, stdin and helper authorization. + +use super::*; + +#[test] +fn success_captures_stdout_and_stderr_exactly() { + let dir = workspace(); + + let result = helper(&dir, "print", limits(SLACK)) + .run() + .expect("should run a successful child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit zero" + ); + assert_eq!( + payload(result.stdout()), + b"out\nline", + "should capture stdout exactly" + ); + assert_eq!( + result.stderr(), + b"err text", + "should capture stderr exactly" + ); + assert!( + result.elapsed() < SLACK, + "should report a bounded elapsed time" + ); + let stdout = result.require_success().expect("should accept exit zero"); + assert_eq!(payload(&stdout), b"out\nline", "should return stdout"); +} + +#[test] +fn nonzero_exit_reports_code_and_bounded_stderr() { + let dir = workspace(); + + let result = helper(&dir, "exit:7", limits(SLACK)) + .run() + .expect("should run a failing child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 7 }, + "should report the exit code" + ); + let report = result + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + assert!( + text.contains("diagnostic detail"), + "should include the stderr excerpt: {text}" + ); + assert!( + matches!(failure(&report), Some(ProcessFailure::Unsuccessful)), + "should classify the failure as unsuccessful" + ); +} + +#[test] +fn stderr_excerpt_is_bounded() { + let dir = workspace(); + + let report = helper(&dir, "bulk-stderr", limits(SLACK)) + .run() + .expect("should run a failing child") + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + + assert!(text.contains("TAIL"), "should keep the stderr tail: {text}"); + assert!( + text.len() < 20_000 / 2, + "should bound the excerpt well below the captured stderr" + ); +} + +#[test] +fn large_stderr_before_stdout_drains_concurrently() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 4 * 1024 * 1024, + }; + + let result = helper(&dir, "stderr-then-stdout", process_limits) + .run() + .expect("should run a child that fills stderr first"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit zero without blocking on a full stderr pipe" + ); + assert_eq!( + result.stderr().len(), + LARGE_STDERR, + "should capture every stderr byte" + ); + assert_eq!(payload(result.stdout()), b"small", "should capture stdout"); +} + +#[test] +fn signaled_child_reports_signal() { + let dir = workspace(); + + let result = helper(&dir, "raise", limits(SLACK)) + .run() + .expect("should run a signaled child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Signaled { + signal: libc::SIGTERM + }, + "should report the terminating signal" + ); + assert!( + result.require_success().is_err(), + "should reject a signaled child" + ); +} + +#[test] +fn nonexistent_program_reports_real_errno() { + let dir = workspace(); + let missing = PathBuf::from("/nonexistent-trusted-server-release/fixture-tool"); + let command = BoundedCommand::new(&missing, dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + + let report = command.run().expect_err("should fail to spawn"); + + assert!( + matches!( + failure(&report), + Some(ProcessFailure::Spawn { + errno: Some(libc::ENOENT) + }) + ), + "should record ENOENT: {report:?}" + ); + assert!( + format!("{report:?}").contains(&format!("os error {}", libc::ENOENT)), + "should carry the operating system error text" + ); +} + +#[test] +fn bounded_stdin_reaches_child() { + let dir = workspace(); + let command = helper(&dir, "cat", limits(SLACK)).stdin_bytes(b"from stdin".to_vec()); + + let stdout = command + .run() + .expect("should run the cat helper") + .require_success() + .expect("should exit zero"); + + assert_eq!( + payload(&stdout), + b"from stdin|done", + "should deliver stdin bytes" + ); +} + +#[test] +fn default_stdin_is_null() { + let dir = workspace(); + + let stdout = helper(&dir, "cat", limits(SLACK)) + .run() + .expect("should run the cat helper") + .require_success() + .expect("should exit zero"); + + assert_eq!( + payload(&stdout), + b"|done", + "should read EOF from null stdin" + ); +} + +#[test] +fn helper_selector_without_fixture_invocation_fails_the_run() { + assert!( + !unauthorized_helper_run(&[HELPER_TEST, "--test-threads=1"], Some("fixture-nonce")), + "should fail a non-exact filter run that carries a selector" + ); + assert!( + !unauthorized_helper_run( + &["--exact", HELPER_TEST, "--test-threads=1"], + Some("other-nonce") + ), + "should fail an exact run whose nonce does not match" + ); + assert!( + !unauthorized_helper_run(&["--exact", HELPER_TEST, "--test-threads=1"], None), + "should fail an exact run without a nonce" + ); +} + +#[test] +fn helper_authorization_requires_exact_filter_and_nonce() { + let fixture: Vec = ["binary", "--exact", HELPER_TEST] + .into_iter() + .map(str::to_owned) + .collect(); + let loose: Vec = ["binary", HELPER_TEST] + .into_iter() + .map(str::to_owned) + .collect(); + + assert_eq!( + authorized_mode("print:n1", Some("n1"), &fixture), + Some("print".to_owned()), + "should authorize a fixture invocation" + ); + assert_eq!( + authorized_mode("print", Some("n1"), &fixture), + None, + "should reject a selector without a nonce" + ); + assert_eq!( + authorized_mode("print:n1", Some(""), &fixture), + None, + "should reject an empty nonce" + ); + assert_eq!( + authorized_mode("print:n1", Some("n1"), &loose), + None, + "should reject a non-exact filter" + ); +} diff --git a/crates/trusted-server-release/src/process/tests/redaction.rs b/crates/trusted-server-release/src/process/tests/redaction.rs new file mode 100644 index 0000000000..287b516b61 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/redaction.rs @@ -0,0 +1,171 @@ +//! Secret redaction in Debug output and failure excerpts. + +use super::*; + +#[test] +fn secret_values_never_appear_in_debug_or_error_text() { + let dir = workspace(); + let command = helper(&dir, "secret", limits(SLACK)) + .secret_environment("TOKEN", SECRET) + .expect("should accept a secret variable"); + + let command_text = format!("{command:?}"); + let result = command.run().expect("should run the child"); + let result_text = format!("{result:?}"); + let report = result + .require_success() + .expect_err("should reject a nonzero exit"); + let report_text = format!("{report:?}"); + + assert!( + command_text.contains("TOKEN"), + "should still name the secret variable" + ); + for text in [&command_text, &result_text, &report_text] { + assert!(!text.contains(SECRET), "should redact the secret value"); + } + assert!( + report_text.contains("leak:[REDACTED]:"), + "should mark the redacted stderr span: {report_text}" + ); +} + +#[test] +fn secret_cut_at_stderr_cap_is_redacted() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024, + stderr_max: u64::try_from(CUT_PADDING + CUT_KEPT).expect("should fit in u64"), + }; + let command = helper(&dir, "secret-cut", process_limits) + .secret_environment("TOKEN", CUT_SECRET) + .expect("should accept a secret variable"); + + let result = command.run().expect("should run the child"); + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stderr + }, + "should cut stderr at its cap" + ); + let report = result + .require_success() + .expect_err("should reject a capped run"); + let text = format!("{report:?}"); + + let kept = CUT_SECRET + .get(..CUT_KEPT) + .expect("should slice the fixture"); + assert!( + !text.contains(kept), + "should redact a secret prefix cut by the cap: {text}" + ); + assert!(text.contains("[REDACTED]"), "should mark the cut: {text}"); +} + +#[test] +fn cut_tail_redaction_follows_actual_stderr_truncation() { + let secrets = vec![CUT_SECRET.as_bytes().to_vec()]; + let result = |outcome, stderr_truncated| ProcessResult { + program: PathBuf::from("/fixture/tool"), + outcome, + stdout: Vec::new(), + stderr: b"exit 1: Q".to_vec(), + stderr_truncated, + elapsed: Duration::ZERO, + secrets: secrets.clone(), + }; + let text = |result: ProcessResult| { + format!( + "{:?}", + result + .require_success() + .expect_err("should reject an unsuccessful run") + ) + }; + + let complete = text(result(ProcessOutcome::Exited { code: 1 }, false)); + let stderr_capped = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stderr, + }, + true, + )); + // Stdout overflowed first, but stderr was also cut mid-secret. + let both_capped = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + true, + )); + let stdout_only = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + false, + )); + + for untouched in [&complete, &stdout_only] { + assert!( + untouched.contains("exit 1: Q") && !untouched.contains("[REDACTED]"), + "should leave untruncated stderr untouched: {untouched}" + ); + } + for redacted in [&stderr_capped, &both_capped] { + assert!( + redacted.contains("exit 1: [REDACTED]"), + "should redact a possible secret prefix at a stderr cut: {redacted}" + ); + } +} + +#[test] +fn overlapping_secrets_are_fully_redacted() { + let dir = workspace(); + let command = helper(&dir, "overlap", limits(SLACK)) + .secret_environment("FIRST", OVERLAP_FIRST) + .expect("should accept a secret variable") + .secret_environment("SECOND", OVERLAP_SECOND) + .expect("should accept a secret variable"); + + let report = command + .run() + .expect("should run the child") + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + + for fragment in ["Kq1", "Vv8", "Jx3"] { + assert!( + !text.contains(fragment), + "should redact every byte of overlapping secrets: {text}" + ); + } +} + +#[test] +fn redaction_merges_overlapping_ranges() { + let secrets = vec![b"abcXYZ".to_vec(), b"XYZdef".to_vec()]; + + assert_eq!( + redact(b"", &secrets), + b"<[REDACTED]>".to_vec(), + "should replace the merged span once" + ); +} + +#[test] +fn debug_redacts_program_and_working_directory() { + let program = PathBuf::from(format!("/fixture/{SECRET}/tool")); + let working_directory = PathBuf::from(format!("/fixture/{SECRET}")); + let command = BoundedCommand::new(&program, &working_directory, limits(SLACK)) + .expect("should accept absolute paths") + .secret_environment("TOKEN", SECRET) + .expect("should accept a secret variable"); + + let text = format!("{command:?}"); + + assert!(!text.contains(SECRET), "should redact paths"); +} diff --git a/crates/trusted-server-release/src/process/tests/supervision.rs b/crates/trusted-server-release/src/process/tests/supervision.rs new file mode 100644 index 0000000000..a6c6657694 --- /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 0000000000..6b38da4b50 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/variables.rs @@ -0,0 +1,231 @@ +//! Command and environment validation. + +use super::*; + +/// Names that inject code or configuration; rejected even as secrets. +const INJECTION_NAMES: [&str; 54] = [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_ANYTHING", + "DYLD_INSERT_LIBRARIES", + "dyld_insert_libraries", + "NODE_OPTIONS", + "BASH_ENV", + "ENV", + "GCONV_PATH", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_EXEC_PATH", + "GIT_SSH_COMMAND", + "GIT_SSH", + "npm_config_registry", + "NPM_CONFIG_REGISTRY", + "PYTHONPATH", + "PYTHONSTARTUP", + "PERL5OPT", + "PERL5LIB", + "RUBYOPT", + "RUBYLIB", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GIT_PROXY_COMMAND", + "GIT_EXTERNAL_DIFF", + "GIT_EDITOR", + "EDITOR", + "VISUAL", + "GIT_PAGER", + "PAGER", + "GIT_TEMPLATE_DIR", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER", + "RUSTC", + "RUSTDOC", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTFLAGS", + "NODE_PATH", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", +]; + +/// Credential names accepted only through `secret_environment`. +const CREDENTIAL_NAMES: [&str; 12] = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "FASTLY_API_TOKEN", + "github_token", + "CARGO_REGISTRY_TOKEN", + "CARGO_REGISTRIES_EXAMPLE_TOKEN", + "SIGSTORE_ID_TOKEN", +]; + +#[test] +fn relative_program_is_rejected() { + let dir = workspace(); + + let report = BoundedCommand::new(Path::new("helper"), dir.path(), limits(SLACK)) + .expect_err("should reject a relative program path"); + + assert!( + matches!(failure(&report), Some(ProcessFailure::InvalidCommand)), + "should classify an invalid command" + ); +} + +#[test] +fn relative_working_directory_is_rejected() { + let report = BoundedCommand::new(&test_binary(), Path::new("relative"), limits(SLACK)) + .expect_err("should reject a relative working directory"); + + assert!( + matches!(failure(&report), Some(ProcessFailure::InvalidCommand)), + "should classify an invalid command" + ); +} + +#[test] +fn environment_is_exactly_the_explicit_map() { + let dir = workspace(); + let command = helper(&dir, "env", limits(SLACK)) + .environment("ALPHA", "1") + .expect("should accept a plain variable") + .environment("BETA", "two words") + .expect("should accept a plain variable"); + + let stdout = command + .run() + .expect("should run the env helper") + .require_success() + .expect("should exit zero"); + + let text = String::from_utf8(payload(&stdout).to_vec()).expect("should print UTF-8"); + let names: Vec<&str> = text + .lines() + .filter_map(|line| line.split_once('=').map(|(name, _)| name)) + .collect(); + assert_eq!( + names, + vec!["ALPHA", "BETA", HELPER_SELECTOR, HELPER_NONCE, HELPER_PIDS], + "should pass exactly the explicit environment" + ); + assert!( + text.contains("ALPHA=1\nBETA=two words\n"), + "should pass explicit values: {text}" + ); +} + +#[test] +fn injection_variables_are_rejected() { + let dir = workspace(); + for name in INJECTION_NAMES { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "/tmp/fixture.so") + .expect_err("should reject an injection variable"); + assert!( + matches!(failure(&plain), Some(ProcessFailure::InvalidCommand)), + "should classify {name} as an invalid command" + ); + assert!( + !format!("{plain:?}").contains("/tmp/fixture.so"), + "should not echo the rejected value" + ); + let secret = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .secret_environment(name, "/tmp/fixture.so"); + assert!( + secret.is_err(), + "should reject {name} as a secret variable too" + ); + } +} + +#[test] +fn credentials_require_secret_classification() { + let dir = workspace(); + for name in CREDENTIAL_NAMES { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "credential"); + assert!( + plain.is_err(), + "should refuse {name} through the plain environment" + ); + let secret = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .secret_environment(name, "credential"); + assert!(secret.is_ok(), "should accept {name} as a secret variable"); + } +} + +#[test] +fn malformed_environment_names_are_rejected() { + let dir = workspace(); + for name in ["", "A=B", "NUL\0NAME"] { + let command = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + assert!( + command.environment(name, "value").is_err(), + "should reject environment name {name:?}" + ); + } +} + +#[test] +fn harmless_names_are_accepted() { + let dir = workspace(); + for name in ["CARGO_TARGET_DIR", "CARGO_HOME", "LANG", "HOME", "TZ"] { + let command = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + assert!( + command.environment(name, "value").is_ok(), + "should accept {name}" + ); + } +} + +#[test] +fn aws_lc_sys_build_switches_are_plain_variables() { + let dir = workspace(); + for name in [ + "AWS_LC_SYS_USE_SYSTEM", + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + ] { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "0"); + assert!(plain.is_ok(), "should accept {name} as a plain variable"); + } + for name in [ + "AWS_SECRET_ACCESS_KEY", + "AWS_LC_SYS_OTHER", + "AWS_LC_SYS_USE_SYSTEM_", + "AWS_LC_SYS_USE_SYSTEMX", + "AWS_LC_SYS_USE_SYSTEM_x86-64", + ] { + let credential = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "fictional"); + assert!( + credential.is_err(), + "should still refuse {name} as a plain variable" + ); + } +} diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index 1f71b33cb5..3ccfcec6aa 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 0000000000..9ca1ef7072 --- /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 033/104] 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 0000000000..55e27edb2f --- /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 357d79e082..2d4c6dea69 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 3ccfcec6aa..06d75f0358 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 b5e3dc0b8d..343503137c 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 034/104] 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 0402bb266e..8e16560c96 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 048c76d44b..756e021315 100644 --- a/crates/trusted-server-integration-tests/tests/common/runtime.rs +++ b/crates/trusted-server-integration-tests/tests/common/runtime.rs @@ -81,10 +81,9 @@ pub trait RuntimeProcessHandle: Send + Sync {} /// Trait defining how to run the trusted-server on different platforms. /// -/// The application configuration (origin URL, integrations, etc.) is baked -/// into the WASM binary at build time via `build.rs`. The runtime environment -/// only needs the WASM binary path and its own platform-specific config -/// (e.g. Viceroy's `fastly.toml` for KV stores and secret stores). +/// Runtime fixtures supply application configuration (origin URL, integrations, +/// etc.) separately from the WASM binary, alongside platform-specific resources +/// (e.g. Viceroy's `fastly.toml` config, KV and secret stores). pub trait RuntimeEnvironment: Send + Sync { /// Platform identifier (e.g., "fastly", "cloudflare") fn id(&self) -> &'static str; @@ -122,8 +121,8 @@ pub fn wasm_binary_path() -> PathBuf { /// Get the fixed origin port used for Docker container port mapping. /// -/// This must match the port baked into the WASM binary via -/// `TRUSTED_SERVER__PUBLISHER__ORIGIN_URL` at build time. +/// This must match the origin port in the generated runtime application +/// configuration and the container host-port mapping. pub fn origin_port() -> u16 { match std::env::var("INTEGRATION_ORIGIN_PORT") { Ok(value) => value diff --git a/crates/trusted-server-integration-tests/tests/frameworks/mod.rs b/crates/trusted-server-integration-tests/tests/frameworks/mod.rs index e77fb9b91d..14cff07b1c 100644 --- a/crates/trusted-server-integration-tests/tests/frameworks/mod.rs +++ b/crates/trusted-server-integration-tests/tests/frameworks/mod.rs @@ -24,8 +24,8 @@ pub trait FrontendFramework: Send + Sync { /// Build a Docker container request mapped to the given origin port. /// - /// The `origin_port` is the fixed host port that the WASM binary - /// expects the origin to be running on (baked in at build time). + /// The `origin_port` is the fixed host port where the runtime test + /// configuration expects the origin to be running. /// /// # Errors /// diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 2428d7799b..d3ab71424b 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 eca379c014..4213752593 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 035/104] 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 abdb21ee5f..ac3aa43664 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 0000000000..90aa69db31 --- /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 0000000000..a6acccb188 --- /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 55e27edb2f..60ed20a92f 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 036/104] 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 90aa69db31..a3474e7f7d 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 a6acccb188..a82e3228b0 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 037/104] 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 a3474e7f7d..e984d71569 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 0000000000..7d0b6f303f --- /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 0000000000..c71bd4ff35 --- /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 038/104] 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 b621bf2484..f17217e61f 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 48bafce521..bf0a79cc8b 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 e984d71569..115bb122ed 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 0000000000..9b4a5d3d94 --- /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 0000000000..e0be3a8cf6 --- /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 039/104] 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 358f70a7f4..10ad4b3545 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 ac3aa43664..44eac5a2a6 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 0000000000..0b9c1d5918 --- /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 0000000000..f88887c842 --- /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 040/104] 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 f17217e61f..41e3707f1e 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 bf0a79cc8b..113d70277e 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 10ad4b3545..e207a511bd 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 0000000000..aa0cf1ce22 --- /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 0000000000..13c6cec13c --- /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 0000000000..83d9339682 --- /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 0000000000..96dc8a8e57 --- /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 0000000000..f6b0b7cf6f --- /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 0000000000..2ddea479b1 --- /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 0000000000..dfa5c96da3 --- /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 0000000000..6ce4ef24df --- /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 0000000000..81548c77a7 --- /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 0000000000..90487f55a9 --- /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 0000000000..f1f843250a --- /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 0000000000..23b77e478a --- /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 44eac5a2a6..0b0ec7aeb2 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 041/104] 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 115bb122ed..cf42b10b57 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 9b4a5d3d94..348588cd9f 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 e0be3a8cf6..b843dbc11a 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 a82e3228b0..d7acee360f 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 0000000000..2169426d0c --- /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 0000000000..7d8e5eb668 --- /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 042/104] 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 41e3707f1e..10c0996746 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 113d70277e..26cdfbc81c 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 0000000000..68d2bb2d09 --- /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 0000000000..f10022eecf --- /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 0000000000..e6b534dbb7 --- /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 0000000000..83fa19bcf6 --- /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 0000000000..92e57bb080 --- /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 0000000000..c45521ece2 --- /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 0000000000..5523ac7a35 --- /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 0000000000..ee660907d6 --- /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 0000000000..1c92f2d409 --- /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 0000000000..fe920d9cd9 --- /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 0000000000..a3992c885e --- /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 0000000000..b28645e6a5 --- /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 0000000000..de214fc968 --- /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 0000000000..3dc31f7c25 --- /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 0000000000..e88df1a2f2 --- /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 0000000000..2c99e50d1c --- /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 0000000000..c012346cfa --- /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 0000000000..2913b320f6 --- /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 0000000000..9a35fc826c --- /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 0000000000..d846a42e9a --- /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 0000000000..34abeedde3 --- /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 0000000000..e10c4fc30e --- /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 0000000000..0406c88fc9 --- /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 0000000000..c2ca4bcf7e --- /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 0000000000..3c6e077b0c --- /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 0000000000..f96def1e6e --- /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 0000000000..15797928f6 --- /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 0000000000..6f8b11c0b4 --- /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 0000000000..48be302d81 --- /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 0000000000..1b96648d93 --- /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 0000000000..a16188c459 --- /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 0000000000..df23044a37 --- /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 0000000000..c5473fbb2e --- /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 0000000000..a3d355d7f3 --- /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 0000000000..3772ea8d0b --- /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 0000000000..caddbe5f73 --- /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 0000000000..60e8dfdc23 --- /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 0000000000..94ebcf71af --- /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 e207a511bd..1a443c679e 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 13c6cec13c..6c5b455a35 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 0000000000..5c8b90d821 --- /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 0000000000..25fb2f957a --- /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 0000000000..afb55e7849 --- /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 0000000000..a046181849 --- /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 0000000000..1ae0198f2a --- /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 0000000000..2262a4176b --- /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 0b0ec7aeb2..eb018f9ac2 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 0000000000..5ae8d14ef8 --- /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 0000000000..fde4a02a5a --- /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 0000000000..d44df109a7 --- /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 0000000000..794c72fe31 --- /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 0000000000..46e20118c9 --- /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 0000000000..f75e600ac4 --- /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 0000000000..9c8e7b98d5 --- /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 0000000000..b796997c33 --- /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 0000000000..ab96f95f6c --- /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 0000000000..a5aa6bb4b1 --- /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 0000000000..504e178caf --- /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 0000000000..2033926555 --- /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 0000000000..23b2eeeaef --- /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 0000000000..23c855137e --- /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 0000000000..435f0fe49d --- /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 0000000000..debbfc9573 --- /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 0000000000..f4dc546d09 --- /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 0000000000..15a4db8386 --- /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 0000000000..992e211a27 --- /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 0000000000..9e1c9cdb9b --- /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 0000000000..21e74bce64 --- /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 0000000000..149973f16e --- /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 0000000000..98414e0df6 --- /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 0000000000..fd78954f87 --- /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 0000000000..37bd717325 --- /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 0000000000..d76575079a --- /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 0000000000..9d71426a40 --- /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 0000000000..4197f489b2 --- /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 0000000000..95433d2ccb --- /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 0000000000..317ee99a79 --- /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 0000000000..4bc192f2e5 --- /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 0000000000..fda46a4b21 --- /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 0000000000..cf8f907cf7 --- /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 0000000000..29a0d77786 --- /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 0000000000..89209e5526 --- /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 0000000000..d926101753 --- /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 0000000000..cd63f3de1d --- /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 0000000000..1a7786a944 --- /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 0000000000..36333db607 --- /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 0000000000..217f40729d --- /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 0000000000..a43460437f --- /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 0000000000..2a2ef397f4 --- /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 0000000000..e4a066298c --- /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 0000000000..eb145ca5e5 --- /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 0000000000..12e3c01b09 --- /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 0000000000..d483770305 --- /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 0000000000..8fcfb08579 --- /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 0000000000..5c23b80682 --- /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 0000000000..55979a1752 --- /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 0000000000..99d63bc60c --- /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 0000000000..c3ce1aef31 --- /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 0000000000..7f4da7983a --- /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 0000000000..8478593776 --- /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 0000000000..04bfa4fe6c --- /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 0000000000..f90bcefd70 --- /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 0000000000..ccab97af43 --- /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 0000000000..79df270d62 --- /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 0000000000..bb54978f06 --- /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 0000000000..2e5de92591 --- /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 0000000000..641070999f --- /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 0000000000..cf36f7872b --- /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 0000000000..9fb388532f --- /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 0000000000..7270445ca4 --- /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 0000000000..ddb3b2b0a1 --- /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 0000000000..94eeaf7ebe --- /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 0000000000..e9fd4c15f8 --- /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 0000000000..85fc8187ac --- /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 0000000000..ab6f3590db --- /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 0000000000..32db26fa19 --- /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 0000000000..df78b329e7 --- /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 0000000000..3603f19624 --- /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 0000000000..4a02258da2 --- /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 0000000000..9b226a46be --- /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 0000000000..7347c7753f --- /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 0000000000..dd385a638b --- /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 0000000000..8ee226e89f --- /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 0000000000..5b778bb833 --- /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 0000000000..bddea561d4 --- /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 0000000000..feb392b9be --- /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 0000000000..9250e199cd --- /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 0000000000..cfc37ba82f --- /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 0000000000..7e5c593693 --- /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 0000000000..bf429cb3a4 --- /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 0000000000..9ffa3a84bf --- /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 0000000000..6e0f1954c0 --- /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 0000000000..4d47cfc602 --- /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 0000000000..3e98d1e927 --- /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 0000000000..36f97fd1a3 --- /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 0000000000..6a7d24a330 --- /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 0000000000..9823e3bfbe --- /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 0000000000..a95096d69a --- /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 0000000000..2a928bd974 --- /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 0000000000..d12198e2d1 --- /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 0000000000..c2bc532d3e --- /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 0000000000..90084d52ee --- /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 0000000000..e097c0fe33 --- /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 0000000000..ab0cced30a --- /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 0000000000..5926e0a6ff --- /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 0000000000..f5bba8c316 --- /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 0000000000..28582798e6 --- /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(""; - -/// 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("