From 7ce6c163f532e95c5e3b96807aaf415a871cea93 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 14:26:52 +0530 Subject: [PATCH 1/8] Draft Phase 3 SPDX and index approval proposals Record complete-evidence admission and an explicit seven-artifact version-2 index proposal, with two independent review rounds. Implementation remains gated on approval. --- ...inary-attestations-phase3-complete-spdx.md | 85 +++++++++++++++++ ...ttestations-phase3-seven-artifact-index.md | 94 +++++++++++++++++++ .../plans/binary-attestations-phases-2-4.md | 4 +- 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md create mode 100644 docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md new file mode 100644 index 0000000000..61d68d273c --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md @@ -0,0 +1,85 @@ +# Gate 2 proposal: complete SPDX from owning build evidence + +**Status:** Design proposal awaiting approval. No implementation, production selection or qualification is authorized by this document. + +**Baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. Rehearsal `.13` remains evidence only for `770a425df4b28caaaef68dfd930bceabd9928046`. + +**Verification baseline:** [Exact Phase 2 head CI passed](https://github.com/IABTechLab/trusted-server/actions/runs/40654745582); signing/publication and manual negative canaries were skipped as configured. + +**Tracking:** Tracking issue number pending. + +## Decision requested + +Approve strict admission of complete composition from retained, artifact-bound evidence, reusing the reviewed projections and renderer from [frozen source `122b78664`](https://github.com/IABTechLab/trusted-server/tree/122b78664b1235c717e9208fd200f9a9b0c335df). Approve an ordinary-build capture approach that supplies equivalent ownership without pretending to be a controlled recipe. A concrete implementation plan and provider-coverage review must follow approval; current receipts cannot yet satisfy this contract. + +The goal is a complete SPDX 2.3 build-composition document for each of the seven selected binaries. Completeness is relative to an explicitly approved input scope. It does not establish hermetic execution, independent reproduction, vulnerability absence or the contents of an operator's eventual runtime environment. + +| Approach | Benefit | Cost / disposition | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| **Strict admission from owning evidence — recommended** | Preserves the reviewed complete-composition requirement and one shared implementation | Native/platform/tool evidence gaps must actually close before production switches | +| Target-specific rollout | Could admit a supported target earlier | Requires a separate cohort/rollout decision; cannot silently omit incomplete members of the seven-artifact cohort | +| Port candidates while complete generation remains closed | Preserves useful diagnostics and source-port progress | Does not complete Gate 2 or justify a complete-SBOM claim | + +## What exists and what is missing + +The frozen `crates/trusted-server-release/src/spdx.rs::generate_from_completed` always returns an error. It is a closed integration point. The existing `spdx/candidate.rs::candidate_from_completed` already joins four projections from one `RecipeCompleted`, verifies the selected root/source, reads the final bytes and renders a gap-bearing candidate. It rejects a gap-free candidate and does not convert to `GeneratedSpdx`. + +Phase 2 owns `recipe/completed.rs` and the non-SPDX composition capture modules. Its completed build retains source/artifact identities, Cargo observations, JS contributions and measured tool/material context; accessors rehash retained inputs. Owning bytes is necessary but does not prove complete coverage. + +| Evidence class | Existing source | Admission requirement / current gap | +| ----------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cargo | `composition/{capture,origin,graph}.rs` | Actual compiler units, features, host/target roles and verified originals; metadata alone is insufficient | +| JavaScript/npm | `javascript/contributions.rs`, `composition/javascript.rs` | Retained installed-package origins, lifecycle/generated inputs and final-module joins; minifier contributors remain conservative | +| Native sources/static outputs | `composition/native.rs` | Identify contributing implementation/source versions and measured outputs; wrapper crate versions alone are insufficient; coverage is currently false | +| Platform/build tools | `composition/platform.rs`, `recipe/{fastly_tools,apple,gnu}.rs` | Close required material, acquisition, helper/load and SDK coverage; current Fastly/Apple/GNU gaps remain blocking | +| Runtime requirements | `composition/external.rs` | Record artifact-bound logical loader/ABI requirements; do not invent identities for installed operator-runtime bytes | +| Licenses | Frozen `spdx/{cargo,javascript,native,platform}.rs` | Preserve observed declarations and their origin. npm capture currently has no declaration field; missing/unsupported declarations remain explained `NOASSERTION` | + +The current native wrapper pins match the reviewed wrappers (`aws-lc-sys` 0.42.0, `ring` 0.17.14, `libsqlite3-sys` 0.38.2), but that does not close their material/tool coverage. The controlled JS observer still accepts esbuild 0.27.3 while the current lock uses 0.28.2. Its separately reviewed adaptation remains a prerequisite wherever that observer is used; no ignored collector test becomes a pass. + +## Proposed admission and data flow + +1. Start with one sealed owner of an actually completed build. Bind approved source, asset/package, target, variant, final binary, Cargo lock, JS inventory, native outputs and tool/material observations to that owner. Re-measure retained inputs at handoff. +2. Project the owned observations through the existing Cargo, JS, native and platform modules. Preserve the distinction between observed build membership and exact retained machine code; selected inputs removed by linking/minification must not be misrepresented as retained code. +3. Admit complete output only when every required class has positive evidence and no unresolved **in-scope** coverage gap. Unknown contributors, unsupported wrappers/tools, mixed owners, changed originals, missing modules and altered final bytes fail. Existing false flags cannot simply be flipped; candidate gaps cannot be deleted to obtain admission. +4. Supply the shared renderer with validated composition and root context. Keep candidate diagnostics and complete output as distinct types and paths; schema/graph validity alone grants no complete-generation, signing or publication authority. +5. Validate with the retained offline SPDX 2.3 schema, graph/identity checks and bounded strict JSON handling. Recheck binary and document digests before handoff to the existing signer. + +The input scope must explicitly include contributing Rust/npm/native material and the selected compiler, linker, SDK, helper and build-tool inputs required by the approved profile. Actual installed operator-runtime bytes remain outside that build-composition scope and are represented as logical external requirements. Every exclusion must be explicit and reviewed; unresolved contributing native/tool material cannot be relabelled as an exclusion to make a release pass. + +## Ordinary release integration + +The Phase 2 ordinary A/B receipt proves equality under its stated conditions. It supplies neither a `RecipeCompleted` nor complete composition. The ordinary path must not fabricate that private receipt or acquire a `Qualified*` capability. + +The proposed bridge captures the real ordinary Cargo stream, verified source/material originals, fresh JS observations, native outputs and selected tools into a retained owner. It uses the existing capture types and the same admission/projection/renderer core; it does not introduce another SBOM design. Its private interface and acquisition/coverage rules must be specified and independently reviewed in the implementation plan before code is written. In particular, the plan must separate seven ordinary artifact identities from the frozen four controlled-recipe selectors rather than enabling new qualified recipes. + +Until that bridge and every required coverage provider pass admission, production keeps the honestly documented interim inventory. A source port or diagnostic candidate is not completion of Gate 2. Connecting a qualified controlled recipe to production remains its separate approval decision. + +## Compatibility requirements + +- Retain SPDX **2.3**, predicate `https://spdx.dev/Document/v2.3`, `.spdx.json`, `.sbom.sigstore.json` and binary SHA-256 binding. Preserve the seven asset names and existing reusable `release-build.yml` binary signer/job. +- Preserve the published binary root identity `SPDXRef-Artifact` and its approved release-tag version. Validate contributing Cargo package versions against their approved manifests separately. The frozen candidate derives a Cargo root version; the implementation plan must explicitly map that difference without conflating the release tag with a crate version. +- Preserve observed license declarations without asserting legal conclusions. The frozen renderer recognizes MIT, Apache-2.0 and their two ordered OR combinations; other/missing values remain explanatory `NOASSERTION` unless a separately reviewed parser/capture extension is approved. +- A complete document may add components and relationships; the current manual verification commands still authenticate the exact shipped document. Predicate identity alone does not establish coverage completeness. +- No new trigger, policy reader, index, publication adapter, public offline verifier, deployment path or production/qualified selector is opened. GNU masking WIP remains excluded. + +## Failure behavior and acceptance evidence + +Admission failure occurs before attestation and publication. It blocks the selected complete-SBOM cohort; it cannot drop an asset, downgrade a profile or publish a candidate/placeholder under a release filename. Existing single-shot mutations, release-ID handling, immutable readback and read-only recovery remain unchanged. + +The implementation plan must provide tests for each missing coverage class, changed binary/retained bytes, wrong source/root/target, mixed receipts, unknown contributors, fabricated license declarations and candidate-to-complete rejection. Positive fixtures must demonstrate actual ownership and complete evidence, not a caller-supplied completeness boolean. Existing candidate gap assertions and closed qualification tests remain. + +Gate 2 is complete only after real selected builds yield admissible documents for all seven assets, with honest coverage and license metadata. A future approved rehearsal must show unchanged signer/source commands, exact SPDX-predicate equality online and from published bundles, real file/bundle inventory and failure before signing for incomplete evidence. Live signing/publication and sandbox-verifier trust remain subject to their separate gates; this proposal authorizes no rehearsal tag or release. + +## Approval choices + +- Approve the recommended strict scope and admission architecture, including explicit external-runtime boundaries and no implicit native/tool exclusions. +- Approve planning the ordinary owning-capture bridge using the reviewed common core, while keeping production closed until evidence is sufficient. +- Confirm honest `NOASSERTION` license handling and the distinct release-tag/Cargo-version mapping. +- Any narrower scope, target subset, new qualified selector or observer adaptation needs an explicit alternative decision. + +## Review log + +- **Round 1 (2026-10-28, fresh independent AI design review):** No blocking findings. Checked both Gate 2/3 approval proposals against the approved phase assignment, frozen source and current ordinary release contract. Confirmed ownership/completeness boundaries, the seven identities and 53-file destination, narrow ordinary-profile handling, and separate consumer/publication/trust gates. Coverage closure, observer adaptation and concrete implementation plans remain prerequisites. Read-only source review; no tests or runtime qualification were performed. + +- **Round 2 (2026-10-28, a second fresh independent AI design review):** No blocking findings or required corrections. Rechecked the proposals against the approved phase assignment, frozen source and current ordinary release contract. Confirmed owning-evidence admission and its unresolved coverage prerequisites, version/license compatibility, exact seven-artifact membership, v2 separation from frozen v1, the 53-file inventory and narrow informational ordinary-profile handling. Concrete implementation plans and separate publication, consumer and trust approvals remain required. Read-only source review; no tests or runtime qualification were performed. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md new file mode 100644 index 0000000000..dfef6cb7d7 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md @@ -0,0 +1,94 @@ +# Gate 3 proposal: seven-artifact release-index amendment + +**Status:** Design proposal awaiting approval. No schema, policy, consumer, producer or workflow implementation is changed here. + +**Baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. `.13` remains attributed exclusively to `770a425df4b28caaaef68dfd930bceabd9928046`. + +**Verification baseline:** [Exact Phase 2 head CI passed](https://github.com/IABTechLab/trusted-server/actions/runs/40654745582); signing/publication and manual negative canaries were skipped as configured. + +**Tracking:** Tracking issue number pending. + +## Decision requested + +Approve a **schema-version 2** index contract for exactly the existing seven binaries, preserving public filenames and frozen v1 bytes. Approve explicit informational handling of the existing ordinary Fastly comparison profile, with no controlled-qualification inference. Complete evidence, live signing/publication and the public consumer remain separately gated. + +The approved [phase assignment](binary-attestations-phases-2-4.md#separate-design-approval-gates) calls for a reviewed v1.1/v2 amendment. The retained v1 schema fixes integer `schema_version: 1` and exactly four entries; it has no minor-version mechanism. Today’s ordinary cohort has seven entries. Neither contract can silently stand in for the other. + +| Approach | Benefit | Cost / disposition | +| --------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| **New version 2 — recommended** | Distinguishes seven-asset acceptance and policy identity; preserves frozen v1 and fixtures | Requires coordinated producer/schema and later consumer changes | +| Replace unpublished v1 explicitly | Fewer version branches | Reuses an exactly-four identity for a different contract; avoid historical-policy ambiguity | +| Keep index deferred | Preserves today’s 30-file distribution | Does not complete Gate 3; never publish a partial four-asset index for seven selected binaries | + +## Exact cohort + +All rows retain `variant: default`, `default_features: true`, `features: []`. These are current ordinary cohort descriptors; their destination labels do not prove controlled qualification. + +| Asset | Package / binary | Role / kind | Target | Runtime / minimum / architecture | Capability | Assurance profile | +| ----------------------------------------------- | ----------------------------------------------------- | ------------------------------------ | --------------------------- | -------------------------------------- | ------------------- | ---------------------------- | +| `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly` / same | `server` / `wasm-core` | `wasm32-wasip1` | `fastly-compute` / `wasip1` / `wasm32` | `fastly-production` | `provenance-sbom-repeatable` | +| `ts-aarch64-apple-darwin` | `trusted-server-cli` / `ts` | `operator-cli` / `native-executable` | `aarch64-apple-darwin` | `macos` / `13.0` / `aarch64` | `cli-execute` | `provenance-sbom` | +| `ts-x86_64-apple-darwin` | `trusted-server-cli` / `ts` | `operator-cli` / `native-executable` | `x86_64-apple-darwin` | `macos` / `13.0` / `x86_64` | `cli-execute` | `provenance-sbom` | +| `ts-x86_64-unknown-linux-gnu` | `trusted-server-cli` / `ts` | `operator-cli` / `native-executable` | `x86_64-unknown-linux-gnu` | `linux-gnu` / `glibc-2.34` / `x86_64` | `cli-execute` | `provenance-sbom` | +| `trusted-server-axum-x86_64-unknown-linux-gnu` | `trusted-server-adapter-axum` / `trusted-server-axum` | `server` / `native-executable` | `x86_64-unknown-linux-gnu` | `linux-gnu` / `glibc-2.34` / `x86_64` | `axum-production` | `provenance-sbom` | +| `trusted-server-axum-x86_64-unknown-linux-musl` | `trusted-server-adapter-axum` / `trusted-server-axum` | `server` / `native-executable` | `x86_64-unknown-linux-musl` | `linux-musl` / `static` / `x86_64` | `axum-production` | `provenance-sbom` | +| `trusted-server-axum-aarch64-unknown-linux-gnu` | `trusted-server-adapter-axum` / `trusted-server-axum` | `server` / `native-executable` | `aarch64-unknown-linux-gnu` | `linux-gnu` / `glibc-2.34` / `aarch64` | `axum-production` | `provenance-sbom` | + +Recommend retaining the opaque legacy cohort ID `phase1-fastly-cli`, distinguished by the explicitly new schema/policy identity. A coordinated rename is an alternative approval choice, not an implicit part of this amendment. Loader floors remain distinct from actually tested operating systems. Native reproduction remains a bounded measurement, with no equality gate. No Fastly staging/reuse variant or additional Axum hardening is enabled. + +## Amendment and ownership + +| Area | Required future change | Boundary preserved | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Governing schema | Add a separately approved `docs/superpowers/specs/schemas/release-index-v2.schema.json`; require version 2 and each of the seven identities exactly once | Existing v1 raw bytes remain unchanged; no open-ended artifact list | +| Producer index descriptors | Represent the seven rows and strict source/digest joins in `distribution/**` and its policy checks | Extending index identities must not inhabit `Qualified*`, extend controlled recipe selection or enable Axum execution | +| Types/compatibility | Add only required Axum, musl/static and GNU aarch64 descriptor values | Existing binary names, default variant and other closed values remain | +| Consumer policy identity | Explicitly review the new `policy_version`, raw-byte hash and seven entries in the existing policy | Downloaded metadata never selects authority; existing signer/repository/tool pins are not silently changed | +| Consumer implementation | Phase 4 updates `src/release/{policy,bounds,evidence}.rs` and `evidence/joins.rs` in the CLI | Phase 3 does not double-port the public consumer or change its `readonly-verify` action | +| Bounds | Explicit seven-executable count for version 2 | Preserve byte/depth/process/deadline limits unless separately reviewed; measure larger-cohort cost | + +Keep source, recipe-source and cohort-source commits equal to the independently approved commit, as in v1. Reject duplicate JSON keys, duplicate/conflicting identities, unsafe names, unknown versions/profiles/capabilities and mismatched inventories. Evaluate schemas offline using their approved dialect/bytes. Do not narrow SemVer/build-metadata acceptance or relax other bounds as a side effect. + +The frozen four-destination `ReleaseTarget`/`FIXED_ARTIFACTS` tables also select controlled recipes. A seven-entry index descriptor is metadata, not execution authority; the implementation plan must preserve that separation. Historical producer-policy acceptance cannot override the consumer’s compiled cohort/schema restrictions. The historical-policy list remains unchanged unless its exact entries are separately approved. + +## Distribution layout + +The destination is **53 real files**, not today’s 30-file distribution: + +- Four release-wide files: `release-index.json`, `release-index.provenance.sigstore.json`, `tsjs-inventory.json`, `SHA256SUMS`. +- Seven per executable: the executable, `.spdx.json`, `.provenance.sigstore.json`, `.sbom.sigstore.json`, `.build-evidence.json`, `.build-recipe.json`, `.rebuild-check.json`. + +Thus `4 + 7 × 7 = 53`; `SHA256SUMS` covers exactly the other 52 files. The index digests exact companion/inventory bytes and excludes its own digest/bundle; their identities remain external. The future signing order must avoid cycles: binary evidence/bundles, then index, then index bundle, then checksums/publication. + +SPDX remains 2.3 with predicate `https://spdx.dev/Document/v2.3`. Existing binary filenames, genuine bundle names and reusable signer identity remain unchanged. No placeholder/empty bundles, invented native measurement documents or candidate companions may fill the destination slots. Live writers and publication ownership require Gate 4 approval. + +## Ordinary Fastly receipt compatibility + +Keep the permanent filename `trusted-server-fastly.wasm.rebuild-check.json`. The currently shipped receipt is version `1`, profile `ordinary-release-fastly-byte-equality-v1`, with `release_qualification:false`. Phase 3 must index its **exact existing bytes**; the frozen candidate renderer must not replace or reserialize it. + +Producer `distribution/joins.rs::check_comparison` and later consumer `release/evidence/joins.rs::parse_comparison` need explicit profile dispatch. Validate ordinary source/target/default variant, final binary size/digest, recipe/lock/JS identities, current-run A/B jobs and retained transfer bindings. Unknown/malformed profiles or mismatched joins fail. + +Frozen consumer `release/evidence.rs` rejects any false qualification value as a candidate. The only proposed exception is informational acceptance of this exact ordinary Fastly rebuild-check profile after strict validation and authenticated membership. Other candidate companions remain rejected; do not globally allow false qualification. Candidate sentinel tags/domains, missing workflow identities and awaiting-signature empty bundles never become production evidence. + +The ordinary profile conveys its narrower equality claim. It cannot satisfy a controlled-qualification requirement; policy must reject it wherever that stronger requirement applies. Cohort labels alone do not grant authority. Later controlled evidence uses a distinct approved profile in the same filename; incompatible field changes require a separately reviewed receipt-version change. The index’s version 2 does not change the receipt’s version 1. + +## Admission, failures and verification + +Schema approval is not permission to publish a seven-entry signed index. Publication still needs Gate 2 admissible evidence for all selected artifacts, real companion/bundle writers and the separately approved signing/publication integration. A missing member blocks that selected cohort; neither a partial index nor a downgraded profile is a recovery path. Actual native measurement outcomes may differ without creating a native equality gate. + +Test exact seven-entry membership, all identity tuples and bounds; every missing/duplicate/extra asset; source/recipe/cohort mismatches; ordinary versus controlled profile dispatch; false-qualification candidate rejection; stale/mismatched transfers; and raw companion-byte preservation. Keep v1 fixtures unchanged and test explicit version-2 policy acceptance, not automatic historical acceptance. + +Future live rehearsal follows Gate 4 and sandbox-trust approval: independently verify every genuine asset, index/bundle and checksum; preserve existing binary provenance/SPDX commands; compare the indexed ordinary receipt byte-for-byte; test a missing/changed companion before publication. Production policy’s official repository/signer trust is not expanded to a fork through downloaded index contents. Gate 6 owns that rehearsal trust mechanism. + +## Approval choices + +- Approve version 2 with frozen v1 retained, the seven exact rows and the 53-file destination layout. +- Retain the legacy cohort ID with explicit new schema/policy identity, or explicitly choose a coordinated rename. +- Approve the narrow informational ordinary-profile handling, with controlled requirements and candidate restrictions preserved. +- Keep public consumer code in Phase 4 and live publication/sandbox trust behind Gates 4/6. No new schema/policy file is created before this design and its concrete implementation plan are approved. + +## Review log + +- **Round 1 (2026-10-28, fresh independent AI design review):** No blocking findings. Checked both Gate 2/3 approval proposals against the approved phase assignment, frozen source and current ordinary release contract. Confirmed ownership/completeness boundaries, the seven identities and 53-file destination, narrow ordinary-profile handling, and separate consumer/publication/trust gates. Coverage closure, observer adaptation and concrete implementation plans remain prerequisites. Read-only source review; no tests or runtime qualification were performed. + +- **Round 2 (2026-10-28, a second fresh independent AI design review):** No blocking findings or required corrections. Rechecked the proposals against the approved phase assignment, frozen source and current ordinary release contract. Confirmed owning-evidence admission and its unresolved coverage prerequisites, version/license compatibility, exact seven-artifact membership, v2 separation from frozen v1, the 53-file inventory and narrow informational ordinary-profile handling. Concrete implementation plans and separate publication, consumer and trust approvals remain required. Read-only source review; no tests or runtime qualification were performed. diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index 44b0d71a13..f79a2268e1 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -1,6 +1,6 @@ # Binary attestations phases 2–4 -**Status:** Assignment approved with dependency-closure corrections on 2026-10-26. The Phase 2 source port and approved ordinary Fastly integration are complete in draft #1449, rehearsed at `770a425df4b28caaaef68dfd930bceabd9928046`. The original completion record was docs-only; the later main merge changes application inputs without changing release machinery and is not re-rehearsed. Qualification and the remaining Phase 3/4 design gates stay closed. +**Status:** Phase 2 port and approved ordinary Fastly integration are complete in draft #1449. Its current baseline `8a797dbcb0467b867dfbcc571597110b9254d672` has green CI and is tree-equivalent to `08e1c33b8`; later application inputs remain unrehearsed, with `.13` exclusively at `770a425df4b28caaaef68dfd930bceabd9928046`. Phase 3 currently contains only [Gate 2 complete-SPDX](binary-attestations-phase3-complete-spdx.md) and [Gate 3 seven-artifact index](binary-attestations-phase3-seven-artifact-index.md) approval proposals. Phase 3 implementation, qualification and remaining design gates stay closed. **Goal:** Land the remaining reviewed source as three stacked, independently checked draft PRs while preserving the Phase 1 release contract. @@ -8,7 +8,7 @@ Frozen source: `122b78664b1235c717e9208fd200f9a9b0c335df` from #1262; source-map base: `f9281917a7260b723f5f22175911c6d2147ef650`. A fresh read of #1262 on 2026-10-26 confirmed that its published head is still the frozen source. Introducing and shaping SHAs remain recorded per group in [the slice map](binary-attestations-slice-map.json); they are provenance references, not safe whole-commit cherry-pick boundaries. -Current Phase 1 base: `20152315c01dd5fddcfc84b969f8e9993b0bfc69`, docs-only evidence after rehearsal `.15` at `68eef902a8bddd9024676cc3806fa5377ed67fee`. Phase 2 `.13` stays attributed to `770a425df4b28caaaef68dfd930bceabd9928046`; the subsequent main merge is a no-release-machinery merge, with changed application inputs and no fresh Phase 2 rehearsal. +Phase 1 review log was added at `c9a6117dd452d8a7f9786a14a000fc48d09315ec`, followed by the web merge of main `054c7b777` at `4940928e800fed00e4bddd8f3727fb52dcb2b296`. Its tree equals the already checked synthetic merge `52978c5b`; `.15` remains evidence only for `68eef902a8bddd9024676cc3806fa5377ed67fee`. Phase 2 carries the updated ancestry with no additional file changes. PRs retain ordinary branch-based stacking and merge-only history; the native stack wrapper was removed without changing PR bases or source history. | Draft PR | Branch | Base | Intended capability | | ------------------------------------------------------------------ | ------------------------------------ | ------------------------------------ | -------------------------------------------------------- | From b1b88070f1c51829c7f427bfda0d91f2ba4fd623 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 15:51:31 +0530 Subject: [PATCH 2/8] Plan Phase 3 evidence coverage and versioned index boundaries --- ...ons-phase3-complete-spdx-implementation.md | 79 +++++++++++++++++ ...inary-attestations-phase3-complete-spdx.md | 6 +- ...se3-seven-artifact-index-implementation.md | 87 +++++++++++++++++++ ...ttestations-phase3-seven-artifact-index.md | 12 +-- .../plans/binary-attestations-phases-2-4.md | 4 +- 5 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md create mode 100644 docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md new file mode 100644 index 0000000000..e0ecd51ab2 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md @@ -0,0 +1,79 @@ +# Gate 2 implementation planning: coverage and rollout decision + +| Asset | Cargo | JS/npm | Native | Platform/build tools | Runtime requirements | Licences | +| ----------------------------------------------- | --------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------- | ------------------------------- | +| `trusted-server-fastly.wasm` | Partial: actual stream, filtered catalog; **C / P** | Partial: installed tree/private hashes; **J / P** | Missing ordinary owning receipt; **N / U** | Partial Rust/Node identities; **TF / U** | Partial Wasm checks/packaging; **RW / P** | Partial declarations; **L / U** | +| `ts-x86_64-unknown-linux-gnu` | Generator inventory only; **C / P** | Installed tree only; **J / P** | Missing ordinary owning receipt; **N / U** | Base image only, derived acquisition missing; **TG / U** | ELF/GLIBC/startup checked; **RG / P** | Partial declarations; **L / U** | +| `ts-aarch64-apple-darwin` | Generator inventory only; **C / P** | Installed tree only; **J / P** | Missing ordinary owning receipt; **N / U** | SDK/toolchain closure unresolved; **TA / U** | Mach-O/version/startup checked; **RM / P** | Partial declarations; **L / U** | +| `ts-x86_64-apple-darwin` | Generator inventory only; **C / P** | Installed tree only; **J / P** | Missing ordinary owning receipt; **N / U** | SDK/toolchain closure unresolved; **TA / U** | Mach-O/version/startup checked; **RM / P** | Partial declarations; **L / U** | +| `trusted-server-axum-x86_64-unknown-linux-gnu` | Generator inventory only; **C / P** | Installed tree only; **J / P** | Missing ordinary owning receipt; **N / U** | Base image only, derived acquisition missing; **TG / U** | ELF/GLIBC/container startup checked; **RG / P** | Partial declarations; **L / U** | +| `trusted-server-axum-x86_64-unknown-linux-musl` | Generator inventory only; **C / P** | Installed tree only; **J / P** | Static libc/crt/runtime ownership missing; **N / U** | Selected package pins, closure missing; **TS / U** | Static ELF/startup checked; **RS / P** | Partial declarations; **L / U** | +| `trusted-server-axum-aarch64-unknown-linux-gnu` | Generator inventory only; **C / P** | Installed tree only; **J / P** | ARM native ownership missing; **N / U** | ARM derived image/acquisition missing; **TG / U** | ELF/GLIBC/ARM startup checked; **RA / P** | Partial declarations; **L / U** | + +Each cell names its **exact closure contract** below. **P** means a bounded ordinary capture provider is feasible on the existing hosted job without a new qualification resource, subject to its implementation/provider review; it does not mean the cell is complete now. **U** means complete closure is unproven or potentially costly with current resources. In particular, licensing evidence cannot close for missing native/tool originals merely because Cargo/npm declarations are available. + +**Status:** Planning stopped at the coverage/rollout decision, as requested. Gate 2's strict design is approved; no implementation plan or code is approved. The matrix exposes costly native/platform/acquisition cells for every target, especially Apple SDK/toolchain, GNU derived images and musl startup/compiler runtimes. No promise is made that the existing hosted setup can close them within current job limits. No target is silently removed and no unresolved contributor is excluded. + +**Tracking:** Part of #1235; draft #1463. Source: Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c` and frozen #1262 `122b78664b1235c717e9208fd200f9a9b0c335df`. This document changes no schema, policy, producer, workflow or release selection. + +## Evidence that closes each cell + +Every receipt must identify its actual source, build invocation, package/bin, target, variant and final binary SHA-256. Retained originals and derived observations must share one owner and be rehashed at handoff; a post-build dependency query alone is insufficient. + +| Contract | Exact evidence required for closure | Current source finding and hosted feasibility | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **C — Cargo** | Actual successful Cargo JSON stream; same-execution unfiltered metadata identity catalog; explicit-target non-dev tree and query arguments; effective host/target unit features; observed package IDs joined to verified workspace manifests, registry archives or Git originals | Only Fastly retains the build stream; ordinary metadata is target-filtered. Extend ordinary capture in the actual job/container; no qualified recipe is needed. `phase1-build.sh`, `ordinary/inputs.rs`, `composition/capture.rs`, frozen `spdx/cargo.rs` | +| **J — JS/npm** | Actual private bundle set and post-transform hashes; source/npm/generated contribution origins; verified npm originals/integrity and installed trees; native npm tool identities; actual lifecycle events/outputs; final executable module membership; explicit build-material versus retained-code roles | Current ordinary hashing proves neither complete composition nor embedded membership. An ordinary capture adapter can use the existing hosted jobs and shared owning/projection core, but requires provider review. `ordinary/inputs.rs`, `javascript/contributions.rs`, `composition/javascript.rs`, frozen `spdx/javascript.rs` | +| **N — native** | Wrapper/context/features, verified original and source/subtree identity, observed linker/static directives, retained output archive hashes, and selected objects/startup/standard-library/compiler-runtime materials | Three reviewed wrappers are not all contributing native material. `composition/native.rs` explicitly leaves material/native coverage false. Ordinary releases have no owning native receipt; musl and ARM require additional ordinary target support. Full closure/cost is unproven; GNU masking WIP stays excluded | +| **TF — Fastly build tools** | Actual selected/executed compiler, assembler, linker, archiver and helpers; relevant loaded libraries/resources; verified Rust sysroot/tool originals and acquisition; selected host build-tool inputs | `recipe/fastly_tools.rs` measures selected files without helper-execution/closure claims; `composition/platform.rs` leaves assembler/build-tool gaps. Ordinary Rust/Node executable hashes do not close this cell. A hosted complete-closure provider and cost assessment are not approved | +| **TA — Apple platform/tools** | Artifact-bound selected Xcode toolchain and SDK inputs, headers/stubs/frameworks/resources, actual helper/library closure and acquisition records | `recipe/apple.rs` always retains unresolved coverage, including toolchain libraries. Selected directory measurement is insufficient. No complete ordinary SDK/tool acquisition/closure provider exists; do not promise feasibility or exclude these inputs | +| **TG — GNU platform/tools** | Actual target-specific base and derived image identities; exact installed package originals/versions/acquisition; invoked tool/helper/load closure; headers/crt/compiler-runtime inputs | Ordinary UBI base is pinned, but `dnf install` packages are not version-pinned and complete derived inputs are not retained. ARM needs its actual ARM producer's records. Existing hosted containers can collect facts; complete acquisition/closure remains a costly design decision | +| **TS — musl platform/tools** | Exact selected musl/GCC packages and originals, wrappers/helpers, headers, archives, crt and compiler runtimes, bound to the static executable | Selected apt versions and a static smoke are insufficient ownership. Existing hosted jobs can collect facts; complete static runtime/tool material coverage is not established | +| **RW — Wasm requirements** | Final-byte Wasm import modules/requirements and artifact digest, plus the accepted target/runtime identity | Existing format/packaging checks are supporting evidence. Retain a logical requirements receipt through the shared inspection core; actual installed operator runtime bytes are outside build composition | +| **RM — Mach-O requirements** | Final-byte CPU, loader/dylib requirements, minimum OS and SDK load facts, bound to the executable hash | Existing Mach-O/minimum-OS/version/startup checks are supporting evidence. Retain their logical requirements; they do not prove SDK/tool closure or execution on macOS 13 | +| **RG — GNU x86_64 requirements** | Final-byte interpreter, needed libraries and symbol-version/ABI requirements, with accepted target and executable hash | Existing ELF/GLIBC/startup checks are supporting evidence. Retained external-requirement projection is feasible on the current job | +| **RS — static musl requirements** | Artifact-bound accepted static ELF/architecture facts, proving no loader/needed libraries for that output | Existing static/startup checks can supply observations. The frozen external-requirement model needs an ordinary static-target extension, without opening controlled execution | +| **RA — GNU ARM requirements** | Artifact-bound ARM ELF interpreter, libraries and version requirements, using the actual ARM output | Existing ARM startup/GLIBC checks can supply observations. The frozen external-requirement model hard-codes GNU x86_64 and needs ordinary ARM metadata support | +| **L — licence evidence** | Declarations and relevant licence/notice files from each verified original, joined by exact material and source path; record unsupported/missing declarations as explained `NOASSERTION` | Frozen Cargo projection carries declarations; npm typed originals/install observations lack a licence field, and native/platform projections do not establish declarations. Cargo/npm capture is feasible; native/tool/SDK originals remain dependent on **N/TF/TA/TG/TS**. This is an evidence statement, not a legal conclusion | + +The offline SPDX schema and a valid graph cannot close any missing cell. Frozen `spdx.rs::generate_from_completed` is a closed error path; the candidate renderer deliberately retains gaps and cannot be promoted by deleting them. Production complete generation stays closed until every cell for all seven assets passes the approved admission rules. + +## Proposed ordinary capture and observer boundary + +Ordinary releases do **not** inherently depend on the frozen esbuild observer. They use fresh `npm ci` and private Rust build outputs; current ordinary measurement does not invoke `observe-npm.mjs`. + +The proposed direction is an ordinary owning-capture adapter feeding the same reviewed composition/projection/renderer core, not another SBOM format or a fabricated `RecipeCompleted`. It must retain the actual installation/build evidence required by **J**. Its concrete provider and lifecycle observation need separate implementation review after the rollout decision. + +If that work instead reuses the controlled observer, stop for a separate adaptation decision first. Its esbuild 0.27.3 restriction remains frozen while the actual lock uses 0.28.2. Reviewing native selection, lifecycle commands, output mutation, hardlinks and fallback/download behavior is required; changing a version literal is insufficient. The opt-in collector remains visibly disabled until that decision and review. + +## Concrete input scope and exclusions for review + +Include contributing Rust source/archives/Git trees, host and target Cargo units, proc macros/build scripts, generated objects and native libraries, Rust sysroot/compiler runtimes, selected compiler/linker/assembler/archiver and relevant helpers/libraries/resources, SDK/header/stub/framework/crt inputs, installed npm build/dev dependencies, native bundler distributions, lifecycle outputs, generated plugin helpers and the actual private bundles. Include logical source-controlled build inputs and the exact command/feature/target/profile context. + +Distinguish build material from final runtime contributors. CLI JS that is not retained in its final executable can still be build material; type-only or eliminated modules can still be compiler inputs. Installed/executed material is not excluded merely because linking or minification removes its runtime contribution. + +The only proposed exclusions are demonstrably unselected/uninstalled optional packages and uncompiled dev-only Cargo units; operator-owned runtime configuration, secrets and actual installed runtime/store bytes; and separately produced operator assets such as an external Prebid bundle. Represent runtime libraries/imports as logical external requirements. An installed npm Prebid package still remains build material if selected by this build. Unresolved native, tool, SDK, startup, system-library or compiler-runtime contributors are **never** exclusions. + +## Public artifact version versus Cargo package version + +Keep `SPDXRef-Artifact`, its public asset filename, exact release-tag `versionInfo` from `RELEASE_VERSION`/`github.ref_name`, and SHA-256 of the canonical executable. The source commit remains independently recorded; it is not substituted for the tag version. + +Validate the contributing root Cargo component against its verified manifest version, currently workspace `0.1.0`, and retain its Cargo package identity separately. Frozen candidate `root_version()` derives a Cargo version; port that validation without making it the public artifact version selector. + +The interim converter also inherits a Cargo purl on the artifact root. The complete-renderer plan must explicitly resolve that mapping: the proposed mapping puts the Cargo purl/version on the separate verified Cargo component and relates it to `SPDXRef-Artifact`, without renaming the public root or changing the tag version. This external-reference adjustment requires approval with the implementation plan; it is not a silent change authorized here. + +## Stop point: target-specific rollout decision + +No implementation task list is authorized by this feasibility document. The costly **U** cells require a user decision before code: + +| Option | What proceeds | Production consequence | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Evidence-first waves — recommended** | Plan bounded ordinary Cargo/JS/runtime ownership first; separately design/cost Fastly host-tool closure, GNU/ARM image acquisition, musl runtime materials and Apple SDK/toolchain closure | Keep all seven assets and honest interim inventories; complete generation and the complete seven-row index remain closed until all seven pass | +| Target-specific complete-SBOM rollout | First establish one target's complete closure, then separately approve its production claim while others retain interim inventories | Requires an explicit amendment to Gate 2's all-seven activation condition. Never drops binaries or silently activates a seven-entry complete index; v2 production stays closed | +| Full-cohort feasibility first | Pause capture implementation until each target has an approved acquisition/closure method and bounded cost assessment | Preserves strict all-seven activation and current release behavior; complete evidence remains deferred without a false completion claim | + +The first option supports progress without promising a cheap Fastly exception: Fastly tool closure is itself unresolved. Approving either design proposal is not approval of a new qualification resource, controlled selector, schema/policy file, production switch or rehearsal tag. + +## Plan review + +Independent read-only plan review on 2026-10-28 found no blocking findings against Phase 2 source `5fa384336` and frozen source `122b78664`. It confirmed the strict all-seven coverage stop, frozen v1 bytes, narrow ordinary-profile handling and cycle-free inventory. A policy-source wording inconsistency in the design was corrected to refer to a separately approved v2 policy. No tests, implementation or runtime qualification were performed by this review. User rollout/plan approvals remain pending. diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md index d5c8287f0e..875a5975d3 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md @@ -1,6 +1,6 @@ # Gate 2 proposal: complete SPDX from owning build evidence -**Status:** Design proposal awaiting approval. No implementation, production selection or qualification is authorized by this document. +**Status:** Gate 2 design approved on 2026-10-28 for strict admission from retained, artifact-bound owning evidence. Plan only: production generation stays closed until all seven binaries pass. The [coverage matrix and rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) stops at costly evidence gaps before implementation; no code, qualification or rehearsal tag is authorized. **Historical reviewed baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. Rehearsal `.13` remains evidence only for `770a425df4b28caaaef68dfd930bceabd9928046`. @@ -10,9 +10,9 @@ **Tracking:** Part of #1235. -## Decision requested +## Approved design scope -Approve strict admission of complete composition from retained, artifact-bound evidence, reusing the reviewed projections and renderer from [frozen source `122b78664`](https://github.com/IABTechLab/trusted-server/tree/122b78664b1235c717e9208fd200f9a9b0c335df). Approve an ordinary-build capture approach that supplies equivalent ownership without pretending to be a controlled recipe. A concrete implementation plan and provider-coverage review must follow approval; current receipts cannot yet satisfy this contract. +The user approved strict admission of complete composition from retained, artifact-bound evidence, reusing the reviewed projections and renderer from [frozen source `122b78664`](https://github.com/IABTechLab/trusted-server/tree/122b78664b1235c717e9208fd200f9a9b0c335df). The approved direction is an ordinary-build capture approach that supplies equivalent ownership without pretending to be a controlled recipe. A concrete implementation plan and provider-coverage review must follow approval; current receipts cannot yet satisfy this contract. The goal is a complete SPDX 2.3 build-composition document for each of the seven selected binaries. Completeness is relative to an explicitly approved input scope. It does not establish hermetic execution, independent reproduction, vulnerability absence or the contents of an operator's eventual runtime environment. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md new file mode 100644 index 0000000000..71f22390f3 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md @@ -0,0 +1,87 @@ +# Gate 3 implementation plan: versioned seven-artifact index + +**Status:** Proposed plan awaiting user approval. The Gate 3 design is approved with a new neutral v2 cohort ID, `trusted-server-binaries`. This plan authorizes no implementation, new schema/policy file, production selector or rehearsal tag. Consumer implementation remains Phase 4; live signing/publication and fork trust remain Gates 4/6. + +**Tracking:** Part of #1235; draft #1463. Read with the [approved design](binary-attestations-phase3-seven-artifact-index.md), [Gate 2 coverage/rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) and [phase assignment](binary-attestations-phases-2-4.md). + +## Frozen and new identities + +| Contract | Frozen v1 | Proposed v2 | +| ------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Index version | Integer `schema_version: 1`, four entries | Integer `schema_version: 2`, exactly the seven approved identities | +| Cohort | `phase1-fastly-cli` | `trusted-server-binaries`; no phase or count in the ID | +| Policy | Existing four-row `crates/trusted-server-cli/release-policy.json` | Additive, separately approved policy version 2 with only the new cohort ID | +| Schema | Existing `release-index-v1.schema.json` | Additive `release-index-v2.schema.json` | +| Published filenames | Existing filenames | Same binary/signer/companion names; index/evidence assets are additive | +| Execution authority | Four controlled target selectors remain closed | Seven metadata descriptors do not expand controlled execution or inhabit a qualified type | + +Existing v1 policy and schema currently match frozen source `122b78664b1235c717e9208fd200f9a9b0c335df` byte-for-byte. Their SHA-256s are respectively `f08ce10cd16ff2d1acc482db2b85eb53b667ec2b4753792f277d12f41d1f636c` and `06a7e598f1d14b499d087ec0c894829b3aab26a690b35e5922d96987a877bb9e`. Keep their fixtures and historical-policy list unchanged. Do not rename existing files or mutate the existing ordinary cohort file to masquerade as v2. + +## Explicit meaning of `provenance-sbom-repeatable` + +**Proposed v2 definition:** authenticated provenance and the approved composition/SPDX evidence, plus mandatory matched complete-final-byte A/B equality under an explicitly approved comparison profile. For the Fastly default row, the accepted ordinary profile is exactly `ordinary-release-fastly-byte-equality-v1`. + +That ordinary profile proves the project's observed ordinary-build repeatability with its recorded source/input/tool/environment/run/transfer joins. It does **not** establish controlled-recipe, acquisition, confinement, alternate-user or independent-builder qualification. Policy and public verification documentation must state this definition before v2 is accepted. + +This is a versioned policy amendment. The original destination spec and frozen v1 policy's stronger controlled-recipe requirements do not change. A consumer requiring those stronger guarantees must still reject the ordinary receipt. If the user chooses to retain controlled qualification as v2's definition instead, the present Fastly row cannot pass it: its receipt explicitly has `release_qualification:false`, and qualified selection stays closed. + +Under the recommended definition, today's matched ordinary receipt can satisfy **the repeatability condition**. It cannot make a complete v2 release pass today: admissible complete evidence for all seven binaries, genuine companions and live publication do not yet exist. Gate 2 and Gate 4 remain prerequisites. No JSON boolean or profile label grants missing production authority. + +## Planned source boundaries + +These are proposed future paths, not files created by this plan. Adding a file under `scripts/release/` or any policy/config file requires approval of that exact addition before implementation. + +| Future change | Purpose | Scope boundary | +| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `docs/superpowers/specs/schemas/release-index-v2.schema.json` | Closed seven-identity schema and exact fields/bounds | Keep frozen v1 schema and validation intact | +| `scripts/release/release-policy-v2.json` — proposed path requiring approval | Source-owned policy version 2, neutral cohort ID, seven exact tuples and explicit profile semantics | Keep the existing CLI v1 policy bytes and historical list intact; no new file now | +| `crates/trusted-server-release/src/policy/index_v2.rs`, `documents.rs`, `distribution/v2.rs` — proposed paths | Add explicit v2 metadata descriptors, offline schema dispatch, strict joins and exact-byte index assembly | Keep four-target controlled execution tables separate; do not manufacture `SignedCompanions` or qualified authority | +| Existing Phase 3 assigned source and test splits | Port reviewed SPDX/collection/distribution primitives and restore staged assertions as separately approved | Closed complete generator and production adapters remain closed; no test constructor becomes production authority | +| Guide/compatibility/spec notes | Define v2 policy identity, ordinary repeatability and unchanged manual binary verification | No edits to carried normative v1 sentences; add versioned notes | +| Phase 4 consumer, later | Dispatch approved v2 policy/schema and enforce the narrow receipt rule | No consumer code or `readonly-verify` changes in Phase 3 | + +The proposed policy location is an explicit v2 amendment to the old fixed approved-source policy path. It must be approved with this plan; choosing a different location is a plan decision, not a downloaded-index choice. Later Phase 4 dispatches only reviewed fixed paths selected by trusted local policy/version rules. An index cannot supply a URL, path, policy hash or version that selects its own authority. Compute the new raw-byte policy hash only after approved policy bytes exist; do not introduce a placeholder pin. + +## Implementation sequence after plan approval + +1. Freeze v1 bytes/fixtures with regression checks. Complete the approved source port and restore Phase 3 assertions in separate coherent commits. Prove Phase 2 plus Phase 3 dependency closure with host all-target check/clippy before integrating v2. +2. Add a metadata-only seven-row descriptor matching every tuple in the approved design: filename, package/bin, role, kind, target, runtime/minimum/architecture, variant, capability and assurance. Never extend `FIXED_ARTIFACTS` to select Axum controlled recipes. +3. Add offline strict v2 schema/document validation, including duplicate-key, unknown-field, count and cross-field checks. Version/policy dispatch must be explicit; a v1 document remains governed by v1. +4. Add exact-byte ordinary Fastly receipt admission for the v2 informational slot. Require the exact supported receipt version/profile, Fastly default identity, `matched`, false controlled qualification, source/recipe/lock/JS joins, canonical/reproduction sizes/digests, distinct current-run/attempt job and artifact bindings, signed membership and binary/SPDX agreement. Index the retained original bytes rather than a re-rendered replacement. +5. Extend metadata assembly and its tests for the exact destination inventory. Keep genuine signed-companion acquisition unavailable until its separately approved integration. Candidate diagnostics, empty signatures and test fixtures cannot authorize publication. +6. Document the v2 definition and compatibility dispatch. Run source/closure tests, formatting/domain lint, independent schema/authority review and hosted CI. Keep the PR draft. Activation, production signing/publication and live rehearsal are **not** part of approving this schema plan. + +Gate 2's coverage/rollout decision can block complete evidence independently. Successful metadata tests or carrying a schema must never bypass that stop point. + +## Narrow false-qualification handling + +The proposed exception is only the **rebuild-check slot** for the exact Fastly default identity under the supported v2 contract and exact ordinary profile, after authenticated membership and all strict joins. It is not a generic permission to accept false qualification. + +False qualification in build evidence or build recipe remains rejected under their governing contracts; other assets, unknown profiles, candidates and stronger controlled policies do not gain an exception. Native reproduction remains observational, with explicit terminal outcomes and no new native equality gate. The permanent Fastly rebuild-check filename and its original version-1 bytes remain unchanged. + +Phase 4 must implement and test this policy dispatch explicitly. Frozen consumer `check_release_qualification` currently rejects false in all three companion slots; Phase 3 must not weaken or port a public consumer shortcut to make a fixture pass. + +## Destination inventory and cycle-free order + +The future complete cohort has exactly **53 files**: for each of seven binaries, the binary, SPDX, provenance bundle, SBOM bundle, build evidence, build recipe and rebuild check (49); plus `release-index.json`, its genuine provenance bundle, `tsjs-inventory.json` and `SHA256SUMS` (four). Checksums cover the other **52**, never themselves. This is a destination contract, not today's 30-file release. + +Keep the cycle-free dependency order: retain actual binary/evidence/inventory bytes; obtain their genuine required bundles; assemble the exact index without including its own digest, its bundle digest or `SHA256SUMS`; attest the index as `release-index.provenance.sigstore.json`; calculate checksums over the resulting other files; then publish only through the separately approved owner. Gate 4 must review exact subject membership, input isolation and failure behavior before any workflow/signing implementation. Gate 6 must approve fork trust without loosening official source/repository/signer policy. + +## Discriminating tests and completion gates + +- Preserve raw v1 policy/schema hashes and existing fixtures; old consumers reject v2 until explicitly updated. +- Accept exactly seven metadata tuples; reject each missing, duplicate, extra or cross-field-mutated row. Metadata cannot select a controlled Axum build or inhabit a qualified type. +- Ordinary Fastly admission accepts only the supported informational slot/profile and complete joins. Reject false in either other companion, any other asset/profile, unknown version, stale/shared identities, changed source/recipe/locks/JS/size/digest/SPDX or missing signed proof. +- Retained companion bytes must match the indexed digest exactly; even a whitespace-only replacement needs independent admission and changes its digest. +- Reject empty or placeholder bundles, candidates, missing authentic proof and self-referential digest construction. Require exactly 53 safe unique filenames and checksum membership exactly 52. +- Measure larger-cohort resource bounds without silently expanding them. No process-spawning tests locally; actual process/release checks remain separately authorized hosted work. + +For each coherent metadata/schema/join change, first add a discriminating failing test in proposed `documents/tests/index_v2.rs` or `distribution/tests/index_v2.rs`; confirm its intended rejection, implement the smallest change, and require the exact selected test to pass before a separate sentence-case signed commit. These test paths are proposals for approval, not new files in this PR head. + +Use the host target explicitly for producer checks, for example `cargo +1.98.1 check -p trusted-server-release --target aarch64-apple-darwin --all-targets --locked` and the matching `clippy --all-targets --all-features -- -D warnings`. List each audited pure test by its full name, then use `cargo +1.98.1 test -p trusted-server-release --target aarch64-apple-darwin --locked -- --exact` with a nonzero/one-passing-test guard. Hosted Linux CI must exercise Linux-only code and process fixtures; do not run the whole producer suite locally. Finish each approved chunk with Rust/docs formatting, domain lint and independent review before pushing. + +Before implementation, approve this plan's v2 profile definition and proposed additive policy path. Before complete production/index release, close Gate 2 coverage for the selected cohort and separately approve Gate 4 publication and Gate 6 rehearsal trust. Consumer implementation remains Phase 4. No schema, policy, source, workflow or tag is created by this plan. + +## Plan review + +Independent read-only plan review on 2026-10-28 found no blocking findings against Phase 2 source `5fa384336` and frozen source `122b78664`. It confirmed the strict all-seven coverage stop, frozen v1 bytes, narrow ordinary-profile handling and cycle-free inventory. A policy-source wording inconsistency in the design was corrected to refer to a separately approved v2 policy. No tests, implementation or runtime qualification were performed by this review. User rollout/plan approvals remain pending. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md index f1f10d2dcd..1002ff10aa 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md @@ -1,6 +1,6 @@ # Gate 3 proposal: seven-artifact release-index amendment -**Status:** Design proposal awaiting approval. No schema, policy, consumer, producer or workflow implementation is changed here. +**Status:** Gate 3 design approved on 2026-10-28 with new neutral v2 cohort ID `trusted-server-binaries`. Frozen v1 keeps `phase1-fastly-cli`. The [concrete index plan](binary-attestations-phase3-seven-artifact-index-implementation.md), including the explicit repeatability definition and additive policy path, awaits approval. No schema, policy, consumer, producer or workflow implementation is authorized yet. **Historical reviewed baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. `.13` remains attributed exclusively to `770a425df4b28caaaef68dfd930bceabd9928046`. @@ -10,9 +10,9 @@ **Tracking:** Part of #1235. -## Decision requested +## Approved design scope -Approve a **schema-version 2** index contract for exactly the existing seven binaries, preserving public filenames and frozen v1 bytes. Approve explicit informational handling of the existing ordinary Fastly comparison profile, with no controlled-qualification inference. Complete evidence, live signing/publication and the public consumer remain separately gated. +The user approved a **schema-version 2** index contract for exactly the existing seven binaries, preserving public filenames and frozen v1 bytes. Approval includes explicit informational handling of the existing ordinary Fastly comparison profile, with no controlled-qualification inference. Complete evidence, live signing/publication and the public consumer remain separately gated. The approved [phase assignment](binary-attestations-phases-2-4.md#separate-design-approval-gates) calls for a reviewed v1.1/v2 amendment. The retained v1 schema fixes integer `schema_version: 1` and exactly four entries; it has no minor-version mechanism. Today’s ordinary cohort has seven entries. Neither contract can silently stand in for the other. @@ -36,7 +36,7 @@ All rows retain `variant: default`, `default_features: true`, `features: []`. Th | `trusted-server-axum-x86_64-unknown-linux-musl` | `trusted-server-adapter-axum` / `trusted-server-axum` | `server` / `native-executable` | `x86_64-unknown-linux-musl` | `linux-musl` / `static` / `x86_64` | `axum-production` | `provenance-sbom` | | `trusted-server-axum-aarch64-unknown-linux-gnu` | `trusted-server-adapter-axum` / `trusted-server-axum` | `server` / `native-executable` | `aarch64-unknown-linux-gnu` | `linux-gnu` / `glibc-2.34` / `aarch64` | `axum-production` | `provenance-sbom` | -Recommend retaining the opaque legacy cohort ID `phase1-fastly-cli`, distinguished by the explicitly new schema/policy identity. A coordinated rename is an alternative approval choice, not an implicit part of this amendment. Loader floors remain distinct from actually tested operating systems. Native reproduction remains a bounded measurement, with no equality gate. No Fastly staging/reuse variant or additional Axum hardening is enabled. +The approved v2 cohort ID is `trusted-server-binaries`, carried only by the new v2 policy identity. Frozen v1 keeps `phase1-fastly-cli` and unchanged bytes, fixtures and policy. There are no other renames. Loader floors remain distinct from actually tested operating systems. Native reproduction remains a bounded measurement, with no equality gate. No Fastly staging/reuse variant or additional Axum hardening is enabled. ## Amendment and ownership @@ -45,7 +45,7 @@ Recommend retaining the opaque legacy cohort ID `phase1-fastly-cli`, distinguish | Governing schema | Add a separately approved `docs/superpowers/specs/schemas/release-index-v2.schema.json`; require version 2 and each of the seven identities exactly once | Existing v1 raw bytes remain unchanged; no open-ended artifact list | | Producer index descriptors | Represent the seven rows and strict source/digest joins in `distribution/**` and its policy checks | Extending index identities must not inhabit `Qualified*`, extend controlled recipe selection or enable Axum execution | | Types/compatibility | Add only required Axum, musl/static and GNU aarch64 descriptor values | Existing binary names, default variant and other closed values remain | -| Consumer policy identity | Explicitly review the new `policy_version`, raw-byte hash and seven entries in the existing policy | Downloaded metadata never selects authority; existing signer/repository/tool pins are not silently changed | +| Consumer policy identity | Explicitly review the new `policy_version`, raw-byte hash and seven entries in a separately approved v2 policy | Downloaded metadata never selects authority; existing signer/repository/tool pins are not silently changed | | Consumer implementation | Phase 4 updates `src/release/{policy,bounds,evidence}.rs` and `evidence/joins.rs` in the CLI | Phase 3 does not double-port the public consumer or change its `readonly-verify` action | | Bounds | Explicit seven-executable count for version 2 | Preserve byte/depth/process/deadline limits unless separately reviewed; measure larger-cohort cost | @@ -85,7 +85,7 @@ Future live rehearsal follows Gate 4 and sandbox-trust approval: independently v ## Approval choices - Approve version 2 with frozen v1 retained, the seven exact rows and the 53-file destination layout. -- Retain the legacy cohort ID with explicit new schema/policy identity, or explicitly choose a coordinated rename. +- The user selected the new neutral v2 cohort ID `trusted-server-binaries`; frozen v1 retains its legacy ID. - Approve the narrow informational ordinary-profile handling, with controlled requirements and candidate restrictions preserved. - Keep public consumer code in Phase 4 and live publication/sandbox trust behind Gates 4/6. No new schema/policy file is created before this design and its concrete implementation plan are approved. diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index aef93967d3..4bb858aa5e 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -2,7 +2,7 @@ **Phase 2 spec:** [ordinary Fastly release equality](../specs/2026-10-28-1235-binary-attestations-phase2-design.md), with its own source-checked review rounds. Tracking issue: #1235. -**Status:** Phase 2 port and approved ordinary Fastly integration are implemented in draft #1449. Current baseline `5fa3843364f247fdc261c438ab9257323637a42c` adds structured failure diagnostics and its own spec/reviews; exact-head CI and a fresh rehearsal are pending. Historical `.13` remains exclusively at `770a425df4b28caaaef68dfd930bceabd9928046`. Phase 3 contains only [Gate 2 complete-SPDX](binary-attestations-phase3-complete-spdx.md) and [Gate 3 seven-artifact index](binary-attestations-phase3-seven-artifact-index.md) design proposals. Phase 3 implementation, qualification and remaining design gates stay closed. +**Status:** Phase 2 at `5fa3843364f247fdc261c438ab9257323637a42c` has green CI and fresh fork rehearsal `.16` is running; historical `.13` remains exclusively at `770a425df4b28caaaef68dfd930bceabd9928046`. Phase 3 is design-only draft #1463. Gate 2 strict-composition and Gate 3 seven-artifact v2 designs are approved, subject to the [coverage/rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) and [index implementation plan](binary-attestations-phase3-seven-artifact-index-implementation.md). Their plans still need approval; implementation, qualification and later gates remain closed. **Tracking issue:** #1235. The earlier separate tracking-issue draft is retained as planning history, not an additional issue prerequisite. @@ -89,6 +89,8 @@ Assignment approval authorizes the mapped port and dependency/test staging, not 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. +**2026-10-28 gate status:** Gates 2/3 designs are approved for planning only. Gate 2 requires the seven-by-six coverage assessment and a target-specific rollout decision for costly cells. Gate 3 selects `trusted-server-binaries` only for v2; its plan must define repeatability explicitly. No schema/policy/source/workflow implementation is approved by these decisions. + 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. From f4388277d7375441c7b9a909238a310078de1490 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 17:02:34 +0530 Subject: [PATCH 3/8] Assess full-cohort closure and revise Phase 3 sequencing --- ...ons-phase3-complete-spdx-implementation.md | 14 +-- ...inary-attestations-phase3-complete-spdx.md | 4 +- ...stations-phase3-full-cohort-feasibility.md | 94 +++++++++++++++++++ ...se3-seven-artifact-index-implementation.md | 25 +++-- ...ttestations-phase3-seven-artifact-index.md | 4 +- .../plans/binary-attestations-phases-2-4.md | 4 +- 6 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md index e0ecd51ab2..ce7ac64ada 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md @@ -12,7 +12,7 @@ Each cell names its **exact closure contract** below. **P** means a bounded ordinary capture provider is feasible on the existing hosted job without a new qualification resource, subject to its implementation/provider review; it does not mean the cell is complete now. **U** means complete closure is unproven or potentially costly with current resources. In particular, licensing evidence cannot close for missing native/tool originals merely because Cargo/npm declarations are available. -**Status:** Planning stopped at the coverage/rollout decision, as requested. Gate 2's strict design is approved; no implementation plan or code is approved. The matrix exposes costly native/platform/acquisition cells for every target, especially Apple SDK/toolchain, GNU derived images and musl startup/compiler runtimes. No promise is made that the existing hosted setup can close them within current job limits. No target is silently removed and no unresolved contributor is excluded. +**Status:** The user selected full-cohort closure and cost assessment first; see the [21-cell feasibility and scope comparison](binary-attestations-phase3-full-cohort-feasibility.md). Planning remains docs-only, with no capture implementation. Gate 2's strict design is approved; no implementation plan or code is approved. The matrix exposes costly native/platform/acquisition cells for every target, especially Apple SDK/toolchain, GNU derived images and musl startup/compiler runtimes. No promise is made that the existing hosted setup can close them within current job limits. No target is silently removed and no unresolved contributor is excluded. **Tracking:** Part of #1235; draft #1463. Source: Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c` and frozen #1262 `122b78664b1235c717e9208fd200f9a9b0c335df`. This document changes no schema, policy, producer, workflow or release selection. @@ -66,13 +66,13 @@ The interim converter also inherits a Cargo purl on the artifact root. The compl No implementation task list is authorized by this feasibility document. The costly **U** cells require a user decision before code: -| Option | What proceeds | Production consequence | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Evidence-first waves — recommended** | Plan bounded ordinary Cargo/JS/runtime ownership first; separately design/cost Fastly host-tool closure, GNU/ARM image acquisition, musl runtime materials and Apple SDK/toolchain closure | Keep all seven assets and honest interim inventories; complete generation and the complete seven-row index remain closed until all seven pass | -| Target-specific complete-SBOM rollout | First establish one target's complete closure, then separately approve its production claim while others retain interim inventories | Requires an explicit amendment to Gate 2's all-seven activation condition. Never drops binaries or silently activates a seven-entry complete index; v2 production stays closed | -| Full-cohort feasibility first | Pause capture implementation until each target has an approved acquisition/closure method and bounded cost assessment | Preserves strict all-seven activation and current release behavior; complete evidence remains deferred without a false completion claim | +| Option | What proceeds | Production consequence | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Evidence-first waves | Plan bounded ordinary Cargo/JS/runtime ownership first; separately design/cost Fastly host-tool closure, GNU/ARM image acquisition, musl runtime materials and Apple SDK/toolchain closure | Keep all seven assets and honest interim inventories; complete generation and the complete seven-row index remain closed until all seven pass | +| Target-specific complete-SBOM rollout | First establish one target's complete closure, then separately approve its production claim while others retain interim inventories | Requires an explicit amendment to Gate 2's all-seven activation condition. Never drops binaries or silently activates a seven-entry complete index; v2 production stays closed | +| Full-cohort feasibility first | Pause capture implementation until each target has an approved acquisition/closure method and bounded cost assessment | Preserves strict all-seven activation and current release behavior; complete evidence remains deferred without a false completion claim | -The first option supports progress without promising a cheap Fastly exception: Fastly tool closure is itself unresolved. Approving either design proposal is not approval of a new qualification resource, controlled selector, schema/policy file, production switch or rehearsal tag. +The user selected full-cohort assessment first; the options above are not implementation authorization. Even the first option cannot promise a cheap Fastly exception: Fastly tool closure is itself unresolved. Approving either design proposal is not approval of a new qualification resource, controlled selector, schema/policy file, production switch or rehearsal tag. ## Plan review diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md index 875a5975d3..ef356ce678 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md @@ -1,12 +1,12 @@ # Gate 2 proposal: complete SPDX from owning build evidence -**Status:** Gate 2 design approved on 2026-10-28 for strict admission from retained, artifact-bound owning evidence. Plan only: production generation stays closed until all seven binaries pass. The [coverage matrix and rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) stops at costly evidence gaps before implementation; no code, qualification or rehearsal tag is authorized. +**Status:** Gate 2 design approved on 2026-10-28 for strict admission from retained, artifact-bound owning evidence. Plan only: production generation stays closed until all seven binaries pass. The [coverage matrix](binary-attestations-phase3-complete-spdx-implementation.md) and [full-cohort cost assessment](binary-attestations-phase3-full-cohort-feasibility.md) expose costly evidence gaps before any implementation; no code, qualification or rehearsal tag is authorized. **Historical reviewed baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. Rehearsal `.13` remains evidence only for `770a425df4b28caaaef68dfd930bceabd9928046`. **Verification baseline:** [Historical Phase 2 `8a797dbcb` CI passed](https://github.com/IABTechLab/trusted-server/actions/runs/40654745582); signing/publication and manual negative canaries were skipped as configured. -**Current integration baseline:** Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c`; [exact-head CI](https://github.com/IABTechLab/trusted-server/actions/runs/40664514307) and readiness work are tracked in #1449. Historical `.13` does not cover later application inputs; the newly authorized rehearsal is not yet recorded. +**Current integration baseline:** Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c`; [exact-head CI](https://github.com/IABTechLab/trusted-server/actions/runs/40664514307) and readiness work are tracked in #1449. Rehearsal `.16` covers this exact implementation; [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408). Phase 2 `fb4843355` is the later docs-only evidence record. **Tracking:** Part of #1235. diff --git a/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md b/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md new file mode 100644 index 0000000000..03689b33ee --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md @@ -0,0 +1,94 @@ +# Gate 2: full-cohort closure and cost assessment + +**Status:** Docs-only assessment requested on 2026-10-28. No capture implementation, acquisition, image build, qualification, production selection or rehearsal tag is authorized. The strict Gate 2 scope remains in force; the identity-only alternative below is a proposed amendment for a user decision, not an adopted definition of completeness. + +**Scope:** All 21 unresolved cells in the [coverage matrix](binary-attestations-phase3-complete-spdx-implementation.md): seven native, seven platform/tool and seven licence cells. Cargo/JS/runtime capture and complete-generator integration also remain work, but their costs are not hidden inside these U-cell estimates. Part of #1235; draft #1463. + +## Basis and limits + +Effort means engineering person-days for design, implementation, focused verification and review **if later authorized**. Ranges are judgment estimates, not benchmarks, staffing commitments or elapsed delivery promises. They exclude acquisition/approval waiting, publication/consumer integration and infrastructure purchase or provisioning time. They assume adequate originals can be obtained; missing Apple acquisition or observation capability can invalidate the estimate rather than merely add a known number of days. + +The existing workflow allows 90 minutes per build and ten minutes for **Fastly comparison only**. Native jobs have no new comparison allowance. In rehearsal `.16` at `5fa384336`, producer jobs took: Fastly 353s, GNU CLI 494s, Apple ARM CLI 783s, Apple Intel CLI 1,869s, GNU x64 Axum 324s, musl Axum 287s and GNU ARM Axum 288s. Fastly reproduction took 269s and comparison 86s. These [observed results](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408) establish current baseline time, **not capture overhead or complete-closure feasibility**. + +No capture experiment or SDK/tool acquisition was run for this assessment. Every strict cell's full 90-minute fit remains unproven. Future feasibility work must measure bounded acquisition, extraction, tracing, hashing, retention and handoff costs in an approved hosted job. Comparison must consume bounded retained evidence; it cannot use its ten-minute budget to reacquire SDKs or discover an unknown tool tree. + +## Shared work, counted once + +| Foundation | Effort | Includes / excludes | +| -------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **N0 — native ownership** | 9–16 days | Ordinary native receipts; Cargo context/features/directives, retained archive and actual link-input selection; Rust std/compiler-builtins/crt/source joins. Excludes platform acquisition provider work | +| **L0 — licence ownership** | 5–8 days | Declarations and relevant notice/licence files from verified originals, joined by material and source path; explained `NOASSERTION`. Excludes acquiring originals and any legal conclusion | +| **T0 — strict tool closure** | 8–12 days | Artifact-owned acquisition and input-observation contracts, conservative manifests, changed-byte checks, fail-closed coverage and bounded handoff | +| **I0 — alternative identity provider** | 3–5 days | Verified version/digest/source joins and negative tests. Replaces T0 only under an approved scope amendment; does not replace N0 or L0 | + +Per-cell increments below reuse these foundations. A shared Rust/RPM/SDK original is acquired once when its exact identity matches, but each artifact retains its own selection and byte joins. Do not sum a family provider again for every dependent cell. + +## Native cells: seven strict closure assessments + +| Asset | Concrete closure method | Existing hosted job / limits | Increment beyond N0 | New resources or prerequisites | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Fastly Wasm | Retain actual selected host/target native inputs, any wrapper originals/archives, target Rust/WASI std, compiler-builtins and startup material; join actual source and final selection | Linux capture is plausible; complete selection/source mapping and 90-minute fit are unproven | 2–4 days | Verified Rust component/source/notice distributions and the TF tool provider; never assume all three reviewed wrappers occur | +| GNU x64 CLI | Capture actual UBI invocation, selected wrapper archives, host units, Rust std/compiler-builtins, GNU crt and compiler-runtime inputs | Existing container can collect facts; acquisition and complete timing remain unproven | 2–4 days | Exact RPM payloads/originals/source joins and Rust components from TG; retained native outputs | +| Apple ARM CLI | Bind actual ARM native archives and Rust components; identify selected Apple startup/stub/framework inputs separately from runtime requirements | Hosted Mac can observe files; complete SDK/native source ownership and permitted observation remain unresolved | 3–5 days | TA acquisition/originals and an approved retention/observation method; possibly a separately approved Apple builder | +| Apple Intel CLI | Reuse Apple provider design with this Intel job's architecture-specific link selections and originals | Same unresolved Apple constraints; ARM observations do not establish Intel ownership | 1–2 days | Matching Intel originals and independent artifact-owned observations | +| GNU x64 Axum | Reuse GNU provider, capturing Axum's actual features, native selection and final link | Collection plausible; no complete 90-minute proof | 1–2 days | Verified shared GNU/Rust originals where identical; separate Axum archive/binary joins | +| Static musl x64 Axum | Identify musl-gcc wrapper/specs, real compiler, libc/crt/GCC runtime archives and Rust self-contained components; retain each actual source/selection join | Existing host supports collection; complete static selection and runtime fit are unproven | 3–5 days | Exact musl **and** GCC/runtime originals/source joins from TS; ordinary static-target support, not new controlled selectors | +| GNU ARM Axum | Capture actual ARM UBI/native archives, ARM Rust std/compiler-builtins/crt/runtime and final link | Native ARM job exists; acquisition and full timing remain unproven | 2–4 days | ARM image/RPM/Rust originals and ordinary ARM metadata support; no x64 substitution or assumed emulator | + +Native increments total **14–26 days**. Existing `composition/native.rs` leaves material/native coverage false; reviewed `aws-lc-sys`, `ring` and `libsqlite3-sys` wrapper versions are not identities for every contained implementation or runtime input. Neither strict tool closure nor the alternative can substitute for this native ownership. + +## Platform/tool cells: strict versus proposed identity-only amendment + +The strict method observes actual helper execution, relevant library/resource loads and selected input files, maps them to verified retained originals, and rejects uncovered inputs. The alternative records selected compilers, linkers, SDKs and images by **exact version, cryptographic digest and authenticated acquisition source**; it explicitly makes no helper/library/file-closure claim. A version label, local image ID or installed-software list alone does not satisfy that alternative. + +| Asset / provider | Strict closure method and resources | Strict increment beyond T0 | Existing hosted fit | Alternative method/resources and increment beyond I0 | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly / **TF** | Verified Rust/Node and OS package originals; actual host compiler/assembler/archiver, Rust linker, build-script/helper/load/resource observations. Needs pinned packages or an approved evidence-bearing builder image and bounded retention | 5–8 days | Ubuntu collection plausible; complete observation/overhead unmeasured. Replacing the current host recipe with a container is a separate recipe decision | Exact selected compiler/linker/Rust/Node identities and acquisition; verified builder/image identity where required. **2–3 days**, conditional on image/source evidence; no helper closure | +| GNU x64 CLI / **TG** | Retain OCI manifest/config/layers, derived build identity, every resolved RPM NEVRA and original, signatures/checksums/repository metadata, installed files and actual tools/helpers/loads; retain mounted Rust/Node separately | 8–12 days for GNU x64 family | Current Docker can collect; unversioned `dnf install` plus cleanup cannot retrospectively supply originals. Controlled acquisition and 90-minute fit unproven | Verified platform manifest and derived image digests; exact selected compiler/linker bytes/versions and Rust/Node originals/source. **3–5 days** for GNU family. Needs a reviewed digest-bearing image/acquisition process | +| Apple ARM CLI / **TA** | Authenticate selected Xcode/SDK originals and provisioning; cover headers/stubs/frameworks/resources, clang/helper/toolchain libraries and reads outside measured directories | 12–20 days for Apple family, plus unresolved acquisition/observation dependency | No established complete provider on existing hosted Macs. Tree hashing alone cannot prove complete inputs; permitted observation and original acquisition need a decision | Selected executable and canonical SDK/toolchain tree digests, exact Xcode/SDK versions and approved acquisition receipts. **5–9 days**, conditional. Still unresolved if authenticated original or whole-VM image digest is mandatory | +| Apple Intel CLI / **TA** | Same Apple contract with actual Intel invocation and architecture-specific receipts | 3–5 days after Apple-family work | Same unresolved provider and timing constraints | Independent Intel identity/acquisition joins. **1–2 days** after Apple family; ARM evidence is insufficient | +| GNU x64 Axum / **TG** | Reuse GNU originals/provider but capture Axum's actual invocation and input membership | 1–2 days after GNU family | Plausible collection; bounded full closure unproven | Bind its binary to verified selected tool/image identities. **0.5–1 day**; reuse only identical verified materials | +| Static musl x64 Axum / **TS** | Retain exact apt metadata and `.deb` originals for musl, GCC/binutils and dependencies; resolve wrapper/specs; cover selected helper/header/crt/runtime files and loads | 4–7 days | Current host can collect; three musl pins plus ambient GCC do not establish closure or fit | Verified wrapper/spec, real compiler/linker and musl/tool package identity/source. **2–3 days**. Requires selected package pins/originals; does not establish object/header/helper membership | +| GNU ARM Axum / **TG** | Extend GNU provider with actual ARM manifests/layers, resolved ARM RPMs, Rust components and execution/input observations | 3–5 days after GNU family | Existing ARM job helps collection; exact acquisition and timing remain unproved | Actual ARM image/tool identities and source records. **1–2 days** after GNU family; never infer ARM from x64 | + +Including its shared foundation, platform/tool effort is **44–71 days strict**, versus **17.5–30 days for the alternative**. These exclude native/licence providers and do not guarantee that a missing acquisition source becomes available. + +GitHub documents weekly standard-image updates and per-run included-software links; it describes Ubuntu/Windows image SBOMs, not an authenticated macOS VM-byte digest. That evidence does not by itself meet either strict closure or a required VM digest. [Hosted runner documentation](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners). + +GitHub's current custom-image creation instructions list Linux x64, Linux ARM64 and Windows x64, require separately enabled larger-runner capabilities/permissions, and say provisioning may take hours. This is a possible **new approved resource** for relevant Linux work, not an assumed macOS solution or something to put inside a release's 90-minute budget. [Custom-image documentation](https://docs.github.com/en/actions/how-tos/manage-runners/larger-runners/use-custom-images). + +If the alternative's image requirement includes the entire hosted VM, current evidence does not establish the required macOS digest/acquisition route. Do not relabel `ImageVersion` as a digest. Accepting provider provisioning as acquisition, limiting image identity to selected builder containers, or changing Apple builders would each need an explicit scope/resource decision. No such decision is adopted here. + +## Licence cells: seven strict closure assessments + +Each method applies L0 to verified originals and preserves declaration/source-path evidence. Missing or unsupported declarations can yield explained `NOASSERTION` **after** material/original identity is established; it cannot replace missing originals. Frozen native/JS projections currently have no owning declaration field, so simply porting the renderer cannot close these cells. + +| Asset | Concrete closure method | Existing hosted job / limits | Increment beyond L0 | New resources or prerequisites | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------- | +| Fastly Wasm | Join actual Cargo/npm/native declarations and Rust/WASI/compiler/tool notices to their originals; retain bundled third-party notices separately from wrapper declarations | Local extraction/join plausible; full timing depends on retained originals and bounds | 1–2 days | N and TF originals, source/path mappings and allowed retention | +| GNU x64 CLI | Include selected wrapper subtree notices and exact RPM/Rust/native declarations/files | Can process local retained packages; complete input availability and runtime unproved | 2–3 days | TG/N originals and relevant RPM/source-package licence records | +| Apple ARM CLI | Bind selected SDK/Xcode/native/Rust notices and declaration evidence to approved originals | Availability/retention and complete timing unresolved | 2–4 days | TA/N acquisition evidence and an accepted retention/access method; no inference from an SDK version | +| Apple Intel CLI | Reuse extraction rules with this target's actual originals and architecture selections | Same Apple prerequisites; exact shared originals may be reused | 0.5–1 day | Verified Intel identities/source-path joins | +| GNU x64 Axum | Cover Axum's actual material set using verified common GNU/Rust originals where identical | Collection feasible in principle; full fit depends on TG/N | 0.5–1 day | Separate Axum material membership; no copied CLI assumption | +| Static musl x64 Axum | Join selected musl/GCC/runtime/Rust/wrapper/npm declarations and notice files; preserve any actually observed runtime-exception declaration | Extraction plausible; missing static runtime originals still block completion | 1–2 days | TS/N originals and source/notice mappings, not guessed licences from a target label | +| GNU ARM Axum | Join ARM RPM/Rust/native identities; reuse architecture-neutral source text only after verified joins | Processing plausible; acquisition and total fit unproved | 1–2 days | TG/N ARM originals; common declaration text alone is insufficient | + +Licence increments total **8–15 days**. Native plus licence foundations/increments total **36–65 days**, excluding platform acquisition/provider work. The identity-only platform amendment leaves these N/L obligations unchanged and does not make all seven complete. + +## Combined cost and decision + +| Direction | Conditional U-cell engineering subtotal | What remains blocked | +| ----------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Strict all-seven | **80–136 person-days** for N/L plus strict platform/tool work | Apple acquisition/observation and every measured job-limit/resource check; also Cargo/JS/runtime capture, generator integration and later publication work excluded from subtotal | +| Proposed platform identity amendment, all seven | **About 54–95 person-days** for unchanged N/L plus identity providers | Same native/licence originals and possibly Apple acquisition/VM identity; scope no longer claims complete helper/library/file closure. Not adopted | +| Target-specific rollout | Cost only the chosen provider wave and its share of foundations, then reassess | Requires an explicit amendment to all-seven production activation and possible assurance-profile changes; no binary is silently removed and no partial seven-row complete index is published | + +These subtotals are not delivery bounds: the unresolved external inputs can prevent completion. Initial strict Fastly-only N/L/tool work would be roughly **30–50 person-days**, including shared foundations and excluding Cargo/JS/runtime/generator integration. It is a candidate first wave because some same-build observations already exist, not a proved cheap complete target. + +**Recommendation: a target-specific rollout decision**, preserving all seven distributed binaries and honest interim inventories while proving the selected target's complete closure first. This avoids making every target wait for unresolved Apple acquisition and the broadest native/material work. It still needs explicit user approval to amend the current all-seven activation rule; until then strict all-seven remains the governing gate. The identity-only amendment is cheaper but does not by itself resolve the other U cells or establish a complete seven-target path. + +No scope amendment, resource, capture code, schema/policy file, production/qualified selector or rehearsal tag follows from this recommendation. Gate 3's v2 implementation steps remain held until the user chooses the Gate 2 rollout/assurance direction. + +## Assessment review + +Independent read-only review on 2026-10-28 checked all 21 U-cell methods, prerequisites and conditional estimates, recomputed shared/per-cell totals, and verified the cited runner constraints against official documentation. No blocking findings or required corrections. It also confirmed that the recommendation and identity-only alternative are not adopted scope, and that the revised Gate 3 sequencing remains closed as specified. No capture, build, acquisition or qualification test was performed. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md index 71f22390f3..dc6ead87d1 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md @@ -34,21 +34,28 @@ These are proposed future paths, not files created by this plan. Adding a file u | Future change | Purpose | Scope boundary | | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `docs/superpowers/specs/schemas/release-index-v2.schema.json` | Closed seven-identity schema and exact fields/bounds | Keep frozen v1 schema and validation intact | -| `scripts/release/release-policy-v2.json` — proposed path requiring approval | Source-owned policy version 2, neutral cohort ID, seven exact tuples and explicit profile semantics | Keep the existing CLI v1 policy bytes and historical list intact; no new file now | +| `crates/trusted-server-cli/release-policy-v2.json` — proposed path requiring approval | Source-owned policy version 2, neutral cohort ID, seven exact tuples and explicit profile semantics | Keep the existing CLI v1 policy bytes and historical list intact; no new file now | | `crates/trusted-server-release/src/policy/index_v2.rs`, `documents.rs`, `distribution/v2.rs` — proposed paths | Add explicit v2 metadata descriptors, offline schema dispatch, strict joins and exact-byte index assembly | Keep four-target controlled execution tables separate; do not manufacture `SignedCompanions` or qualified authority | +| `scripts/release/release-cohort-v2.json` — proposed future source | Producer-owned v2 metadata cohort; cross-check exactly against the CLI-owned policy | Do not repurpose existing cohort or select controlled recipes; held with Steps 2–5 | | Existing Phase 3 assigned source and test splits | Port reviewed SPDX/collection/distribution primitives and restore staged assertions as separately approved | Closed complete generator and production adapters remain closed; no test constructor becomes production authority | | Guide/compatibility/spec notes | Define v2 policy identity, ordinary repeatability and unchanged manual binary verification | No edits to carried normative v1 sentences; add versioned notes | | Phase 4 consumer, later | Dispatch approved v2 policy/schema and enforce the narrow receipt rule | No consumer code or `readonly-verify` changes in Phase 3 | -The proposed policy location is an explicit v2 amendment to the old fixed approved-source policy path. It must be approved with this plan; choosing a different location is a plan decision, not a downloaded-index choice. Later Phase 4 dispatches only reviewed fixed paths selected by trusted local policy/version rules. An index cannot supply a URL, path, policy hash or version that selects its own authority. Compute the new raw-byte policy hash only after approved policy bytes exist; do not introduce a placeholder pin. +The v2 policy stays beside v1 at the CLI-owned tracked policy location, consistent with R-POLICY. Its eventual embedding and trusted fixed-path/version dispatch belong to the Phase 4 consumer implementation; Phase 3 does not add consumer code. It must be approved with this plan; choosing a different location is a plan decision, not a downloaded-index choice. Later Phase 4 dispatches only reviewed fixed paths selected by trusted local policy/version rules. An index cannot supply a URL, path, policy hash or version that selects its own authority. Compute the new raw-byte policy hash only after approved policy bytes exist; do not introduce a placeholder pin. + +## Producer cohort and authority sources + +The proposed producer cohort source is `scripts/release/release-cohort-v2.json`, separate from the existing `release-cohort.json`. It carries the new neutral ID and the seven exact metadata tuples, with assurance profiles finalized only after the Gate 2 rollout decision. The CLI-owned `release-policy-v2.json` governs trusted policy identity, approved cohort/profile requirements and bounds; it is not replaced by the producer cohort. Cross-validation must reject any row, target, profile or count drift between those approved sources. Neither file is created by this plan or by Step 1. Existing v1 policy, ordinary cohort file and historical-policy list remain unchanged. ## Implementation sequence after plan approval -1. Freeze v1 bytes/fixtures with regression checks. Complete the approved source port and restore Phase 3 assertions in separate coherent commits. Prove Phase 2 plus Phase 3 dependency closure with host all-target check/clippy before integrating v2. -2. Add a metadata-only seven-row descriptor matching every tuple in the approved design: filename, package/bin, role, kind, target, runtime/minimum/architecture, variant, capability and assurance. Never extend `FIXED_ARTIFACTS` to select Axum controlled recipes. -3. Add offline strict v2 schema/document validation, including duplicate-key, unknown-field, count and cross-field checks. Version/policy dispatch must be explicit; a v1 document remains governed by v1. -4. Add exact-byte ordinary Fastly receipt admission for the v2 informational slot. Require the exact supported receipt version/profile, Fastly default identity, `matched`, false controlled qualification, source/recipe/lock/JS joins, canonical/reproduction sizes/digests, distinct current-run/attempt job and artifact bindings, signed membership and binary/SPDX agreement. Index the retained original bytes rather than a re-rendered replacement. -5. Extend metadata assembly and its tests for the exact destination inventory. Keep genuine signed-companion acquisition unavailable until its separately approved integration. Candidate diagnostics, empty signatures and test fixtures cannot authorize publication. +**Sequencing:** revised-plan approval permits **Step 1 only**. Steps 2–5, including v2 policy/cohort/schema files, metadata descriptors, ordinary-receipt admission and assembly, wait for the **final Gate 2 rollout/assurance decision**. Choosing the current full-cohort cost assessment is not that final decision. Step 6 integration follows those held steps; planning documents may still be revised now. A target-specific choice could change the v2 rows’ assurance profiles, so do not freeze them prematurely. + +1. **After revised-plan approval:** freeze v1 bytes/fixtures with regression checks. Complete the approved source port and restore Phase 3 assertions in separate coherent commits. Prove Phase 2 plus Phase 3 dependency closure with host all-target check/clippy before integrating v2. +2. **Held for Gate 2 decision:** add the separately approved v2 policy/cohort sources and a metadata-only seven-row descriptor matching every tuple in the approved design: filename, package/bin, role, kind, target, runtime/minimum/architecture, variant, capability and assurance. Never extend `FIXED_ARTIFACTS` to select Axum controlled recipes. +3. **Held for Gate 2 decision:** add offline strict v2 schema/document validation, including duplicate-key, unknown-field, count and cross-field checks. Version/policy dispatch must be explicit; a v1 document remains governed by v1. +4. **Held for Gate 2 decision:** add exact-byte ordinary Fastly receipt admission for the v2 informational slot. Require the exact supported receipt version/profile, Fastly default identity, `matched`, false controlled qualification, source/recipe/lock/JS joins, canonical/reproduction sizes/digests, distinct current-run/attempt job and artifact bindings, signed membership and binary/SPDX agreement. Index the retained original bytes rather than a re-rendered replacement. +5. **Held for Gate 2 decision:** extend metadata assembly and its tests for the exact destination inventory. Keep genuine signed-companion acquisition unavailable until its separately approved integration. Candidate diagnostics, empty signatures and test fixtures cannot authorize publication. 6. Document the v2 definition and compatibility dispatch. Run source/closure tests, formatting/domain lint, independent schema/authority review and hosted CI. Keep the PR draft. Activation, production signing/publication and live rehearsal are **not** part of approving this schema plan. Gate 2's coverage/rollout decision can block complete evidence independently. Successful metadata tests or carrying a schema must never bypass that stop point. @@ -80,8 +87,10 @@ For each coherent metadata/schema/join change, first add a discriminating failin Use the host target explicitly for producer checks, for example `cargo +1.98.1 check -p trusted-server-release --target aarch64-apple-darwin --all-targets --locked` and the matching `clippy --all-targets --all-features -- -D warnings`. List each audited pure test by its full name, then use `cargo +1.98.1 test -p trusted-server-release --target aarch64-apple-darwin --locked -- --exact` with a nonzero/one-passing-test guard. Hosted Linux CI must exercise Linux-only code and process fixtures; do not run the whole producer suite locally. Finish each approved chunk with Rust/docs formatting, domain lint and independent review before pushing. -Before implementation, approve this plan's v2 profile definition and proposed additive policy path. Before complete production/index release, close Gate 2 coverage for the selected cohort and separately approve Gate 4 publication and Gate 6 rehearsal trust. Consumer implementation remains Phase 4. No schema, policy, source, workflow or tag is created by this plan. +Before Step 1, approve this revised plan and its sequencing. The repeatability definition, frozen v1 hashes, narrow rebuild-check exception and 53/52 contract are already approved. Before Steps 2–5, obtain the final Gate 2 rollout/assurance decision; this plan alone does not open them. Before complete production/index release, close Gate 2 coverage for the selected cohort and separately approve Gate 4 publication and Gate 6 rehearsal trust. Consumer implementation remains Phase 4. No schema, policy, source, workflow or tag is created by this plan. ## Plan review Independent read-only plan review on 2026-10-28 found no blocking findings against Phase 2 source `5fa384336` and frozen source `122b78664`. It confirmed the strict all-seven coverage stop, frozen v1 bytes, narrow ordinary-profile handling and cycle-free inventory. A policy-source wording inconsistency in the design was corrected to refer to a separately approved v2 policy. No tests, implementation or runtime qualification were performed by this review. User rollout/plan approvals remain pending. + +A subsequent independent review checked the user-requested CLI policy location, separate producer cohort source, Step-1-only approval boundary and hold on Steps 2–5. It found no blocking findings; no source or configuration implementation was performed. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md index 1002ff10aa..f1e2b74e3d 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index.md @@ -1,12 +1,12 @@ # Gate 3 proposal: seven-artifact release-index amendment -**Status:** Gate 3 design approved on 2026-10-28 with new neutral v2 cohort ID `trusted-server-binaries`. Frozen v1 keeps `phase1-fastly-cli`. The [concrete index plan](binary-attestations-phase3-seven-artifact-index-implementation.md), including the explicit repeatability definition and additive policy path, awaits approval. No schema, policy, consumer, producer or workflow implementation is authorized yet. +**Status:** Gate 3 design approved on 2026-10-28 with new neutral v2 cohort ID `trusted-server-binaries`. Frozen v1 keeps `phase1-fastly-cli`. The [concrete index plan](binary-attestations-phase3-seven-artifact-index-implementation.md), with the CLI-owned v2 policy, a separate producer cohort source and staged execution gates, awaits revised-plan approval. The repeatability definition is approved; no implementation follows until its sequencing gates open. No schema, policy, consumer, producer or workflow implementation is authorized yet. **Historical reviewed baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. `.13` remains attributed exclusively to `770a425df4b28caaaef68dfd930bceabd9928046`. **Verification baseline:** [Historical Phase 2 `8a797dbcb` CI passed](https://github.com/IABTechLab/trusted-server/actions/runs/40654745582); signing/publication and manual negative canaries were skipped as configured. -**Current integration baseline:** Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c`; [exact-head CI](https://github.com/IABTechLab/trusted-server/actions/runs/40664514307) and readiness work are tracked in #1449. Historical `.13` does not cover later application inputs; the newly authorized rehearsal is not yet recorded. +**Current integration baseline:** Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c`; [exact-head CI](https://github.com/IABTechLab/trusted-server/actions/runs/40664514307) and readiness work are tracked in #1449. Rehearsal `.16` covers this exact implementation; [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408). Phase 2 `fb4843355` is the later docs-only evidence record. **Tracking:** Part of #1235. diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index c63187a4f1..766a49b4cb 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -2,7 +2,7 @@ **Phase 2 spec:** [ordinary Fastly release equality](../specs/2026-10-28-1235-binary-attestations-phase2-design.md), with its own source-checked review rounds. Tracking issue: #1235. -**Status:** Phase 2 implementation `5fa3843364f247fdc261c438ab9257323637a42c` passed exact-source CI and rehearsal `.16`; [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408). Phase 2 `fb4843355` is its docs-only evidence head. Phase 3 is design-only draft #1463: Gate 2/3 designs are approved, but the [coverage/rollout assessment](binary-attestations-phase3-complete-spdx-implementation.md) and [index plan](binary-attestations-phase3-seven-artifact-index-implementation.md) still require their decisions. Implementation, qualification and later gates remain closed. +**Status:** Phase 2 implementation `5fa3843364f247fdc261c438ab9257323637a42c` passed exact-source CI and rehearsal `.16`; [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408). Phase 2 `fb4843355` is its docs-only evidence head. Phase 3 is design-only draft #1463: Gate 2/3 designs are approved, but the [full-cohort closure/cost assessment](binary-attestations-phase3-full-cohort-feasibility.md) and [index plan](binary-attestations-phase3-seven-artifact-index-implementation.md) still require their decisions. Implementation, qualification and later gates remain closed. **Tracking issue:** #1235. The earlier separate tracking-issue draft is retained as planning history, not an additional issue prerequisite. @@ -89,7 +89,7 @@ Assignment approval authorizes the mapped port and dependency/test staging, not 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. -**2026-10-28 gate status:** Gates 2/3 designs are approved for planning only. Gate 2 requires the seven-by-six coverage assessment and a target-specific rollout decision for costly cells. Gate 3 selects `trusted-server-binaries` only for v2; its plan must define repeatability explicitly. No schema/policy/source/workflow implementation is approved by these decisions. +**2026-10-28 gate status:** Gates 2/3 designs are approved for planning only. Gate 2 now requires full-cohort closure/cost assessment, including an unadopted platform identity alternative. Gate 3 selects `trusted-server-binaries` only for v2 and its repeatability definition is approved. Its revised plan places policy beside v1 in the CLI and names a separate producer cohort. Revised-plan approval can open the frozen-source port only; v2 metadata/schema/admission/assembly wait for the final Gate 2 rollout/assurance decision. No source or config implementation is authorized yet. 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. From faf6e8efb3d71a1254e7f256ae60f4a82221c2e1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 18:11:02 +0530 Subject: [PATCH 4/8] Preserve frozen v1 contracts and restore evidence fixtures --- .../tests/fixtures/release/release-index.json | 218 ++++++++++++++++++ .../src/documents/tests.rs | 45 ++++ .../tests/fixtures/README.md | 17 ++ .../tests/fixtures/collection/README.md | 61 +++++ .../fixtures/collection/build-record.json | 48 ++++ .../fixtures/collection/composition.json | 203 ++++++++++++++++ .../candidate/candidate-release-index.json | 1 + ...ted-server-fastly.wasm.build-evidence.json | 1 + ...usted-server-fastly.wasm.build-recipe.json | 1 + ...sted-server-fastly.wasm.rebuild-check.json | 1 + .../trusted-server-fastly.wasm.spdx.json | 1 + ...s-aarch64-apple-darwin.build-evidence.json | 1 + .../ts-aarch64-apple-darwin.build-recipe.json | 1 + ...ts-aarch64-apple-darwin.rebuild-check.json | 1 + .../ts-aarch64-apple-darwin.spdx.json | 1 + ...ts-x86_64-apple-darwin.build-evidence.json | 1 + .../ts-x86_64-apple-darwin.build-recipe.json | 1 + .../ts-x86_64-apple-darwin.rebuild-check.json | 1 + .../ts-x86_64-apple-darwin.spdx.json | 1 + ...6_64-unknown-linux-gnu.build-evidence.json | 1 + ...x86_64-unknown-linux-gnu.build-recipe.json | 1 + ...86_64-unknown-linux-gnu.rebuild-check.json | 1 + .../ts-x86_64-unknown-linux-gnu.spdx.json | 1 + .../index/candidate/tsjs-inventory.json | 1 + 24 files changed, 610 insertions(+) create mode 100644 crates/trusted-server-cli/tests/fixtures/release/release-index.json create mode 100644 crates/trusted-server-release/tests/fixtures/README.md create mode 100644 crates/trusted-server-release/tests/fixtures/collection/README.md create mode 100644 crates/trusted-server-release/tests/fixtures/collection/build-record.json create mode 100644 crates/trusted-server-release/tests/fixtures/collection/composition.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json create mode 100644 crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json diff --git a/crates/trusted-server-cli/tests/fixtures/release/release-index.json b/crates/trusted-server-cli/tests/fixtures/release/release-index.json new file mode 100644 index 0000000000..c7b6d9b897 --- /dev/null +++ b/crates/trusted-server-cli/tests/fixtures/release/release-index.json @@ -0,0 +1,218 @@ +{ + "artifacts": [ + { + "assurance_profile": "provenance-sbom-repeatable", + "capabilities": ["fastly-production"], + "companions": { + "build_evidence": { + "name": "trusted-server-fastly.wasm.build-evidence.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 2048 + }, + "build_recipe": { + "name": "trusted-server-fastly.wasm.build-recipe.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1024 + }, + "provenance": { + "name": "trusted-server-fastly.wasm.provenance.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + }, + "rebuild_check": { + "name": "trusted-server-fastly.wasm.rebuild-check.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 512 + }, + "sbom": { + "name": "trusted-server-fastly.wasm.spdx.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 4096 + }, + "sbom_attestation": { + "name": "trusted-server-fastly.wasm.sbom.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + } + }, + "compatibility": { + "architecture": "wasm32", + "minimum_version": "wasip1", + "runtime": "fastly-compute" + }, + "js_inventory_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": "wasm-core", + "name": "trusted-server-fastly.wasm", + "package": "trusted-server-adapter-fastly", + "role": "server", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1048576, + "target": "wasm32-wasip1", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "capabilities": ["cli-execute"], + "companions": { + "build_evidence": { + "name": "ts-aarch64-apple-darwin.build-evidence.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 2048 + }, + "build_recipe": { + "name": "ts-aarch64-apple-darwin.build-recipe.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1024 + }, + "provenance": { + "name": "ts-aarch64-apple-darwin.provenance.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + }, + "rebuild_check": { + "name": "ts-aarch64-apple-darwin.rebuild-check.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 512 + }, + "sbom": { + "name": "ts-aarch64-apple-darwin.spdx.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 4096 + }, + "sbom_attestation": { + "name": "ts-aarch64-apple-darwin.sbom.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + } + }, + "compatibility": { + "architecture": "aarch64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "js_inventory_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": "native-executable", + "name": "ts-aarch64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1048576, + "target": "aarch64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "capabilities": ["cli-execute"], + "companions": { + "build_evidence": { + "name": "ts-x86_64-apple-darwin.build-evidence.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 2048 + }, + "build_recipe": { + "name": "ts-x86_64-apple-darwin.build-recipe.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1024 + }, + "provenance": { + "name": "ts-x86_64-apple-darwin.provenance.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + }, + "rebuild_check": { + "name": "ts-x86_64-apple-darwin.rebuild-check.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 512 + }, + "sbom": { + "name": "ts-x86_64-apple-darwin.spdx.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 4096 + }, + "sbom_attestation": { + "name": "ts-x86_64-apple-darwin.sbom.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + } + }, + "compatibility": { + "architecture": "x86_64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "js_inventory_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": "native-executable", + "name": "ts-x86_64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1048576, + "target": "x86_64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "capabilities": ["cli-execute"], + "companions": { + "build_evidence": { + "name": "ts-x86_64-unknown-linux-gnu.build-evidence.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 2048 + }, + "build_recipe": { + "name": "ts-x86_64-unknown-linux-gnu.build-recipe.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1024 + }, + "provenance": { + "name": "ts-x86_64-unknown-linux-gnu.provenance.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + }, + "rebuild_check": { + "name": "ts-x86_64-unknown-linux-gnu.rebuild-check.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 512 + }, + "sbom": { + "name": "ts-x86_64-unknown-linux-gnu.spdx.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 4096 + }, + "sbom_attestation": { + "name": "ts-x86_64-unknown-linux-gnu.sbom.sigstore.json", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 8192 + } + }, + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "js_inventory_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kind": "native-executable", + "name": "ts-x86_64-unknown-linux-gnu", + "package": "trusted-server-cli", + "role": "operator-cli", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size": 1048576, + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + } + ], + "cohort": "phase1-fastly-cli", + "cohort_source_commit": "0123456789abcdef0123456789abcdef01234567", + "policy_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "policy_version": 1, + "recipe_source_commit": "0123456789abcdef0123456789abcdef01234567", + "repository": "IABTechLab/trusted-server", + "schema_version": 1, + "source_commit": "0123456789abcdef0123456789abcdef01234567", + "tag": "v1.2.0", + "tsjs_inventory": { + "name": "tsjs-inventory.json", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size": 4096 + } +} diff --git a/crates/trusted-server-release/src/documents/tests.rs b/crates/trusted-server-release/src/documents/tests.rs index e3d48ebe85..347650c7bd 100644 --- a/crates/trusted-server-release/src/documents/tests.rs +++ b/crates/trusted-server-release/src/documents/tests.rs @@ -881,3 +881,48 @@ fn index_role_checks_require_role_specific_companion_names() { "should reject companion names not derived from their asset" ); } + +#[test] +fn phase3_preserves_frozen_v1_policy_and_release_index_schema() { + for (bytes, expected) in [ + ( + include_bytes!("../../../trusted-server-cli/release-policy.json").as_slice(), + "f08ce10cd16ff2d1acc482db2b85eb53b667ec2b4753792f277d12f41d1f636c", + ), + ( + include_bytes!( + "../../../../docs/superpowers/specs/schemas/release-index-v1.schema.json" + ) + .as_slice(), + "06a7e598f1d14b499d087ec0c894829b3aab26a690b35e5922d96987a877bb9e", + ), + ] { + assert_eq!( + hex::encode(Sha256::digest(bytes)), + expected, + "should preserve the frozen v1 contract bytes during the Phase 3 source port" + ); + } +} + +#[test] +fn phase3_restores_exact_frozen_v1_release_index_fixtures() { + for (bytes, expected) in [ + ( + include_bytes!("../../../trusted-server-cli/tests/fixtures/release/release-index.json") + .as_slice(), + "4ff7a4b64e2724172e19d8ef0b02f500f133719411a15b0ce74d8fce4c8b7034", + ), + ( + include_bytes!("../../tests/fixtures/index/candidate/candidate-release-index.json") + .as_slice(), + "2e35dd6998ec958eae1f23454c50a5b6306be29d2f5eabdb60fd7360f0ca4203", + ), + ] { + assert_eq!( + hex::encode(Sha256::digest(bytes)), + expected, + "should restore the exact frozen v1 index fixture bytes" + ); + } +} diff --git a/crates/trusted-server-release/tests/fixtures/README.md b/crates/trusted-server-release/tests/fixtures/README.md new file mode 100644 index 0000000000..0bbc65d2b1 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/README.md @@ -0,0 +1,17 @@ +# Release document fixtures + +Private unit tests in `src/documents/tests.rs`, `src/policy/tests.rs`, `src/preflight/tests.rs` and `src/source/tests.rs` are registered by their modules; public process tests in `tests/commands.rs` exercise the closed dispatch, the fail-closed `preflight` and `audit-inputs` commands, and the closed POSIX bootstrap. Fixture documents, names and digests are fictional controls. Schema-valid fixtures demonstrate structural validation, not source approval, authenticated acquisition, complete SPDX coverage or release qualification. + +The source-owned `../../schemas/spdx-2.3.schema.json` is retained byte-for-byte from the SPDX specification at commit `aadf3b0b8dbbabdb4d880b0fc714255fea436ff7`, path `schemas/spdx-schema.json`: 45,312 bytes, SHA-256 `239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b`, JSON Schema Draft 7. SPDX specification contributors provide this material under Creative Commons Attribution 3.0 Unported; the original repository license is retained in `../../schemas/SPDX-LICENSE`. The schema has not been modified. + +Source: [SPDX specification schema](https://github.com/spdx/spdx-spec/blob/aadf3b0b8dbbabdb4d880b0fc714255fea436ff7/schemas/spdx-schema.json), [upstream license](https://github.com/spdx/spdx-spec/blob/aadf3b0b8dbbabdb4d880b0fc714255fea436ff7/LICENSE). + +The exact `jsonschema` 0.58.6 dependency is a locally tested evaluator candidate. Default HTTP, file, TLS and IDNA features and optional language bindings are disabled. Every evaluator construction selects a draft explicitly and uses `offline()` to reject unregistered references, including under feature unification. Production code selects only the three retained schemas; no schema-file CLI override exists. The synthetic prefix-items control qualifies Draft 2020-12 behavior alongside structural positive and negative tests of the governing index and qualification-result schemas. + +Atomic-output callers must select a trusted parent directory. Source and process tests build their own Git repositories in temporary directories (`git init`, fictional files, fixed fictional author and committer dates) under a cleared environment; they never assert success against the developer checkout. Process fixtures copy the tracked cohort, tools and consumer policy bytes and may mutate only those copies, for example a candidate cohort listing its own `rc/` branch or structurally qualified tool pins with fictional digests. A clean fixture still fails at the closed qualified selector because pins are pending and authenticated acquisition receipts (Task 8) cannot be constructed; remote branch-tip ancestry and ruleset validation (Task 14) are not established locally. Producer execution and the POSIX bootstrap remain closed. Full real-artifact SPDX coverage remains Task 11 work. + +The 640-byte `sha-mbles-1.bin` is the public SHA-1 chosen-prefix collision vector from the SHA-mbles research (Leurent and Peyrin, 2020), copied byte-for-byte from the `sha1-checked` 0.10.0 crate test data (SHA-256 `3ead211681cec93d265c8ac123dd062e105408cebf82fa6e2b126f4f40bcb88c`). The unit test hashes it directly to prove collision detection rejects it; Git-framed objects add a ` \0` header, so an attacker would have to include that header in the chosen prefix. + +Fixture and process tests require a `git` executable on an absolute `PATH` entry; they build every repository under a cleared environment. Because preflight rejects any `.cargo/config` or `.cargo/config.toml` above the source root, the temporary directory used for fixtures must have no such ancestor layer; the fixtures assert this and name the offending file (set `TMPDIR` to a clean directory if needed). + +The `index/candidate/` files are the golden output of the unqualified candidate release index assembly (`src/distribution.rs`), rendered from fictional entry facts in `src/distribution/tests.rs` and compared byte-for-byte by `distribution::tests::golden`. Every name, digest and commit is fictional except `policy_sha256`, which is the SHA-256 of the tracked `crates/trusted-server-cli/release-policy.json`, so the consumer accepts the producer policy as current; a change to that policy requires regenerating these files in the same change. The index bytes are named `candidate-release-index.json`, never `release-index.json`; the tag is the sentinel `v0.0.0-unqualified-candidate`; both signature bundle records per artifact are the empty-file record (size 0, SHA-256 of zero bytes) and no bundle file exists. The CLI's `release::evidence::candidate_fixture_tests` includes these files test-only (neither crate depends on the other), runs every unsigned consumer join over them and confirms that the consumer's non-empty bundle rule rejects the candidate. These fixtures grant no signing, publication or qualification authority. diff --git a/crates/trusted-server-release/tests/fixtures/collection/README.md b/crates/trusted-server-release/tests/fixtures/collection/README.md new file mode 100644 index 0000000000..36844b9ea2 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/collection/README.md @@ -0,0 +1,61 @@ +# Collection candidate fixtures + +`build-record.json` is fictional imported diagnostic data. The tests fill its +artifact, inventory, composition, and runtime digests from local fixture bytes. + +`composition.json` is a fictional sealed composition receipt in the producer's +exact `composition_export()` shape: Cargo query plan digests, compiler +observations, and the graph, package origin, native, JavaScript and platform +receipts. The tests fill its target, final artifact digest (JavaScript, +platform and external-requirement bindings), approved source commit (package +origins and workspace origins) and shared inventory digest per build. The +collector rejects unknown or missing members at every level it checks, binds +the receipt to the record's artifact, source, inventory, materials digest and +fixed target, requires compiled units and one well-formed origin per observed +package, rejects any complete material native coverage claim, and requires a +Fastly receipt to embed every prepared JS module. The platform's installed-tool +subtrees stay opaque apart from those bindings, Apple evidence present exactly +for Darwin targets, and a runtime format (`wasm`, `elf`, `macho`) matching the +target; the tests add fictional Apple and runtime facts per target. +A Fastly runtime diagnostic names the build role (`A` or `B`) whose own run +accepted the final bytes and must match its record's role. That role is +asserted by the producer inside its own record, so it catches mix-ups and reuse +in an honest pipeline but not a malicious producer copying the other role's +runtime object over equal bytes. Binding strength comes from the trusted upload +role and job isolation; a per-run Viceroy identity remains a named gap. A +missing optional +native reproduction has a separate bounded result with no upload ID, transfer +digest, or invented environment observations. Its outcome derives from the +observed job state: never started, skipped or cancelled before starting is +`unavailable`; started without an upload, or a failed or timed-out conclusion, +is `failed`. + +The record's boundary block keeps two identities apart. `fingerprint` and +`execution_fingerprint` name the boundary instance; they must be equal (the +socket denial receipt belongs to the instance that ran the build) and differ +between A and B. Because each instance fingerprint includes a fresh nonce, that +difference only proves separate instances. `invocation_digest` is the exact +build invocation digest (program, arguments including jobs, environment, +checkout-root working directory and umask); A and B must also differ there. + +The dependency capability has no production constructor: the qualified adapter +is an uninhabited type. An imported record never constructs local recipe, +network-denial, composition, or runtime proof types. In particular, the imported +composition receipt checks digest and identity consistency; it does not prove +complete source coverage or final executable membership. These diagnostics +cannot authorize signing, publication, or deployment. + +The pending workflow adapter must obtain upload identities from trusted current +run dependencies, supply the approved repository to both exact-ID requests, +limit transport bytes and time, and reject transfer warnings. Raw artifact REST +metadata corroborates artifact ID/name/digest, expiration, run, source, and +repository IDs; it does not independently identify a producing job or attempt. +The adapter must never select a caller-supplied download URL. + +The ZIP reader accepts a declared flat ZIP32 profile with stored or raw-deflate +members and optional data descriptors. It rejects ZIP64, extras, encryption, +nonregular members, multidisk archives, comments, duplicate identities, +undeclared bytes, and local/central/descriptor disagreements. It retains checked +member bytes in memory and performs no filesystem extraction. Qualification of +the actual upload action archive format and trusted producer export remains +required before official collection. diff --git a/crates/trusted-server-release/tests/fixtures/collection/build-record.json b/crates/trusted-server-release/tests/fixtures/collection/build-record.json new file mode 100644 index 0000000000..540a3df51b --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/collection/build-record.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "recipe_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "asset": "trusted-server-fastly.wasm", + "role": "A", + "job": "fastly-a", + "attempt": 1, + "inputs": { + "cargo_lock": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "materials": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "rust_tools": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "js_settings": "1111111111111111111111111111111111111111111111111111111111111111", + "js_tools": "2222222222222222222222222222222222222222222222222222222222222222" + }, + "inventory_sha256": "", + "artifact_sha256": "", + "artifact_size": 0, + "composition_sha256": "", + "ambient": { + "umask": 18, + "jobs": 4, + "checkout_root": "/external-release-A", + "started_at_seconds": 100, + "execution_uid": 1000, + "host_locale": "C", + "host_timezone": "UTC", + "child_locale": "C", + "child_timezone": "UTC" + }, + "claim": { + "varied": ["umask", "jobs", "checkout_root", "start_time"], + "normalized": ["locale", "timezone"], + "unsupported": ["execution_user"] + }, + "boundary": { + "fingerprint": "3333333333333333333333333333333333333333333333333333333333333333", + "execution_fingerprint": "3333333333333333333333333333333333333333333333333333333333333333", + "invocation_digest": "5555555555555555555555555555555555555555555555555555555555555555", + "confined": true, + "control_exit": 0, + "control_accepts": 1, + "probe_exit": 3, + "probe_errno": 1, + "probe_accepts": 0 + }, + "runtime": null +} diff --git a/crates/trusted-server-release/tests/fixtures/collection/composition.json b/crates/trusted-server-release/tests/fixtures/collection/composition.json new file mode 100644 index 0000000000..9ac423af0e --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/collection/composition.json @@ -0,0 +1,203 @@ +{ + "metadata_plan_sha256": "7777777777777777777777777777777777777777777777777777777777777777", + "tree_plan_sha256": "8888888888888888888888888888888888888888888888888888888888888888", + "observations": { + "packages": [ + { + "id": "path+file:///workspace/crates/example-app#0.1.0", + "name": "example-app", + "version": "0.1.0", + "source": null, + "manifest_path": "/workspace/crates/example-app/Cargo.toml", + "license": "Apache-2.0" + }, + { + "id": "registry+https://registry.example.com/index#example-dep@1.0.0", + "name": "example-dep", + "version": "1.0.0", + "source": "registry+https://registry.example.com/index", + "manifest_path": "/workspace/vendor/example-dep-1.0.0/Cargo.toml", + "license": null + } + ], + "units": [ + { + "package_id": "registry+https://registry.example.com/index#example-dep@1.0.0", + "target_name": "example_dep", + "target_kind": ["lib"], + "crate_types": ["lib"], + "context": "target", + "features": ["default"], + "profile": { + "opt_level": "3", + "debuginfo": 2, + "debug_assertions": false, + "overflow_checks": false, + "test": false + }, + "filenames": [ + "/workspace/target/release/deps/libexample_dep-0000000000000000.rlib" + ], + "executable": null + }, + { + "package_id": "path+file:///workspace/crates/example-app#0.1.0", + "target_name": "example-app", + "target_kind": ["bin"], + "crate_types": ["bin"], + "context": "target", + "features": [], + "profile": { + "opt_level": "3", + "debuginfo": 2, + "debug_assertions": false, + "overflow_checks": false, + "test": false + }, + "filenames": ["/workspace/target/release/example-app"], + "executable": "/workspace/target/release/example-app" + } + ], + "build_scripts": [ + { + "package_id": "registry+https://registry.example.com/index#example-dep@1.0.0", + "context": "target", + "out_dir": "/workspace/target/release/build/example-dep-0000000000000000/out", + "linked_libs": ["static=example"], + "linked_paths": [ + "native=/workspace/target/release/build/example-dep-0000000000000000/out" + ], + "cfgs": ["example_cfg"], + "environment": [["EXAMPLE_ENV", "1"]] + } + ] + }, + "composition": { + "graph": { + "selected_package_id": "path+file:///workspace/crates/example-app#0.1.0", + "tree_package_ids": [ + "path+file:///workspace/crates/example-app#0.1.0", + "registry+https://registry.example.com/index#example-dep@1.0.0" + ], + "observed_package_ids": [ + "path+file:///workspace/crates/example-app#0.1.0", + "registry+https://registry.example.com/index#example-dep@1.0.0" + ], + "edges": [ + { + "parent": "path+file:///workspace/crates/example-app#0.1.0", + "dependency": "registry+https://registry.example.com/index#example-dep@1.0.0", + "kinds": ["normal"] + } + ] + }, + "package_origins": { + "source_commit": "", + "materials_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "packages": { + "path+file:///workspace/crates/example-app#0.1.0": { + "name": "example-app", + "version": "0.1.0", + "manifest_path": "crates/example-app/Cargo.toml", + "prepared_manifest_sha256": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "origin": { + "kind": "workspace", + "source_commit": "", + "path": "crates/example-app/Cargo.toml" + } + }, + "registry+https://registry.example.com/index#example-dep@1.0.0": { + "name": "example-dep", + "version": "1.0.0", + "manifest_path": "vendor/example-dep-1.0.0/Cargo.toml", + "prepared_manifest_sha256": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "origin": { + "kind": "material", + "source": "registry+https://registry.example.com/index", + "original": { + "kind": "registry", + "archive_sha256": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + } + } + } + } + }, + "native": { + "contributors": [ + { + "package_id": "registry+https://registry.example.com/index#example-dep@1.0.0", + "name": "example-dep", + "version": "1.0.0", + "context": "target", + "features": ["default"], + "classification": "conservative_embedded_contributor", + "original_sha256": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "source_tree_sha256": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "source_subtrees": [ + { + "path": "native", + "sha256": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" + } + ], + "linked_libs": ["static=example"], + "linked_paths": [ + "native=/workspace/target/release/build/example-dep-0000000000000000/out" + ], + "archives": [ + { + "path": "/workspace/target/release/build/example-dep-0000000000000000/out/libexample.a", + "size": 4096, + "sha256": "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + } + ] + } + ], + "build_tool_package_ids": [], + "material_native_coverage_complete": false + }, + "javascript": { + "artifact_sha256": "", + "inventory_sha256": "", + "modules": [ + { + "module": { + "id": "core", + "filename": "tsjs-core.js", + "size": 64, + "sha256": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a" + }, + "classification": "conservative_embedded_js" + } + ], + "method": "complete-uncompressed-module-substring; no function reachability claim" + }, + "platform": { + "target": "", + "artifact_sha256": "", + "rust": { + "tree_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "target": "", + "cargo": "cargo 1.95.0", + "rustc": "rustc 1.95.0", + "target_standard_library": "lib/rustlib/example-target/lib", + "identity_scope": "fictional-installed-tree", + "origin": "fictional-unauthenticated", + "qualification": "unqualified" + }, + "apple": null, + "external_requirements": { + "target": "", + "artifact_sha256": "", + "runtime": { + "format": "wasm", + "import_modules": ["fastly_http_req"] + } + }, + "native_tools": { + "scope": "host_path_unresolved", + "roles": ["compiler", "assembler", "archiver", "linker", "build_tools"] + }, + "material_native_coverage_complete": false + } + } +} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json b/crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json new file mode 100644 index 0000000000..c642eeff0a --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json @@ -0,0 +1 @@ +{"artifacts":[{"assurance_profile":"provenance-sbom-repeatable","capabilities":["fastly-production"],"companions":{"build_evidence":{"name":"trusted-server-fastly.wasm.build-evidence.json","sha256":"d507ab4f6377db0ffc5f35c0471322aeaf5b0fd454dcd6e223d5801d68017230","size":1396},"build_recipe":{"name":"trusted-server-fastly.wasm.build-recipe.json","sha256":"5dd2af6404b6554d20cdb67be8228f88f0726447612f29476972f6f8f3ff98d1","size":948},"provenance":{"name":"trusted-server-fastly.wasm.provenance.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0},"rebuild_check":{"name":"trusted-server-fastly.wasm.rebuild-check.json","sha256":"381505ae1be7d5c46e7019a6e514c2e1ef7844bc85faba3ddb0da67513190437","size":646},"sbom":{"name":"trusted-server-fastly.wasm.spdx.json","sha256":"ac94b402aa1d0fb889bc9cb063e81ff41573c16d52fd93a02290bec75b3f7dcb","size":886},"sbom_attestation":{"name":"trusted-server-fastly.wasm.sbom.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0}},"compatibility":{"architecture":"wasm32","minimum_version":"wasip1","runtime":"fastly-compute"},"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","kind":"wasm-core","name":"trusted-server-fastly.wasm","package":"trusted-server-adapter-fastly","role":"server","sha256":"53ebda42e7686cb0048b1b9d75de6ae360c436f63f844a09177e139fa96b9617","size":53,"target":"wasm32-wasip1","variant":"default"},{"assurance_profile":"provenance-sbom","capabilities":["cli-execute"],"companions":{"build_evidence":{"name":"ts-aarch64-apple-darwin.build-evidence.json","sha256":"4398ff1d660f8f25741f0d26128312c856f8a80ed45fad92ce1961e3f89f6b66","size":1260},"build_recipe":{"name":"ts-aarch64-apple-darwin.build-recipe.json","sha256":"c2ae63588adfd51957a42b943c668a8b0c9119f12b5cf13098c927662a82f95b","size":975},"provenance":{"name":"ts-aarch64-apple-darwin.provenance.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0},"rebuild_check":{"name":"ts-aarch64-apple-darwin.rebuild-check.json","sha256":"e20a78e71af499d34b2d1aeb16b1429cfe7281a8eec715e571ebc7a840fa000f","size":619},"sbom":{"name":"ts-aarch64-apple-darwin.spdx.json","sha256":"fe487f344797a42c97bdfe44248af98fc8d3e05aff8453c7e2f57f5bd5b88992","size":877},"sbom_attestation":{"name":"ts-aarch64-apple-darwin.sbom.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0}},"compatibility":{"architecture":"aarch64","minimum_version":"13.0","runtime":"macos"},"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","kind":"native-executable","name":"ts-aarch64-apple-darwin","package":"trusted-server-cli","role":"operator-cli","sha256":"ba83ee6a8918e5d9d50f042f53dc853341d43b36a89e755cce842076b46ecc17","size":50,"target":"aarch64-apple-darwin","variant":"default"},{"assurance_profile":"provenance-sbom","capabilities":["cli-execute"],"companions":{"build_evidence":{"name":"ts-x86_64-apple-darwin.build-evidence.json","sha256":"248b207f66e9b325327779d493b353d9ccf88942d5739eaa89a42962d17c5fb1","size":1258},"build_recipe":{"name":"ts-x86_64-apple-darwin.build-recipe.json","sha256":"0b01813499eceb2aafc9e35311f6b14dce56c6b499fd0f5b97333c5267d2b114","size":973},"provenance":{"name":"ts-x86_64-apple-darwin.provenance.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0},"rebuild_check":{"name":"ts-x86_64-apple-darwin.rebuild-check.json","sha256":"26737bde3396202434cd28fb6d9f3e7b085e4a82e7762e3f0177cb09d6966e21","size":618},"sbom":{"name":"ts-x86_64-apple-darwin.spdx.json","sha256":"dbaef04e50548077cced269f7d4baade02717ce3d9b054bbbd335d9bdea1020a","size":874},"sbom_attestation":{"name":"ts-x86_64-apple-darwin.sbom.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0}},"compatibility":{"architecture":"x86_64","minimum_version":"13.0","runtime":"macos"},"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","kind":"native-executable","name":"ts-x86_64-apple-darwin","package":"trusted-server-cli","role":"operator-cli","sha256":"d3866b0e34f91e944be06eba013f25dab3a62d66d2ea6ac1d4b7a035ca4cda9d","size":49,"target":"x86_64-apple-darwin","variant":"default"},{"assurance_profile":"provenance-sbom","capabilities":["cli-execute"],"companions":{"build_evidence":{"name":"ts-x86_64-unknown-linux-gnu.build-evidence.json","sha256":"5a8f3a8ae4277cc3bcd03a7b58c600469b22205a957718b6570d5ddc1644ee63","size":1268},"build_recipe":{"name":"ts-x86_64-unknown-linux-gnu.build-recipe.json","sha256":"de836324f0ca933580b1eb336f177fc21e1cc43140004ff5ecba735371cd3ee1","size":983},"provenance":{"name":"ts-x86_64-unknown-linux-gnu.provenance.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0},"rebuild_check":{"name":"ts-x86_64-unknown-linux-gnu.rebuild-check.json","sha256":"c1f61f93204390231ef694c26eb0c6b3035475490ef4e4ae2850fa8d13714ce6","size":623},"sbom":{"name":"ts-x86_64-unknown-linux-gnu.spdx.json","sha256":"dd5cb2569a0ca2a1abbe110be9b966a282dff42dae9059cebcfa8b15268b274f","size":889},"sbom_attestation":{"name":"ts-x86_64-unknown-linux-gnu.sbom.sigstore.json","sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0}},"compatibility":{"architecture":"x86_64","minimum_version":"glibc-2.34","runtime":"linux-gnu"},"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","kind":"native-executable","name":"ts-x86_64-unknown-linux-gnu","package":"trusted-server-cli","role":"operator-cli","sha256":"f1772b30282757aae1e6917cb270a76cccbca4565e9364b3155c1daf7a98e927","size":54,"target":"x86_64-unknown-linux-gnu","variant":"default"}],"cohort":"phase1-fastly-cli","cohort_source_commit":"0123456789abcdef0123456789abcdef01234567","policy_sha256":"f08ce10cd16ff2d1acc482db2b85eb53b667ec2b4753792f277d12f41d1f636c","policy_version":1,"recipe_source_commit":"0123456789abcdef0123456789abcdef01234567","repository":"IABTechLab/trusted-server","schema_version":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","tag":"v0.0.0-unqualified-candidate","tsjs_inventory":{"name":"tsjs-inventory.json","sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","size":449}} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json new file mode 100644 index 0000000000..575cb44766 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json @@ -0,0 +1 @@ +{"artifact_sha256":"53ebda42e7686cb0048b1b9d75de6ae360c436f63f844a09177e139fa96b9617","artifact_size":53,"asset":"trusted-server-fastly.wasm","compatibility_tests":[{"facts":{"health_status":"200","viceroy_version":"0.0.0-fictional"},"name":"fastly-final-byte-publisher-request","outcome":"passed"}],"domain":"trusted-server.release.candidate-build-evidence","embedded_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26},{"filename":"tsjs-prebid.js","id":"prebid","sha256":"429a327f21c12ce0e398855d8c4c992c380a93a973bad5b4778b1a828c3843d6","size":28}],"gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","prepared_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26},{"filename":"tsjs-prebid.js","id":"prebid","sha256":"429a327f21c12ce0e398855d8c4c992c380a93a973bad5b4778b1a828c3843d6","size":28}],"release_qualification":false,"schema_version":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"wasm32-wasip1","transfer_artifact_id":null,"variant":"default","workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json new file mode 100644 index 0000000000..17c27b6f55 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json @@ -0,0 +1 @@ +{"asset":"trusted-server-fastly.wasm","domain":"trusted-server.release.candidate-build-recipe","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"inputs":{"cargo_lock_sha256":"0d95b8073b49236f0ec898c9efba2201ee27f00aa64148a74c61990dbdb7eabe","materials_digest":"7edac7b524ebfb25e7d463ed246ec0c6dff3917828e7372b1d8a7ade48d29b54","rust_tools_digest":"3d1bccbc4fde967b6e0724d8e07740e1959bcbc4fbeeb04f53f760aa77bf06bf"},"invocation_sha256":"b3fdb94d7962a986406510a2bc0a7a539f9ec1a27d25714385eacd6141da1886","package":"trusted-server-adapter-fastly","recipe_definition_sha256":"4d62015ed31382ffa84a7b8d1aa56482ebca46ac93ee3e41f921d2fe051d7380","release_qualification":false,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"wasm32-wasip1","variant":"default","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json new file mode 100644 index 0000000000..9683969f4c --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json @@ -0,0 +1 @@ +{"asset":"trusted-server-fastly.wasm","canonical_sha256":"53ebda42e7686cb0048b1b9d75de6ae360c436f63f844a09177e139fa96b9617","domain":"trusted-server.release.candidate-rebuild-check","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"outcome":"matched","release_qualification":false,"reproduction_sha256":"53ebda42e7686cb0048b1b9d75de6ae360c436f63f844a09177e139fa96b9617","reproduction_transfer_artifact_id":null,"source_commit":"0123456789abcdef0123456789abcdef01234567","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json new file mode 100644 index 0000000000..f584c37815 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json @@ -0,0 +1 @@ +{"SPDXID":"SPDXRef-DOCUMENT","creationInfo":{"comment":"Unqualified candidate fixture; gaps: fictional-coverage-gap","created":"2026-01-01T00:00:00Z","creators":["Tool: fictional-candidate-fixture"]},"dataLicense":"CC0-1.0","documentDescribes":["SPDXRef-Artifact"],"documentNamespace":"https://example.com/spdx/candidate/trusted-server-fastly.wasm","name":"trusted-server-fastly.wasm","packages":[{"SPDXID":"SPDXRef-Artifact","checksums":[{"algorithm":"SHA256","checksumValue":"53ebda42e7686cb0048b1b9d75de6ae360c436f63f844a09177e139fa96b9617"}],"copyrightText":"NOASSERTION","downloadLocation":"NOASSERTION","filesAnalyzed":false,"licenseConcluded":"NOASSERTION","licenseDeclared":"NOASSERTION","name":"trusted-server-fastly.wasm"}],"relationships":[{"relatedSpdxElement":"SPDXRef-Artifact","relationshipType":"DESCRIBES","spdxElementId":"SPDXRef-DOCUMENT"}],"spdxVersion":"SPDX-2.3"} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json new file mode 100644 index 0000000000..bae45e03ed --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json @@ -0,0 +1 @@ +{"artifact_sha256":"ba83ee6a8918e5d9d50f042f53dc853341d43b36a89e755cce842076b46ecc17","artifact_size":50,"asset":"ts-aarch64-apple-darwin","compatibility_tests":[{"facts":{"startup_kind":"fictional-startup"},"name":"native-startup","outcome":"passed"}],"domain":"trusted-server.release.candidate-build-evidence","embedded_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26}],"gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","prepared_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26},{"filename":"tsjs-prebid.js","id":"prebid","sha256":"429a327f21c12ce0e398855d8c4c992c380a93a973bad5b4778b1a828c3843d6","size":28}],"release_qualification":false,"schema_version":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"aarch64-apple-darwin","transfer_artifact_id":null,"variant":"default","workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json new file mode 100644 index 0000000000..634e49cd57 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json @@ -0,0 +1 @@ +{"asset":"ts-aarch64-apple-darwin","domain":"trusted-server.release.candidate-build-recipe","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"inputs":{"cargo_lock_sha256":"0d95b8073b49236f0ec898c9efba2201ee27f00aa64148a74c61990dbdb7eabe","materials_digest":"7edac7b524ebfb25e7d463ed246ec0c6dff3917828e7372b1d8a7ade48d29b54","rust_tools_digest":"3d1bccbc4fde967b6e0724d8e07740e1959bcbc4fbeeb04f53f760aa77bf06bf"},"invocation_sha256":"b3fdb94d7962a986406510a2bc0a7a539f9ec1a27d25714385eacd6141da1886","package":"trusted-server-cli","recipe_definition_sha256":"4d62015ed31382ffa84a7b8d1aa56482ebca46ac93ee3e41f921d2fe051d7380","release_qualification":false,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"aarch64-apple-darwin","variant":"default","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json new file mode 100644 index 0000000000..0d54ebcb47 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json @@ -0,0 +1 @@ +{"asset":"ts-aarch64-apple-darwin","canonical_sha256":"ba83ee6a8918e5d9d50f042f53dc853341d43b36a89e755cce842076b46ecc17","domain":"trusted-server.release.candidate-rebuild-check","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"outcome":"unavailable","release_qualification":false,"reproduction_sha256":null,"reproduction_transfer_artifact_id":null,"source_commit":"0123456789abcdef0123456789abcdef01234567","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json new file mode 100644 index 0000000000..bb6924545e --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json @@ -0,0 +1 @@ +{"SPDXID":"SPDXRef-DOCUMENT","creationInfo":{"comment":"Unqualified candidate fixture; gaps: fictional-coverage-gap","created":"2026-01-01T00:00:00Z","creators":["Tool: fictional-candidate-fixture"]},"dataLicense":"CC0-1.0","documentDescribes":["SPDXRef-Artifact"],"documentNamespace":"https://example.com/spdx/candidate/ts-aarch64-apple-darwin","name":"ts-aarch64-apple-darwin","packages":[{"SPDXID":"SPDXRef-Artifact","checksums":[{"algorithm":"SHA256","checksumValue":"ba83ee6a8918e5d9d50f042f53dc853341d43b36a89e755cce842076b46ecc17"}],"copyrightText":"NOASSERTION","downloadLocation":"NOASSERTION","filesAnalyzed":false,"licenseConcluded":"NOASSERTION","licenseDeclared":"NOASSERTION","name":"ts-aarch64-apple-darwin"}],"relationships":[{"relatedSpdxElement":"SPDXRef-Artifact","relationshipType":"DESCRIBES","spdxElementId":"SPDXRef-DOCUMENT"}],"spdxVersion":"SPDX-2.3"} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json new file mode 100644 index 0000000000..6e3aa770fe --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json @@ -0,0 +1 @@ +{"artifact_sha256":"d3866b0e34f91e944be06eba013f25dab3a62d66d2ea6ac1d4b7a035ca4cda9d","artifact_size":49,"asset":"ts-x86_64-apple-darwin","compatibility_tests":[{"facts":{"startup_kind":"fictional-startup"},"name":"native-startup","outcome":"passed"}],"domain":"trusted-server.release.candidate-build-evidence","embedded_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26}],"gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","prepared_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26},{"filename":"tsjs-prebid.js","id":"prebid","sha256":"429a327f21c12ce0e398855d8c4c992c380a93a973bad5b4778b1a828c3843d6","size":28}],"release_qualification":false,"schema_version":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"x86_64-apple-darwin","transfer_artifact_id":null,"variant":"default","workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json new file mode 100644 index 0000000000..ef49832ed5 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json @@ -0,0 +1 @@ +{"asset":"ts-x86_64-apple-darwin","domain":"trusted-server.release.candidate-build-recipe","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"inputs":{"cargo_lock_sha256":"0d95b8073b49236f0ec898c9efba2201ee27f00aa64148a74c61990dbdb7eabe","materials_digest":"7edac7b524ebfb25e7d463ed246ec0c6dff3917828e7372b1d8a7ade48d29b54","rust_tools_digest":"3d1bccbc4fde967b6e0724d8e07740e1959bcbc4fbeeb04f53f760aa77bf06bf"},"invocation_sha256":"b3fdb94d7962a986406510a2bc0a7a539f9ec1a27d25714385eacd6141da1886","package":"trusted-server-cli","recipe_definition_sha256":"4d62015ed31382ffa84a7b8d1aa56482ebca46ac93ee3e41f921d2fe051d7380","release_qualification":false,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"x86_64-apple-darwin","variant":"default","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json new file mode 100644 index 0000000000..05e734aff3 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json @@ -0,0 +1 @@ +{"asset":"ts-x86_64-apple-darwin","canonical_sha256":"d3866b0e34f91e944be06eba013f25dab3a62d66d2ea6ac1d4b7a035ca4cda9d","domain":"trusted-server.release.candidate-rebuild-check","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"outcome":"unavailable","release_qualification":false,"reproduction_sha256":null,"reproduction_transfer_artifact_id":null,"source_commit":"0123456789abcdef0123456789abcdef01234567","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json new file mode 100644 index 0000000000..7eb2ce3994 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json @@ -0,0 +1 @@ +{"SPDXID":"SPDXRef-DOCUMENT","creationInfo":{"comment":"Unqualified candidate fixture; gaps: fictional-coverage-gap","created":"2026-01-01T00:00:00Z","creators":["Tool: fictional-candidate-fixture"]},"dataLicense":"CC0-1.0","documentDescribes":["SPDXRef-Artifact"],"documentNamespace":"https://example.com/spdx/candidate/ts-x86_64-apple-darwin","name":"ts-x86_64-apple-darwin","packages":[{"SPDXID":"SPDXRef-Artifact","checksums":[{"algorithm":"SHA256","checksumValue":"d3866b0e34f91e944be06eba013f25dab3a62d66d2ea6ac1d4b7a035ca4cda9d"}],"copyrightText":"NOASSERTION","downloadLocation":"NOASSERTION","filesAnalyzed":false,"licenseConcluded":"NOASSERTION","licenseDeclared":"NOASSERTION","name":"ts-x86_64-apple-darwin"}],"relationships":[{"relatedSpdxElement":"SPDXRef-Artifact","relationshipType":"DESCRIBES","spdxElementId":"SPDXRef-DOCUMENT"}],"spdxVersion":"SPDX-2.3"} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json new file mode 100644 index 0000000000..71dc9910d1 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json @@ -0,0 +1 @@ +{"artifact_sha256":"f1772b30282757aae1e6917cb270a76cccbca4565e9364b3155c1daf7a98e927","artifact_size":54,"asset":"ts-x86_64-unknown-linux-gnu","compatibility_tests":[{"facts":{"startup_kind":"fictional-startup"},"name":"native-startup","outcome":"passed"}],"domain":"trusted-server.release.candidate-build-evidence","embedded_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26}],"gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"js_inventory_sha256":"64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796","prepared_modules":[{"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26},{"filename":"tsjs-prebid.js","id":"prebid","sha256":"429a327f21c12ce0e398855d8c4c992c380a93a973bad5b4778b1a828c3843d6","size":28}],"release_qualification":false,"schema_version":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"x86_64-unknown-linux-gnu","transfer_artifact_id":null,"variant":"default","workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json new file mode 100644 index 0000000000..e8e429a124 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json @@ -0,0 +1 @@ +{"asset":"ts-x86_64-unknown-linux-gnu","domain":"trusted-server.release.candidate-build-recipe","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"inputs":{"cargo_lock_sha256":"0d95b8073b49236f0ec898c9efba2201ee27f00aa64148a74c61990dbdb7eabe","materials_digest":"7edac7b524ebfb25e7d463ed246ec0c6dff3917828e7372b1d8a7ade48d29b54","rust_tools_digest":"3d1bccbc4fde967b6e0724d8e07740e1959bcbc4fbeeb04f53f760aa77bf06bf"},"invocation_sha256":"b3fdb94d7962a986406510a2bc0a7a539f9ec1a27d25714385eacd6141da1886","package":"trusted-server-cli","recipe_definition_sha256":"4d62015ed31382ffa84a7b8d1aa56482ebca46ac93ee3e41f921d2fe051d7380","release_qualification":false,"source_commit":"0123456789abcdef0123456789abcdef01234567","target":"x86_64-unknown-linux-gnu","variant":"default","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json new file mode 100644 index 0000000000..6121407e65 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json @@ -0,0 +1 @@ +{"asset":"ts-x86_64-unknown-linux-gnu","canonical_sha256":"f1772b30282757aae1e6917cb270a76cccbca4565e9364b3155c1daf7a98e927","domain":"trusted-server.release.candidate-rebuild-check","gaps":["authenticated-bootstrap-not-established","fictional-coverage-gap","native-reproduction-unavailable","signature-bundles-awaiting-signature","trusted-workflow-transfer-not-bound","workflow-run-identity-unavailable"],"outcome":"unavailable","release_qualification":false,"reproduction_sha256":null,"reproduction_transfer_artifact_id":null,"source_commit":"0123456789abcdef0123456789abcdef01234567","version":1,"workflow_run_id":null} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json new file mode 100644 index 0000000000..dab76caac0 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json @@ -0,0 +1 @@ +{"SPDXID":"SPDXRef-DOCUMENT","creationInfo":{"comment":"Unqualified candidate fixture; gaps: fictional-coverage-gap","created":"2026-01-01T00:00:00Z","creators":["Tool: fictional-candidate-fixture"]},"dataLicense":"CC0-1.0","documentDescribes":["SPDXRef-Artifact"],"documentNamespace":"https://example.com/spdx/candidate/ts-x86_64-unknown-linux-gnu","name":"ts-x86_64-unknown-linux-gnu","packages":[{"SPDXID":"SPDXRef-Artifact","checksums":[{"algorithm":"SHA256","checksumValue":"f1772b30282757aae1e6917cb270a76cccbca4565e9364b3155c1daf7a98e927"}],"copyrightText":"NOASSERTION","downloadLocation":"NOASSERTION","filesAnalyzed":false,"licenseConcluded":"NOASSERTION","licenseDeclared":"NOASSERTION","name":"ts-x86_64-unknown-linux-gnu"}],"relationships":[{"relatedSpdxElement":"SPDXRef-Artifact","relationshipType":"DESCRIBES","spdxElementId":"SPDXRef-DOCUMENT"}],"spdxVersion":"SPDX-2.3"} diff --git a/crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json b/crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json new file mode 100644 index 0000000000..572cb544f5 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json @@ -0,0 +1 @@ +{"modules":[{"contributions":[{"package":"fictional-js"}],"filename":"tsjs-core.js","id":"core","sha256":"b206e4e772105ede4afc00a7971ec971b757cfb4f2e2e62975a459f2a6e160ae","size":26},{"contributions":[{"package":"fictional-js"}],"filename":"tsjs-prebid.js","id":"prebid","sha256":"429a327f21c12ce0e398855d8c4c992c380a93a973bad5b4778b1a828c3843d6","size":28}],"schema_version":1,"source_commit":"0123456789abcdef0123456789abcdef01234567","tools":[]} From 4682306b1c7c68171da32b05e668e34df8cc69bf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 18:11:13 +0530 Subject: [PATCH 5/8] Port retained SPDX collection and distribution foundations --- Cargo.lock | 140 ++ crates/trusted-server-release/Cargo.toml | 6 + .../trusted-server-release/src/collection.rs | 368 ++++ .../src/collection/archive.rs | 317 +++ .../src/collection/producer.rs | 361 ++++ .../src/collection/producer/strict_record.rs | 329 +++ .../producer/strict_record/tests.rs | 516 +++++ .../src/collection/records.rs | 583 +++++ .../src/collection/records/receipt.rs | 548 +++++ .../src/collection/tests.rs | 1925 +++++++++++++++++ .../src/collection/transfer.rs | 649 ++++++ .../src/collection/transfer/ordinary.rs | 104 + .../src/collection/transfer/tests.rs | 836 +++++++ .../src/collection/workflow_jobs.rs | 430 ++++ .../src/collection/workflow_jobs/tests.rs | 644 ++++++ .../collection/workflow_jobs/tests/support.rs | 216 ++ .../workflow_jobs/tests/transfer.rs | 99 + .../src/composition/native/tests.rs | 134 ++ .../src/distribution.rs | 546 +++++ .../src/distribution/entry.rs | 534 +++++ .../src/distribution/joins.rs | 248 +++ .../src/distribution/ordinary.rs | 184 ++ .../src/distribution/summary.rs | 208 ++ .../src/distribution/tests.rs | 729 +++++++ .../src/distribution/tests/golden.rs | 169 ++ .../src/distribution/tests/summary.rs | 160 ++ .../src/distribution/tests/transfer.rs | 811 +++++++ .../src/distribution/transfer.rs | 652 ++++++ crates/trusted-server-release/src/error.rs | 6 + crates/trusted-server-release/src/main.rs | 6 + crates/trusted-server-release/src/recipe.rs | 8 +- .../src/recipe/startup.rs | 1 - .../src/recipe/tests.rs | 4 + .../src/recipe/tests/candidate.rs | 199 ++ .../src/recipe/tests/candidate_entry.rs | 121 ++ .../src/recipe/tests/candidate_index.rs | 107 + .../src/recipe/tests/cli.rs | 32 + .../src/recipe/tests/complete.rs | 23 + .../src/recipe/tests/full.rs | 34 + .../src/recipe/tests/macos_cli.rs | 41 +- .../src/recipe/tests/spdx_candidate.rs | 406 ++++ crates/trusted-server-release/src/spdx.rs | 560 +++++ .../src/spdx/candidate.rs | 331 +++ .../src/spdx/candidate/tests.rs | 403 ++++ .../trusted-server-release/src/spdx/cargo.rs | 412 ++++ .../src/spdx/cargo/tests.rs | 370 ++++ .../src/spdx/javascript.rs | 365 ++++ .../src/spdx/javascript/tests.rs | 387 ++++ .../trusted-server-release/src/spdx/native.rs | 358 +++ .../src/spdx/native/tests.rs | 148 ++ .../src/spdx/platform.rs | 447 ++++ .../src/spdx/platform/tests.rs | 646 ++++++ .../trusted-server-release/src/spdx/tests.rs | 656 ++++++ 53 files changed, 18514 insertions(+), 3 deletions(-) create mode 100644 crates/trusted-server-release/src/collection.rs create mode 100644 crates/trusted-server-release/src/collection/archive.rs create mode 100644 crates/trusted-server-release/src/collection/producer.rs create mode 100644 crates/trusted-server-release/src/collection/producer/strict_record.rs create mode 100644 crates/trusted-server-release/src/collection/producer/strict_record/tests.rs create mode 100644 crates/trusted-server-release/src/collection/records.rs create mode 100644 crates/trusted-server-release/src/collection/records/receipt.rs create mode 100644 crates/trusted-server-release/src/collection/tests.rs create mode 100644 crates/trusted-server-release/src/collection/transfer.rs create mode 100644 crates/trusted-server-release/src/collection/transfer/ordinary.rs create mode 100644 crates/trusted-server-release/src/collection/transfer/tests.rs create mode 100644 crates/trusted-server-release/src/collection/workflow_jobs.rs create mode 100644 crates/trusted-server-release/src/collection/workflow_jobs/tests.rs create mode 100644 crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs create mode 100644 crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs create mode 100644 crates/trusted-server-release/src/distribution.rs create mode 100644 crates/trusted-server-release/src/distribution/entry.rs create mode 100644 crates/trusted-server-release/src/distribution/joins.rs create mode 100644 crates/trusted-server-release/src/distribution/ordinary.rs create mode 100644 crates/trusted-server-release/src/distribution/summary.rs create mode 100644 crates/trusted-server-release/src/distribution/tests.rs create mode 100644 crates/trusted-server-release/src/distribution/tests/golden.rs create mode 100644 crates/trusted-server-release/src/distribution/tests/summary.rs create mode 100644 crates/trusted-server-release/src/distribution/tests/transfer.rs create mode 100644 crates/trusted-server-release/src/distribution/transfer.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/candidate_entry.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/candidate_index.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs create mode 100644 crates/trusted-server-release/src/spdx.rs create mode 100644 crates/trusted-server-release/src/spdx/candidate.rs create mode 100644 crates/trusted-server-release/src/spdx/candidate/tests.rs create mode 100644 crates/trusted-server-release/src/spdx/cargo.rs create mode 100644 crates/trusted-server-release/src/spdx/cargo/tests.rs create mode 100644 crates/trusted-server-release/src/spdx/javascript.rs create mode 100644 crates/trusted-server-release/src/spdx/javascript/tests.rs create mode 100644 crates/trusted-server-release/src/spdx/native.rs create mode 100644 crates/trusted-server-release/src/spdx/native/tests.rs create mode 100644 crates/trusted-server-release/src/spdx/platform.rs create mode 100644 crates/trusted-server-release/src/spdx/platform/tests.rs create mode 100644 crates/trusted-server-release/src/spdx/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 366d75dc5c..f3dcc03070 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,6 +1078,30 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1819,6 +1843,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -3302,6 +3338,52 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "hmac" version = "0.13.0" @@ -3708,6 +3790,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -4221,6 +4316,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multimap" version = "0.10.1" @@ -4433,6 +4545,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -5300,6 +5416,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "hickory-resolver", "http", "http-body", "http-body-util", @@ -5308,6 +5425,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "once_cell", "percent-encoding", "pin-project-lite", "quinn", @@ -5368,6 +5486,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -6278,6 +6402,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tar" version = "0.4.46" @@ -7019,6 +7149,9 @@ dependencies = [ "jsonschema", "libc", "object", + "rcgen", + "reqwest 0.12.28", + "rustls", "serde", "serde_json", "sha1-checked", @@ -7026,6 +7159,7 @@ dependencies = [ "tar", "tempfile", "time", + "tokio", "toml", "wasmparser", ] @@ -7503,6 +7637,12 @@ dependencies = [ "libc", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index 26cdfbc81c..fcf156cb41 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -22,6 +22,7 @@ object = { version = "=0.37.3", default-features = false, features = [ "read_core", "std", ] } +reqwest = { workspace = true, features = ["hickory-dns"] } serde.workspace = true serde_json.workspace = true sha1-checked = { version = "=0.10.0", default-features = false } @@ -29,6 +30,7 @@ sha2.workspace = true tempfile.workspace = true tar = { version = "=0.4.46", default-features = false } time = { workspace = true, features = ["parsing"] } +tokio.workspace = true toml.workspace = true wasmparser = { version = "=0.247.0", default-features = false, features = [ "features", @@ -37,5 +39,9 @@ wasmparser = { version = "=0.247.0", default-features = false, features = [ "validate", ] } +[dev-dependencies] +rcgen.workspace = true +rustls.workspace = true + [lints] workspace = true diff --git a/crates/trusted-server-release/src/collection.rs b/crates/trusted-server-release/src/collection.rs new file mode 100644 index 0000000000..aa2eb0b6af --- /dev/null +++ b/crates/trusted-server-release/src/collection.rs @@ -0,0 +1,368 @@ +//! Bounded cross-job transfer diagnostics, without release authorization. +//! +//! REST metadata corroborates a dependency-selected artifact, never its producer +//! job. Only a qualified workflow adapter may eventually mint the dependency +//! capability. Imported documents never reconstruct local recipe, boundary, +//! composition, or runtime proof types. That production adapter remains closed. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{Role, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::policy::FIXED_ARTIFACTS; + +const ARCHIVE_LIMIT: usize = 768 * 1024 * 1024; + +mod archive; +pub(crate) mod producer; +mod records; +pub(crate) mod transfer; +mod workflow_jobs; +pub(crate) use records::CollectionDiagnostic; +#[cfg(test)] +pub(crate) use records::{ReceiptFacts, check_composition_receipt}; +#[cfg(test)] +mod tests; + +/// Qualification of the dependency adapter is not available yet. +#[derive(Clone, Copy)] +pub(crate) enum QualifiedCollection {} + +/// Expected invocation identity from the trusted workflow dependency graph. +#[derive(Clone, Debug)] +struct Invocation { + repository: String, + repository_id: u64, + run_id: u64, + source_commit: String, + inventory_sha256: String, + profiles: [records::RoleProfile; 2], +} + +/// One upload step output; REST cannot independently prove this job association. +#[derive(Clone, Debug)] +struct Upload { + asset: String, + role: String, + job: String, + attempt: u64, + recipe_sha256: String, + elapsed_seconds: u64, + deadline_seconds: u64, + artifact_name: String, + artifact_id: u64, + archive_sha256: String, + members: BTreeMap, +} + +/// A terminal job conclusion as the trusted dependency adapter observed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum JobConclusion { + Success, + Failure, + Cancelled, + Skipped, + TimedOut, +} + +/// What the trusted adapter observed for one optional reproduction job. +/// +/// `conclusion` is `None` when the job had no terminal conclusion when the +/// collector's bounded wait reached its deadline. The trusted adapter MUST +/// wait for a terminal conclusion or for the full deadline before it +/// constructs a conclusion-less observation; stopping early is not a legal +/// input, and [`NoUpload::outcome`] rejects one whose elapsed wait is short of +/// the deadline. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +struct ObservedJob { + /// At least one job step executed. + /// + /// This is not GitHub's job `started_at`, which a job skipped or cancelled + /// before any step may still carry; the adapter derives it from executed + /// steps. + started: bool, + conclusion: Option, +} + +/// An optional reproduction job that produced no upload. +/// +/// It has no field for an upload ID, name or digest, so none can be supplied +/// or synthesized for it. +#[derive(Clone, Debug)] +struct NoUpload { + asset: String, + job: ObservedJob, + elapsed_seconds: u64, + deadline_seconds: u64, +} + +impl NoUpload { + /// Derives the spec outcome, or `None` for contradictory or unbounded input. + /// + /// `unavailable` means no reproduction ran: the job was still queued at the + /// deadline, or the scheduler skipped or cancelled it before any step + /// started. `failed` means it started and produced no upload, or its own + /// conclusion states failure or timeout. The latter stays `failed` even + /// without a started step, so a stated failure is never relabeled as an + /// absent measurement. A job without a conclusion is accepted only once the + /// wait reached the deadline, so the collector cannot cut a reproduction + /// short; deadlines are capped, so it cannot wait indefinitely either. + fn outcome(&self) -> Option { + if !bounded(self.elapsed_seconds, self.deadline_seconds) { + return None; + } + let at_deadline = self.elapsed_seconds == self.deadline_seconds; + match (self.job.started, self.job.conclusion) { + (false, None) if at_deadline => Some(records::Outcome::Unavailable), + (true, None) if at_deadline => Some(records::Outcome::Failed), + (_, None) => None, + (false, Some(JobConclusion::Skipped | JobConclusion::Cancelled)) => { + Some(records::Outcome::Unavailable) + } + (true, Some(JobConclusion::Skipped)) | (false, Some(JobConclusion::Success)) => None, + (_, Some(JobConclusion::Failure | JobConclusion::TimedOut)) + | (true, Some(JobConclusion::Cancelled | JobConclusion::Success)) => { + Some(records::Outcome::Failed) + } + } + } +} + +/// A bounded optional job result with no invented artifact identity. +#[derive(Clone, Debug)] +enum OptionalBuild { + /// An upload, valid only from a started job that concluded successfully. + Uploaded { + upload: Upload, + job: ObservedJob, + }, + NotUploaded(NoUpload), +} + +/// Authenticated dependency outputs; no JSON or caller-field constructor exists. +#[derive(Debug)] +pub(crate) struct TrustedDependencies { + invocation: Invocation, + required: Vec, + optional: Vec, +} + +impl TrustedDependencies { + /// Keeps production closed until a qualified adapter supplies typed inputs. + pub(crate) fn from_qualified(qualification: QualifiedCollection) -> Self { + match qualification {} + } +} + +/// A read-only exact-ID source; the future adapter must enforce transport bounds. +pub(crate) trait ArtifactSource { + /// Fetches REST metadata for this exact artifact ID. + fn metadata( + &mut self, + repository: &str, + artifact_id: u64, + ) -> Result, Report>; + /// Re-downloads that same artifact's archive and reports any transfer warning. + fn download( + &mut self, + repository: &str, + artifact_id: u64, + ) -> Result>; +} + +/// Downloaded bytes, separate from the final executable digest. +pub(crate) struct Download { + pub(crate) archive: Vec, + pub(crate) transfer_warning: bool, +} + +#[derive(Debug)] +struct Transfer { + upload: Upload, + members: BTreeMap>, +} + +/// Validated transfer bytes; this is not a qualified build or signing proof. +#[derive(Debug)] +pub(crate) struct ValidatedTransfers { + invocation: Invocation, + transfers: Vec, + absent: Vec, +} + +/// Validates current-run metadata and the complete bounded ZIP before use. +/// +/// # Errors +/// +/// Rejects mismatched dependency identities, stale REST metadata, transfer +/// warnings, byte changes, or unsafe/undeclared archive members. +pub(crate) fn validate_transfers( + dependencies: &TrustedDependencies, + source: &mut impl ArtifactSource, +) -> Result> { + if dependencies.required.len() > 5 || dependencies.optional.len() > 3 { + return Err(collection_error( + "dependency transfer count exceeds cohort bounds", + )); + } + let invocation = &dependencies.invocation; + if invocation.repository_id == 0 + || invocation.run_id == 0 + || !lower_hex(&invocation.source_commit, 40) + || !lower_hex(&invocation.inventory_sha256, 64) + || !safe_repository(&invocation.repository) + { + return Err(collection_error("invalid invocation identity")); + } + let mut ids = BTreeSet::new(); + let mut roles = BTreeSet::new(); + let mut transfers = Vec::new(); + let mut absent = Vec::new(); + // The mandatory channel carries every canonical build and only the Fastly + // reproduction; native reproductions are optional. + if dependencies.required.iter().any(|upload| { + upload.role != "A" && (upload.role != "B" || upload.asset != FIXED_ARTIFACTS[0].name) + }) { + return Err(collection_error( + "mandatory upload is neither canonical nor the Fastly reproduction", + )); + } + let mut uploads = dependencies.required.iter().collect::>(); + for optional in &dependencies.optional { + match optional { + OptionalBuild::Uploaded { upload, job } => { + if *job + != (ObservedJob { + started: true, + conclusion: Some(JobConclusion::Success), + }) + { + return Err(collection_error( + "optional B claims an upload identity without a successful job", + )); + } + // The optional channel carries only native reproductions. + if upload.role != "B" || upload.asset == FIXED_ARTIFACTS[0].name { + return Err(collection_error( + "optional upload is not a native reproduction", + )); + } + uploads.push(upload); + } + OptionalBuild::NotUploaded(no_upload) => { + if no_upload.outcome().is_none() + || !roles.insert((no_upload.asset.clone(), "B".to_owned())) + { + return Err(collection_error( + "optional B result is not bounded failed/unavailable", + )); + } + absent.push(no_upload.clone()); + } + } + } + for upload in uploads { + if !bounded_upload(upload) + || upload.artifact_id == 0 + || upload.attempt == 0 + || upload.job.is_empty() + || upload.job.len() > 128 + || upload.job.chars().any(char::is_control) + || !matches!(upload.role.as_str(), "A" | "B") + || !lower_hex(&upload.archive_sha256, 64) + || !lower_hex(&upload.recipe_sha256, 64) + || !ids.insert(upload.artifact_id) + || !roles.insert((upload.asset.clone(), upload.role.clone())) + { + return Err(collection_error( + "invalid or reused dependency upload identity", + )); + } + let metadata = parse_json( + &source.metadata(&invocation.repository, upload.artifact_id)?, + Role::Metadata, + ) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + if metadata["id"].as_u64() != Some(upload.artifact_id) + || metadata["name"].as_str() != Some(upload.artifact_name.as_str()) + || metadata["expired"].as_bool() != Some(false) + || metadata["digest"].as_str() + != Some(format!("sha256:{}", upload.archive_sha256).as_str()) + || metadata["workflow_run"]["id"].as_u64() != Some(invocation.run_id) + || metadata["workflow_run"]["head_sha"].as_str() + != Some(invocation.source_commit.as_str()) + || metadata["workflow_run"]["repository_id"].as_u64() != Some(invocation.repository_id) + || metadata["workflow_run"]["head_repository_id"].as_u64() + != Some(invocation.repository_id) + { + return Err(collection_error( + "REST artifact metadata disagrees with current invocation", + )); + } + let downloaded = source.download(&invocation.repository, upload.artifact_id)?; + if downloaded.transfer_warning + || downloaded.archive.len() > ARCHIVE_LIMIT + || digest(&downloaded.archive) != upload.archive_sha256 + { + return Err(collection_error( + "transfer warning or archive digest mismatch", + )); + } + let members = archive::read(&downloaded.archive, &upload.members)?; + transfers.push(Transfer { + upload: upload.clone(), + members, + }); + } + Ok(ValidatedTransfers { + invocation: invocation.clone(), + transfers, + absent, + }) +} + +/// Measures validated transfer data without constructing release authorization. +/// +/// # Errors +/// +/// Rejects missing A, mandatory Fastly B, changed inputs, dishonest observations, +/// failed mandatory gates, or unequal complete final Wasm bytes. +pub(crate) fn diagnose_collection( + transfers: &ValidatedTransfers, +) -> Result> { + records::diagnose(transfers) +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn collection_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Collection).attach(detail.to_string()) +} + +fn bounded_upload(upload: &Upload) -> bool { + bounded(upload.elapsed_seconds, upload.deadline_seconds) +} + +/// Requires a nonzero capped deadline that the observed wait did not exceed. +fn bounded(elapsed_seconds: u64, deadline_seconds: u64) -> bool { + deadline_seconds > 0 && deadline_seconds <= 5400 && elapsed_seconds <= deadline_seconds +} + +fn safe_repository(repository: &str) -> bool { + let parts = repository.split('/').collect::>(); + parts.len() == 2 + && parts.iter().all(|part| { + !part.is_empty() + && part.len() <= 100 + && !matches!(*part, "." | "..") + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) +} diff --git a/crates/trusted-server-release/src/collection/archive.rs b/crates/trusted-server-release/src/collection/archive.rs new file mode 100644 index 0000000000..c204f88127 --- /dev/null +++ b/crates/trusted-server-release/src/collection/archive.rs @@ -0,0 +1,317 @@ +//! 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 super::{ARCHIVE_LIMIT, collection_error}; + +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::Collection))?; + 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)) +} + +pub(super) 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::Collection))?; + 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", + )?; + } +} diff --git a/crates/trusted-server-release/src/collection/producer.rs b/crates/trusted-server-release/src/collection/producer.rs new file mode 100644 index 0000000000..dcc81bfc9c --- /dev/null +++ b/crates/trusted-server-release/src/collection/producer.rs @@ -0,0 +1,361 @@ +//! Flat diagnostic exports from owning build results, without release authority. +//! +//! This candidate format deliberately differs from the strict collector's build +//! record. Runtime acceptance is retained only from its actual owning run; +//! pair claims and qualified workflow identity remain unavailable. +//! [`strict_record`] separately renders the collector's record shape from the +//! same owners; it is not yet an export member. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::json; + +use super::{collection_error, digest}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::recipe::{FastlyRuntimeAccepted, NativeStartupAccepted, RecipeCompleted, copy_artifact}; + +const RECORD: &str = "candidate-build-record.json"; +const COMPOSITION: &str = "composition.json"; +const ARTIFACT: &str = "artifact"; +const INVENTORY: &str = "tsjs-inventory.json"; +const DOCUMENT_LIMIT: u64 = 16 * 1024 * 1024; + +pub(crate) mod strict_record; + +/// Owned flat candidate files, with no signing or trusted dependency authority. +#[derive(Debug)] +pub(crate) struct CandidateBuildExport { + directory: tempfile::TempDir, + root: PathBuf, + members: BTreeMap, +} + +#[derive(Debug)] +struct Member { + size: u64, + sha256: String, +} + +enum RuntimeAcceptance<'a> { + Fastly(&'a FastlyRuntimeAccepted), + Native(&'a NativeStartupAccepted), +} + +impl RuntimeAcceptance<'_> { + fn diagnostic_for( + &self, + completed: &RecipeCompleted, + ) -> Result> { + match self { + Self::Fastly(accepted) => accepted.diagnostic_for(completed), + Self::Native(accepted) => accepted.diagnostic_for(completed), + } + } +} + +impl CandidateBuildExport { + /// Copies measured owning results into a new flat diagnostic handoff. + /// + /// Actual socket observations and an approved-source recipe definition are + /// mandatory. Exact invocation identity remains separate from that shared + /// definition. Single-build ambient observations make no A/B variation claim. + /// The original owner is checked again after the copy. + /// + /// # Errors + /// + /// Rejects missing measured receipts, missing composition, changed owned + /// outputs, unsafe paths, oversized documents or changed copied bytes. + pub(crate) fn from_completed( + completed: &RecipeCompleted, + ) -> Result> { + Self::from_owned(completed, None) + } + + /// Copies a candidate with its actual role-bound final-byte Fastly acceptance. + /// + /// # Errors + /// + /// Rejects another source, role or artifact, missing required build evidence, + /// changed owned files or an oversized diagnostic record. + pub(crate) fn with_fastly_runtime( + completed: &RecipeCompleted, + accepted: &FastlyRuntimeAccepted, + ) -> Result> { + Self::from_owned(completed, Some(RuntimeAcceptance::Fastly(accepted))) + } + + /// Copies a candidate with its actual role-bound native startup acceptance. + /// + /// # Errors + /// + /// Rejects another source, role, selected CLI package or final artifact, + /// missing build evidence, changed owned files or oversized diagnostics. + pub(crate) fn with_native_startup( + completed: &RecipeCompleted, + accepted: &NativeStartupAccepted, + ) -> Result> { + Self::from_owned(completed, Some(RuntimeAcceptance::Native(accepted))) + } + + fn from_owned( + completed: &RecipeCompleted, + accepted: Option>, + ) -> Result> { + let definition = completed.recipe_definition()?; + let denial = completed.denial_receipt()?; + let composition = completed.composition_export()?; + let javascript = completed.javascript_contributions()?; + let artifact = completed.artifact(); + let observed = completed.ambient().observed(); + let runtime = accepted + .map(|accepted| accepted.diagnostic_for(completed)) + .transpose()?; + let gaps = [ + "runtime-acceptance-not-exported", + "ambient-pair-claims-not-exported", + "composition-coverage-incomplete", + "bootstrap-not-authenticated", + "workflow-producer-authority-not-established", + ] + .into_iter() + .filter(|gap| *gap != "runtime-acceptance-not-exported" || runtime.is_none()) + .collect::>(); + let [inventory, settings, js_tools] = completed.js_digests(); + let record = serde_json::to_vec(&json!({ + "domain": "trusted-server.release.candidate-build-diagnostic", + "schema_version": 1, + "release_qualification": false, + "source_commit": completed.commit().as_str(), + "asset": artifact.name(), "role": completed.role().spec_label(), + "recipe_sha256": definition.sha256(), + "recipe_definition": definition.descriptor(), + "invocation_sha256": completed.plan_digest(), + "artifact": {"member": ARTIFACT, "sha256": artifact.sha256(), "size": artifact.size()}, + "composition": {"member": COMPOSITION, "sha256": composition.sha256(), "summary": composition.summary()}, + "javascript_inventory": {"member": INVENTORY, "sha256": javascript.inventory_sha256(), + "size": javascript.raw_inventory().len()}, + "inputs": {"cargo_lock": completed.cargo_lock_sha256(), + "materials": completed.materials_digest(), "rust_tools": completed.rust_tools_digest(), + "js_inventory": inventory, "js_settings": settings, "js_tools": js_tools}, + "socket_observations": denial, + "runtime_acceptance": runtime, + "ambient_observation": { + "umask": observed.umask, "jobs": observed.jobs, + "host_locale": observed.host_locale, "host_timezone": observed.host_timezone, + "child_locale": observed.child_locale, "child_timezone": observed.child_timezone, + "execution_uid": observed.execution_uid, "checkout_root": observed.checkout_root, + "started_at_seconds": observed.started_at_seconds, + }, + "elapsed_seconds": completed.elapsed().as_secs(), + "gaps": gaps, + })) + .map_err(collection_error)?; + if record.len() as u64 > DOCUMENT_LIMIT { + return Err(collection_error("candidate build record exceeds its bound")); + } + let directory = tempfile::tempdir().map_err(collection_error)?; + let root = fs::canonicalize(directory.path()).map_err(collection_error)?; + let copied = copy_artifact( + artifact.path(), + completed.rehash_for_handoff()?, + &root, + ARTIFACT, + artifact.size(), + )?; + if copied.size() != artifact.size() || copied.sha256() != artifact.sha256() { + return Err(collection_error("candidate artifact copy changed")); + } + let mut members = BTreeMap::from([( + ARTIFACT.to_owned(), + Member { + size: copied.size(), + sha256: copied.sha256().to_owned(), + }, + )]); + for (name, bytes) in [ + (COMPOSITION, composition.bytes()), + (INVENTORY, javascript.raw_inventory()), + (RECORD, record.as_slice()), + ] { + write_new(&root.join(name), bytes)?; + members.insert( + name.to_owned(), + Member { + size: bytes.len() as u64, + sha256: digest(bytes), + }, + ); + } + completed.rehash_for_handoff()?; + let export = Self { + directory, + root, + members, + }; + export.rehash_for_handoff()?; + Ok(export) + } + + /// Returns the flat directory after checking every exact retained member. + /// + /// The owned files survive recipe/source/target cleanup. This verifies their + /// integrity only and cannot authenticate a workflow upload or release. + /// + /// # Errors + /// + /// Rejects missing, extra, changed, aliased, linked or oversized members. + pub(crate) fn rehash_for_handoff(&self) -> Result<&Path, Report> { + let root = fs::canonicalize(self.directory.path()).map_err(collection_error)?; + if root != self.root { + return Err(collection_error("candidate export directory changed")); + } + let names = fs::read_dir(&self.root) + .map_err(collection_error)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(collection_error)?; + if names.len() != self.members.len() + || names.iter().any(|name| { + !name + .to_str() + .is_some_and(|name| self.members.contains_key(name)) + }) + { + return Err(collection_error("candidate export members changed")); + } + for (name, expected) in &self.members { + let measured = + hash_bounded_with_check(&self.root.join(name), expected.size, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + if measured.size != expected.size || measured.sha256 != expected.sha256 { + return Err(collection_error("candidate export bytes changed")); + } + } + Ok(&self.root) + } +} + +/// Writes `bytes` to a new private file, refusing an existing path or link. +/// +/// # Errors +/// +/// Rejects an existing path, a symlink at `path` and any write or sync failure. +pub(crate) fn write_new(path: &Path, bytes: &[u8]) -> Result<(), Report> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + .map_err(collection_error)?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(collection_error) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::*; + + // These files exercise diagnostic-file integrity only. They contain no + // measured execution receipts and cannot enter the owning producer path. + fn diagnostic_files() -> CandidateBuildExport { + let directory = tempfile::tempdir().expect("should create diagnostic storage"); + let root = fs::canonicalize(directory.path()).expect("should resolve diagnostic storage"); + let mut members = BTreeMap::new(); + for (name, bytes) in [ + (ARTIFACT, b"fictional executable".as_slice()), + (COMPOSITION, b"fictional composition".as_slice()), + (INVENTORY, b"fictional original inventory".as_slice()), + (RECORD, b"fictional record".as_slice()), + ] { + write_new(&root.join(name), bytes).expect("should retain diagnostic member"); + members.insert( + name.to_owned(), + Member { + size: bytes.len() as u64, + sha256: digest(bytes), + }, + ); + } + CandidateBuildExport { + directory, + root, + members, + } + } + + #[test] + fn flat_diagnostic_members_survive_without_original_build_roots() { + let export = diagnostic_files(); + let root = export + .rehash_for_handoff() + .expect("should verify retained diagnostic files"); + assert_eq!( + fs::read_dir(root).expect("should list export").count(), + 4, + "should retain artifact, original inventory, composition and diagnostic record" + ); + assert!( + root.join("tsjs-inventory.json").is_file(), + "should retain original JS inventory" + ); + } + + #[test] + fn each_changed_export_member_blocks_handoff() { + for name in [ARTIFACT, COMPOSITION, INVENTORY, RECORD] { + let export = diagnostic_files(); + fs::write(export.root.join(name), b"changed").expect("should change one member"); + assert!( + export.rehash_for_handoff().is_err(), + "should reject changed {name}" + ); + } + } + + #[test] + fn missing_and_extra_export_members_block_handoff() { + let missing = diagnostic_files(); + fs::remove_file(missing.root.join(COMPOSITION)).expect("should remove composition"); + assert!( + missing.rehash_for_handoff().is_err(), + "should reject missing member" + ); + let extra = diagnostic_files(); + fs::write(extra.root.join("unexpected"), b"extra").expect("should add extra member"); + assert!( + extra.rehash_for_handoff().is_err(), + "should reject undeclared member" + ); + } + + #[test] + fn linked_export_members_block_handoff_even_when_bytes_match() { + for hard_link in [false, true] { + let export = diagnostic_files(); + let outside = tempfile::tempdir().expect("should create separate file storage"); + let path = outside.path().join("same-bytes"); + fs::copy(export.root.join(ARTIFACT), &path).expect("should copy matching bytes"); + fs::remove_file(export.root.join(ARTIFACT)).expect("should remove original member"); + if hard_link { + fs::hard_link(&path, export.root.join(ARTIFACT)) + .expect("should hard link matching bytes"); + } else { + symlink(&path, export.root.join(ARTIFACT)).expect("should alias matching bytes"); + } + assert!( + export.rehash_for_handoff().is_err(), + "should reject linked artifact with matching bytes" + ); + } + } +} diff --git a/crates/trusted-server-release/src/collection/producer/strict_record.rs b/crates/trusted-server-release/src/collection/producer/strict_record.rs new file mode 100644 index 0000000000..1cdbe62659 --- /dev/null +++ b/crates/trusted-server-release/src/collection/producer/strict_record.rs @@ -0,0 +1,329 @@ +//! Renders the strict collector's `build-record.json` from owning build results. +//! +//! [`super::CandidateBuildExport`] keeps its own diagnostic record. This +//! renderer states the same owned observations in the collector's schema, so +//! the collector's unchanged checks can run on producer output. It grants no +//! authority: the collector still binds identity fields to trusted upload +//! outputs, and the record cannot reconstruct any local proof type. +//! `composition_sha256` names the producer's own sealed composition receipt, +//! which the collector parses strictly and binds to this record. +//! +//! Remaining gaps before a real producer transfer can pass the collector: +//! +//! - Job identity: the producing job's workflow job ID and attempt are not +//! owned by the completed build, so the caller must supply [`ProducerJob`]. +//! - Native startup: the collector schema allows no runtime block for native +//! builds, so native startup acceptance cannot be represented. + +use std::collections::BTreeSet; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::{DOCUMENT_LIMIT, collection_error}; +use crate::boundary::DenialReceipt; +use crate::error::ReleaseError; +use crate::policy::FIXED_ARTIFACTS; +use crate::recipe::{ + AmbientClaim, Dimension, FastlyRuntimeAccepted, ObservedAmbient, RecipeCompleted, Role, +}; + +/// Member name the strict collector reads from one build transfer. +pub(crate) const STRICT_RECORD: &str = "build-record.json"; +/// Longest workflow job ID in bytes, as the collector's upload identity check bounds it. +const JOB_LIMIT: usize = 128; + +type Result = std::result::Result>; + +/// The producing job's workflow job ID and run attempt, as its runner reports them. +/// +/// This is self-reported context, not authority: the collector requires it to +/// equal the trusted dependency upload identity of the same transfer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ProducerJob { + job: String, + attempt: u64, +} + +impl ProducerJob { + /// Accepts exactly the job IDs and attempts the collector's upload check accepts. + /// + /// # Errors + /// + /// Rejects an empty job ID, one over [`JOB_LIMIT`] bytes or holding a + /// control character, and attempt `0`. + pub(crate) fn new(job: &str, attempt: u64) -> Result { + if job.is_empty() + || job.len() > JOB_LIMIT + || job.chars().any(char::is_control) + || attempt == 0 + { + return Err(collection_error( + "producer job identity is empty, unbounded or has no attempt", + )); + } + Ok(Self { + job: job.to_owned(), + attempt, + }) + } +} + +/// Approved input digests the collector compares across a pair. +#[derive(Clone, Debug)] +struct InputDigests { + cargo_lock: String, + materials: String, + rust_tools: String, + js_settings: String, + js_tools: String, +} + +/// Measured socket controls, boundary identity and exact invocation of one build. +#[derive(Clone, Debug)] +struct SocketControls { + /// Boundary instance the measured denial receipt names. + fingerprint: String, + /// Boundary instance that executed the build. Instance identity only: it + /// includes a fresh per-instance nonce and says nothing about the invocation. + execution_fingerprint: String, + /// Exact invocation digest: program, arguments including jobs, + /// environment, checkout-root working directory and umask. + invocation_digest: String, + control_exit: i32, + control_accepts: u32, + probe_exit: i32, + probe_errno: i32, + probe_accepts: u32, +} + +impl SocketControls { + fn from_owned(completed: &RecipeCompleted, receipt: &DenialReceipt) -> Result { + let count = |accepts: usize| u32::try_from(accepts).map_err(collection_error); + let (control_exit, control_accepts) = receipt.control(); + let (probe_exit, probe_accepts) = receipt.probe(); + Ok(Self { + fingerprint: receipt.fingerprint().to_owned(), + execution_fingerprint: hex::encode(completed.boundary_fingerprint()), + invocation_digest: completed.plan_digest().to_owned(), + control_exit, + control_accepts: count(control_accepts)?, + probe_exit, + probe_errno: receipt.denied_errno(), + probe_accepts: count(probe_accepts)?, + }) + } +} + +/// Final-byte Fastly runtime facts taken from this build's own acceptance. +#[derive(Clone, Debug)] +struct RuntimeFacts { + role: Role, + source_commit: String, + final_wasm_sha256: String, + final_wasm_size: u64, + publisher_status: u16, + viceroy_version: String, +} + +/// One build's owned observations, in the strict collector's record shape. +#[derive(Clone, Debug)] +pub(crate) struct StrictBuildRecord { + source_commit: String, + recipe_sha256: String, + asset: String, + role: Role, + job: ProducerJob, + inputs: InputDigests, + inventory_sha256: String, + artifact_sha256: String, + artifact_size: u64, + composition_sha256: String, + ambient: ObservedAmbient, + boundary: SocketControls, + runtime: Option, +} + +impl StrictBuildRecord { + /// Collects the strict record facts from one completed build. + /// + /// A Fastly build requires its own role-bound final-byte acceptance, which + /// is checked against this build before use. A native build carries none: + /// the collector schema has no native startup block, so native startup + /// acceptance is not represented here. + /// + /// # Errors + /// + /// Rejects missing measured socket receipts, recipe definition, composition + /// or JS evidence, changed owned outputs, a missing Fastly acceptance, an + /// acceptance for another build, and any acceptance on a native build. + pub(crate) fn from_owned( + completed: &RecipeCompleted, + accepted: Option<&FastlyRuntimeAccepted>, + job: ProducerJob, + ) -> Result { + let definition = completed.recipe_definition()?; + let denial = completed.denial_receipt()?; + let composition = completed.composition_export()?; + let javascript = completed.javascript_contributions()?; + let artifact = completed.artifact(); + let fastly = completed.asset().artifact().name == FIXED_ARTIFACTS[0].name; + let runtime = match (fastly, accepted) { + (true, Some(accepted)) => { + accepted.diagnostic_for(completed)?; + let observed = accepted.observed(); + Some(RuntimeFacts { + role: accepted.role(), + source_commit: accepted.source_commit().as_str().to_owned(), + final_wasm_sha256: observed.artifact_sha256().to_owned(), + final_wasm_size: observed.artifact_size(), + publisher_status: observed.publisher().status, + viceroy_version: observed.viceroy_version().to_owned(), + }) + } + (true, None) => { + return Err(collection_error( + "a Fastly strict record requires this build's own runtime acceptance", + )); + } + (false, Some(_)) => { + return Err(collection_error( + "a native strict record cannot carry a Fastly runtime acceptance", + )); + } + (false, None) => None, + }; + let [_, js_settings, js_tools] = completed.js_digests(); + let record = Self { + source_commit: completed.commit().as_str().to_owned(), + recipe_sha256: definition.sha256().to_owned(), + asset: artifact.name().to_owned(), + role: completed.role(), + job, + inputs: InputDigests { + cargo_lock: completed.cargo_lock_sha256().to_owned(), + materials: completed.materials_digest().to_owned(), + rust_tools: completed.rust_tools_digest().to_owned(), + js_settings: js_settings.to_owned(), + js_tools: js_tools.to_owned(), + }, + inventory_sha256: javascript.inventory_sha256().to_owned(), + artifact_sha256: artifact.sha256().to_owned(), + artifact_size: artifact.size(), + composition_sha256: composition.sha256().to_owned(), + ambient: completed.ambient().observed().clone(), + boundary: SocketControls::from_owned(completed, denial)?, + runtime, + }; + completed.rehash_for_handoff()?; + Ok(record) + } + + /// Renders the exact strict collector record bytes. + /// + /// # Errors + /// + /// Rejects a non-UTF-8 checkout root or a record exceeding the metadata bound. + pub(crate) fn to_bytes(&self) -> Result> { + let bytes = serde_json::to_vec(&self.to_value()?).map_err(collection_error)?; + if bytes.len() as u64 > DOCUMENT_LIMIT { + return Err(collection_error("strict build record exceeds its bound")); + } + Ok(bytes) + } + + fn to_value(&self) -> Result { + let ambient = &self.ambient; + let checkout_root = ambient + .checkout_root + .to_str() + .ok_or_else(|| collection_error("observed checkout root is not UTF-8"))?; + // The compiled placement policy, not a pair observation: a single build + // cannot see its counterpart, and the collector's pair comparison + // verifies every varied dimension from both observed records. + let claim = AmbientClaim::standard(); + let boundary = &self.boundary; + // A denial receipt exists only after the confined probe was denied, so + // its probe run is the confined one. + let runtime = self.runtime.as_ref().map(|runtime| { + // Acceptance exists only after the publisher page held this run's + // origin marker exactly once and exactly one injected script. + json!({ + "role": runtime.role.spec_label(), + "source_commit": runtime.source_commit, + "final_wasm_sha256": runtime.final_wasm_sha256, + "final_wasm_size": runtime.final_wasm_size, + "publisher_status": runtime.publisher_status, + "injected_scripts": 1, + "origin_marker_matched": true, + "viceroy_version": runtime.viceroy_version, + }) + }); + Ok(json!({ + "schema_version": 1, + "source_commit": self.source_commit, + "recipe_sha256": self.recipe_sha256, + "asset": self.asset, + "role": self.role.spec_label(), + "job": self.job.job, + "attempt": self.job.attempt, + "inputs": { + "cargo_lock": self.inputs.cargo_lock, + "materials": self.inputs.materials, + "rust_tools": self.inputs.rust_tools, + "js_settings": self.inputs.js_settings, + "js_tools": self.inputs.js_tools, + }, + "inventory_sha256": self.inventory_sha256, + "artifact_sha256": self.artifact_sha256, + "artifact_size": self.artifact_size, + "composition_sha256": self.composition_sha256, + "ambient": { + "umask": ambient.umask, + "jobs": ambient.jobs, + "checkout_root": checkout_root, + "started_at_seconds": ambient.started_at_seconds, + "execution_uid": ambient.execution_uid, + "host_locale": ambient.host_locale, + "host_timezone": ambient.host_timezone, + "child_locale": ambient.child_locale, + "child_timezone": ambient.child_timezone, + }, + "claim": { + "varied": dimension_names(&claim.varied), + "normalized": dimension_names(&claim.normalized), + "unsupported": dimension_names(&claim.unsupported), + }, + "boundary": { + "fingerprint": boundary.fingerprint, + "execution_fingerprint": boundary.execution_fingerprint, + "invocation_digest": boundary.invocation_digest, + "confined": true, + "control_exit": boundary.control_exit, + "control_accepts": boundary.control_accepts, + "probe_exit": boundary.probe_exit, + "probe_errno": boundary.probe_errno, + "probe_accepts": boundary.probe_accepts, + }, + "runtime": runtime, + })) + } +} + +fn dimension_names(dimensions: &BTreeSet) -> Vec<&'static str> { + dimensions + .iter() + .map(|dimension| match dimension { + Dimension::Umask => "umask", + Dimension::Jobs => "jobs", + Dimension::CheckoutRoot => "checkout_root", + Dimension::StartTime => "start_time", + Dimension::Locale => "locale", + Dimension::Timezone => "timezone", + Dimension::ExecutionUser => "execution_user", + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/collection/producer/strict_record/tests.rs b/crates/trusted-server-release/src/collection/producer/strict_record/tests.rs new file mode 100644 index 0000000000..f413277bc8 --- /dev/null +++ b/crates/trusted-server-release/src/collection/producer/strict_record/tests.rs @@ -0,0 +1,516 @@ +use std::ffi::OsStr; +use std::os::unix::ffi::OsStrExt as _; +use std::path::PathBuf; + +use serde_json::json; + +use super::*; +use crate::collection::tests::{cohort_fixture, composition_receipt}; +use crate::collection::{ValidatedTransfers, diagnose_collection, digest}; + +// Fictional facts mirroring what a completed build owns. Pure tests cannot +// construct measured socket receipts, composition receipts or runtime +// acceptance, so these exercise the rendering and the unchanged collector only. +fn observed_ambient(role: Role) -> ObservedAmbient { + let (umask, jobs, root, started, locale, timezone) = match role { + Role::Canonical => (0o022, 4, "/external-release-A", 100, "C", "UTC"), + Role::Reproduction => ( + 0o027, + 2, + "/external-release-B", + 101, + "en_US.UTF-8", + "Pacific/Auckland", + ), + }; + ObservedAmbient { + umask, + jobs, + host_locale: locale.to_owned(), + host_timezone: timezone.to_owned(), + child_locale: "C".to_owned(), + child_timezone: "UTC".to_owned(), + execution_uid: 1000, + checkout_root: PathBuf::from(root), + started_at_seconds: started, + } +} + +/// Producer facts for one fixture transfer, agreeing with its trusted upload. +fn producer_record(fixture: &ValidatedTransfers, index: usize) -> StrictBuildRecord { + let transfer = &fixture.transfers[index]; + let upload = &transfer.upload; + let role = match upload.role.as_str() { + "A" => Role::Canonical, + "B" => Role::Reproduction, + other => panic!("should use a known fixture role, got {other}"), + }; + let artifact = &transfer.members["artifact"]; + let (fingerprint, invocation_digest) = match role { + Role::Canonical => ("3".repeat(64), "5".repeat(64)), + Role::Reproduction => ("4".repeat(64), "6".repeat(64)), + }; + let runtime = (upload.asset == FIXED_ARTIFACTS[0].name).then(|| RuntimeFacts { + role, + source_commit: fixture.invocation.source_commit.clone(), + final_wasm_sha256: digest(artifact), + final_wasm_size: artifact.len() as u64, + publisher_status: 200, + viceroy_version: "0.21.0".to_owned(), + }); + StrictBuildRecord { + source_commit: fixture.invocation.source_commit.clone(), + recipe_sha256: upload.recipe_sha256.clone(), + asset: upload.asset.clone(), + role, + job: ProducerJob::new(&upload.job, upload.attempt) + .expect("should accept the fixture job identity"), + inputs: InputDigests { + cargo_lock: "d".repeat(64), + materials: "e".repeat(64), + rust_tools: "f".repeat(64), + js_settings: "1".repeat(64), + js_tools: "2".repeat(64), + }, + inventory_sha256: digest(&transfer.members["tsjs-inventory.json"]), + artifact_sha256: digest(artifact), + artifact_size: artifact.len() as u64, + composition_sha256: digest(&transfer.members["composition.json"]), + ambient: observed_ambient(role), + boundary: SocketControls { + fingerprint: fingerprint.clone(), + execution_fingerprint: fingerprint, + invocation_digest, + control_exit: 0, + control_accepts: 1, + probe_exit: 3, + probe_errno: 1, + probe_accepts: 0, + }, + runtime, + } +} + +fn install(fixture: &mut ValidatedTransfers, index: usize, record: &StrictBuildRecord) { + let bytes = record + .to_bytes() + .expect("should render the strict build record"); + fixture.transfers[index] + .members + .insert(STRICT_RECORD.to_owned(), bytes); +} + +/// The collector fixture cohort with every build record rendered by the producer. +fn producer_cohort() -> ValidatedTransfers { + let mut fixture = cohort_fixture(); + for index in 0..fixture.transfers.len() { + let record = producer_record(&fixture, index); + install(&mut fixture, index, &record); + } + fixture +} + +#[test] +fn producer_rendered_records_pass_the_unchanged_strict_collector() { + let fixture = producer_cohort(); + for (index, transfer) in fixture.transfers.iter().enumerate() { + assert_eq!( + transfer.members[STRICT_RECORD], + producer_record(&fixture, index) + .to_bytes() + .expect("should render again"), + "should carry the producer rendering for transfer {index}" + ); + } + let diagnostic = + diagnose_collection(&fixture).expect("should accept every producer-rendered record"); + assert_eq!( + diagnostic.canonical.len(), + 4, + "should retain all four canonical artifacts from producer records" + ); +} + +#[test] +fn rendered_claim_and_runtime_follow_owned_policy_and_acceptance() { + let fixture = cohort_fixture(); + let rendered: Value = serde_json::from_slice( + &producer_record(&fixture, 0) + .to_bytes() + .expect("should render Fastly record"), + ) + .expect("should parse rendered record"); + assert_eq!( + rendered["claim"], + json!({ + "varied": ["umask", "jobs", "checkout_root", "start_time"], + "normalized": ["locale", "timezone"], + "unsupported": ["execution_user"], + }), + "should render the compiled placement in collector names" + ); + assert_eq!( + ( + &rendered["runtime"]["injected_scripts"], + &rendered["runtime"]["origin_marker_matched"], + &rendered["boundary"]["confined"], + ), + (&json!(1), &json!(true), &json!(true)), + "should state only facts implied by acceptance and denial receipts" + ); + let native: Value = serde_json::from_slice( + &producer_record(&fixture, 2) + .to_bytes() + .expect("should render native record"), + ) + .expect("should parse native record"); + assert!( + native["runtime"].is_null(), + "should render no runtime block for a native build" + ); +} + +type Mutation = fn(&mut StrictBuildRecord); + +#[test] +fn each_changed_producer_fact_is_rejected_by_the_collector() { + let cases: [(&str, usize, Mutation); 22] = [ + ("missing Fastly runtime", 0, |record| record.runtime = None), + ("runtime of the other role", 0, |record| { + if let Some(runtime) = &mut record.runtime { + runtime.role = Role::Reproduction; + } + }), + ("runtime for other bytes", 0, |record| { + if let Some(runtime) = &mut record.runtime { + runtime.final_wasm_size += 1; + } + }), + ("failed publisher status", 0, |record| { + if let Some(runtime) = &mut record.runtime { + runtime.publisher_status = 500; + } + }), + ("unpinned Viceroy", 0, |record| { + if let Some(runtime) = &mut record.runtime { + runtime.viceroy_version = "0.16.0".to_owned(); + } + }), + ("native runtime block", 2, |record| { + record.runtime = Some(RuntimeFacts { + role: Role::Canonical, + source_commit: record.source_commit.clone(), + final_wasm_sha256: record.artifact_sha256.clone(), + final_wasm_size: record.artifact_size, + publisher_status: 200, + viceroy_version: "0.21.0".to_owned(), + }); + }), + ("unnormalized child locale", 0, |record| { + record.ambient.child_locale = "en_US.UTF-8".to_owned(); + }), + ("profile umask of another role", 0, |record| { + record.ambient.umask = 0o027; + }), + ("relative checkout root", 0, |record| { + record.ambient.checkout_root = PathBuf::from("external-release-A"); + }), + ("execution outside the measured boundary", 0, |record| { + record.boundary.execution_fingerprint = "9".repeat(64); + }), + ("malformed invocation digest", 0, |record| { + record.boundary.invocation_digest = "5".repeat(63); + }), + ("probe errno other than a denial", 0, |record| { + record.boundary.probe_errno = 111; + }), + ("failed socket control", 0, |record| { + record.boundary.control_accepts = 0; + }), + ("reproduction reusing the canonical boundary", 1, |record| { + record.boundary.fingerprint = "3".repeat(64); + record.boundary.execution_fingerprint = "3".repeat(64); + }), + ( + "reproduction reusing the canonical invocation", + 1, + |record| { + record.boundary.invocation_digest = "5".repeat(64); + }, + ), + ("reproduction with the canonical start time", 1, |record| { + record.ambient.started_at_seconds = 100; + }), + ("another job", 0, |record| { + record.job = ProducerJob::new("another-job", 1).expect("should accept job"); + }), + ("another attempt", 0, |record| { + record.job = ProducerJob::new(&record.job.job.clone(), 2).expect("should accept job"); + }), + ("pair input digest", 0, |record| { + record.inputs.cargo_lock = "9".repeat(64); + }), + ("composition digest", 0, |record| { + record.composition_sha256 = "9".repeat(64); + }), + ("role of another upload", 0, |record| { + record.role = Role::Reproduction; + }), + ("inventory digest", 0, |record| { + record.inventory_sha256 = "9".repeat(64); + }), + ]; + for (name, index, mutate) in cases { + let mut fixture = producer_cohort(); + let mut record = producer_record(&fixture, index); + mutate(&mut record); + install(&mut fixture, index, &record); + let error = format!( + "{:?}", + diagnose_collection(&fixture).expect_err("should reject a changed producer record") + ); + assert!( + error.contains(expected_rejection(name)), + "should reject producer record with {name} at its own gate: {error}" + ); + } +} + +/// The collector gate each changed producer fact must reach. +fn expected_rejection(name: &str) -> &'static str { + match name { + "missing Fastly runtime" => "missing final-byte runtime proof", + "runtime of the other role" + | "runtime for other bytes" + | "failed publisher status" + | "unpinned Viceroy" => "runtime diagnostic is not bound", + "native runtime block" => "native transfer carries a Fastly runtime", + "unnormalized child locale" | "relative checkout root" => { + "child normalization or observed checkout root" + } + "profile umask of another role" => "observed ambient differs from selected role profile", + "execution outside the measured boundary" + | "malformed invocation digest" + | "probe errno other than a denial" + | "failed socket control" => "missing/failed socket controls", + "reproduction reusing the canonical boundary" => "A/B share one boundary instance", + "reproduction reusing the canonical invocation" => "A/B share one exact invocation", + "reproduction with the canonical start time" => "supported A/B variation is missing", + "another job" | "another attempt" | "role of another upload" => { + "build record differs from trusted dependency identity" + } + "pair input digest" => "A/B build inputs or recipe differ", + "composition digest" => "composition document digest changed", + "inventory digest" => "shared JS inventory changed", + other => panic!("should name an expected gate for {other}"), + } +} + +/// Installs a sealed receipt and the producer record naming its exact digest. +fn install_receipt(fixture: &mut ValidatedTransfers, index: usize, receipt: Vec) { + let mut record = producer_record(fixture, index); + record.composition_sha256 = digest(&receipt); + fixture.transfers[index] + .members + .insert("composition.json".to_owned(), receipt); + install(fixture, index, &record); +} + +/// The producer's receipt shape for one fixture transfer, with its own bindings. +fn producer_receipt(fixture: &ValidatedTransfers, index: usize) -> Value { + let transfer = &fixture.transfers[index]; + let target = FIXED_ARTIFACTS + .iter() + .find(|asset| asset.name == transfer.upload.asset) + .expect("should use a fixed fixture asset") + .target + .triple(); + serde_json::from_slice(&composition_receipt( + target, + &digest(&transfer.members["artifact"]), + &fixture.invocation.source_commit, + &digest(&transfer.members["tsjs-inventory.json"]), + )) + .expect("should parse producer receipt") +} + +#[test] +fn producer_composition_receipt_passes_the_strict_collector() { + // The producer's `composition.json` is its sealed normalized receipt + // (`metadata_plan_sha256`, `tree_plan_sha256`, `observations`, + // `composition`). Each record's `composition_sha256` names its exact bytes. + let mut fixture = producer_cohort(); + for index in 0..fixture.transfers.len() { + let receipt = serde_json::to_vec(&producer_receipt(&fixture, index)) + .expect("should serialize producer receipt"); + install_receipt(&mut fixture, index, receipt); + } + for transfer in &fixture.transfers { + let receipt: Value = serde_json::from_slice(&transfer.members["composition.json"]) + .expect("should parse installed receipt"); + let keys = receipt + .as_object() + .expect("should be an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + [ + "composition", + "metadata_plan_sha256", + "observations", + "tree_plan_sha256" + ], + "should carry exactly the producer receipt members" + ); + } + let diagnostic = + diagnose_collection(&fixture).expect("should accept producer records and receipts"); + assert_eq!( + diagnostic.canonical.len(), + 4, + "should retain all four canonical artifacts from producer receipts" + ); +} + +/// Changes one receipt fact, given the dependency and workspace package IDs. +type ReceiptMutation = fn(&mut Value, &str, &str); + +#[test] +fn each_changed_producer_receipt_fact_is_rejected_by_the_collector() { + let dependency = "registry+https://registry.example.com/index#example-dep@1.0.0"; + let workspace = "path+file:///workspace/crates/example-app#0.1.0"; + let cases: [(&str, ReceiptMutation, &str); 9] = [ + ( + "another artifact", + |receipt, _, _| { + receipt["composition"]["javascript"]["artifact_sha256"] = json!("9".repeat(64)); + }, + "composition receipt is not bound to the final artifact", + ), + ( + "another source", + |receipt, _, workspace| { + receipt["composition"]["package_origins"]["packages"][workspace]["origin"]["source_commit"] = + json!("9".repeat(40)); + }, + "composition receipt is not bound to the approved source", + ), + ( + "another inventory", + |receipt, _, _| { + receipt["composition"]["javascript"]["inventory_sha256"] = json!("9".repeat(64)); + }, + "composition receipt is not bound to the shared JS inventory", + ), + ( + "malformed plan digest", + |receipt, _, _| { + receipt["metadata_plan_sha256"] = json!("A".repeat(64)); + }, + "composition receipt digests are malformed", + ), + ( + "malformed origin digest", + |receipt, dependency, _| { + receipt["composition"]["package_origins"]["packages"][dependency]["prepared_manifest_sha256"] = + json!("b2".repeat(31)); + }, + "composition receipt package origins are malformed or ambiguous", + ), + ( + "empty observations", + |receipt, _, _| { + receipt["observations"]["units"] = json!([]); + }, + "composition receipt has no compiled units", + ), + ( + "no package origins", + |receipt, _, _| { + receipt["observations"]["packages"] = json!([]); + receipt["composition"]["package_origins"]["packages"] = json!({}); + }, + "composition receipt package origins are malformed or ambiguous", + ), + ( + "extra field", + |receipt, _, _| { + receipt["composition"]["coverage_complete"] = json!(true); + }, + "unknown field", + ), + ( + "unjoined dependency", + |receipt, dependency, _| { + receipt["composition"]["package_origins"]["packages"] + .as_object_mut() + .expect("should find package origins") + .remove(dependency); + }, + "composition receipt package origins are malformed or ambiguous", + ), + ]; + for (name, mutate, expected) in cases { + for index in [0, 2] { + let mut fixture = producer_cohort(); + let mut receipt = producer_receipt(&fixture, index); + mutate(&mut receipt, dependency, workspace); + let bytes = serde_json::to_vec(&receipt).expect("should serialize changed receipt"); + install_receipt(&mut fixture, index, bytes); + let error = format!( + "{:?}", + diagnose_collection(&fixture).expect_err("should reject a changed receipt") + ); + assert!( + error.contains(expected), + "should reject receipt with {name} for transfer {index}: {error}" + ); + } + } +} + +#[test] +fn producer_job_identity_matches_the_collector_upload_check_and_checkout_root_is_bounded() { + // The collector accepts a non-empty job ID of at most 128 bytes without + // control characters, and a positive attempt; the producer mirrors it. + let longest = "j".repeat(128); + for job in [ + "fastly-a", + "candidate (macos-14)", + "kandidat-ü", + longest.as_str(), + ] { + assert!( + ProducerJob::new(job, 1).is_ok(), + "should accept collector-valid job {job:?}" + ); + } + let oversized = "j".repeat(129); + for (job, attempt) in [ + ("", 1), + ("fastly-a", 0), + ("fastly-a\n", 1), + ("fastly\u{7f}a", 1), + ("fastly\u{85}a", 1), + (oversized.as_str(), 1), + ] { + assert!( + ProducerJob::new(job, attempt).is_err(), + "should reject collector-invalid job {job:?} attempt {attempt}" + ); + } + let mut fixture = producer_cohort(); + fixture.transfers[0].upload.job = "candidate (macos-14)".to_owned(); + let mut record = producer_record(&fixture, 0); + record.job = ProducerJob::new("candidate (macos-14)", 1).expect("should accept spaced job"); + install(&mut fixture, 0, &record); + diagnose_collection(&fixture).expect("should bind a spaced job ID the collector accepts"); + let mut record = producer_record(&cohort_fixture(), 0); + record.ambient.checkout_root = PathBuf::from(OsStr::from_bytes(b"/external-\xff")); + assert!( + record.to_bytes().is_err(), + "should reject a checkout root the collector schema cannot state" + ); +} diff --git a/crates/trusted-server-release/src/collection/records.rs b/crates/trusted-server-release/src/collection/records.rs new file mode 100644 index 0000000000..d8d1703a52 --- /dev/null +++ b/crates/trusted-server-release/src/collection/records.rs @@ -0,0 +1,583 @@ +//! Strict imported diagnostics. None of these structs is a local execution proof. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path}; + +use error_stack::Report; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use self::receipt::{ReceiptBinding, check_receipt}; +use super::{Transfer, ValidatedTransfers, collection_error, digest}; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::inspect::inspect_wasm; +use crate::policy::FIXED_ARTIFACTS; + +mod receipt; + +/// A bounded native reproduction measurement. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Outcome { + Matched, + Mismatched, + Failed, + Unavailable, +} + +/// The selected profile belongs to the trusted dependency capability. +#[derive(Clone, Debug)] +pub(super) struct RoleProfile { + umask: u32, + jobs: u32, + host_locale: String, + host_timezone: String, +} + +impl RoleProfile { + #[cfg(test)] + pub(super) fn fixtures() -> [Self; 2] { + [ + Self { + umask: 0o022, + jobs: 4, + host_locale: "C".to_owned(), + host_timezone: "UTC".to_owned(), + }, + Self { + umask: 0o027, + jobs: 2, + host_locale: "en_US.UTF-8".to_owned(), + host_timezone: "Pacific/Auckland".to_owned(), + }, + ] + } + + /// Selects an arbitrary fictional profile so pair checks can be isolated. + #[cfg(test)] + pub(super) fn for_tests(umask: u32, jobs: u32, host_locale: &str, host_timezone: &str) -> Self { + Self { + umask, + jobs, + host_locale: host_locale.to_owned(), + host_timezone: host_timezone.to_owned(), + } + } +} + +/// Candidate data only; no field authorizes signing or deployment. +#[derive(Debug)] +pub(crate) struct CollectionDiagnostic { + /// Exact complete canonical artifact bytes. + pub(crate) canonical: BTreeMap>, + /// Native B outcomes, including honest no-upload outcomes. + pub(crate) native: BTreeMap, + /// Unqualified rebuild details for future evidence assembly. + pub(crate) rebuild: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BuildRecord { + schema_version: u64, + source_commit: String, + recipe_sha256: String, + asset: String, + role: String, + job: String, + attempt: u64, + inputs: BTreeMap, + inventory_sha256: String, + artifact_sha256: String, + artifact_size: u64, + composition_sha256: String, + ambient: Ambient, + claim: Claim, + boundary: Boundary, + runtime: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Ambient { + umask: u32, + jobs: u32, + checkout_root: String, + started_at_seconds: u64, + execution_uid: u32, + host_locale: String, + host_timezone: String, + child_locale: String, + child_timezone: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Claim { + varied: Vec, + normalized: Vec, + unsupported: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Boundary { + /// Boundary instance the measured socket denial receipt names. + fingerprint: String, + /// Boundary instance that executed the build; instance identity only, + /// including a fresh per-instance nonce, not the invocation content. + execution_fingerprint: String, + /// SHA-256 of the exact build invocation: program, arguments including + /// jobs, environment, working directory (the checkout root) and umask. + invocation_digest: String, + confined: bool, + control_exit: i32, + control_accepts: u32, + probe_exit: i32, + probe_errno: i32, + probe_accepts: u32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Runtime { + /// The build role the producer claims for the Viceroy run that accepted + /// these final bytes; self-asserted, not an independent run identity. + role: String, + source_commit: String, + final_wasm_sha256: String, + final_wasm_size: u64, + publisher_status: u16, + injected_scripts: u32, + origin_marker_matched: bool, + viceroy_version: String, +} + +type Result = std::result::Result>; + +/// Build record facts a sealed composition receipt must restate. +pub(crate) struct ReceiptFacts<'a> { + /// Exact final artifact digest. + pub(crate) artifact_sha256: &'a str, + /// Approved source commit. + pub(crate) source_commit: &'a str, + /// Shared original JS inventory digest. + pub(crate) inventory_sha256: &'a str, + /// Prepared Cargo materials tree digest (`inputs.materials`). + pub(crate) materials_sha256: &'a str, + /// Fixed asset name; selects the target and the Fastly JS rule. + pub(crate) asset: &'a str, +} + +/// Checks a sealed composition receipt exactly as the strict collector does. +/// +/// The collector calls this after binding the receipt bytes to the record's +/// `composition_sha256`; the real candidate tests call it on actual producer +/// exports so the collector schema cannot drift from the producer's. +/// +/// # Errors +/// +/// Rejects an unknown asset and every receipt rejection of the collector. +pub(crate) fn check_composition_receipt(bytes: &[u8], facts: &ReceiptFacts<'_>) -> Result<()> { + let fixed = FIXED_ARTIFACTS + .iter() + .find(|asset| asset.name == facts.asset) + .ok_or_else(|| collection_error("unknown cohort artifact"))?; + check_receipt( + bytes, + &ReceiptBinding { + artifact_sha256: facts.artifact_sha256, + source_commit: facts.source_commit, + inventory_sha256: facts.inventory_sha256, + materials_sha256: facts.materials_sha256, + target: fixed.target, + }, + ) +} + +fn require(condition: bool, detail: &str) -> Result<()> { + if !condition { + return Err(collection_error(detail)); + } + Ok(()) +} +fn parse(bytes: &[u8]) -> Result { + let value = parse_json(bytes, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + serde_json::from_value(value).map_err(collection_error) +} +fn member<'a>(transfer: &'a Transfer, name: &str) -> Result<&'a [u8]> { + transfer + .members + .get(name) + .map(Vec::as_slice) + .ok_or_else(|| collection_error(format!("missing declared {name}"))) +} +fn exact_names(values: &[String], expected: &[&str]) -> bool { + values.len() == expected.len() + && values.iter().map(String::as_str).collect::>() + == expected.iter().copied().collect() +} +fn normal_path(value: &str) -> bool { + let path = Path::new(value); + path.is_absolute() + && path != Path::new("/") + && path + .components() + .all(|part| matches!(part, Component::RootDir | Component::Normal(_))) + && path + .components() + .collect::() + .as_os_str() + == path.as_os_str() +} + +fn check_ambient(record: &BuildRecord, profile: &RoleProfile) -> Result<()> { + let ambient = &record.ambient; + require( + ambient.umask == profile.umask + && ambient.jobs == profile.jobs + && ambient.host_locale == profile.host_locale + && ambient.host_timezone == profile.host_timezone, + "observed ambient differs from selected role profile", + )?; + require( + ambient.child_locale == "C" + && ambient.child_timezone == "UTC" + && normal_path(&ambient.checkout_root), + "child normalization or observed checkout root is invalid", + )?; + require( + exact_names( + &record.claim.varied, + &["umask", "jobs", "checkout_root", "start_time"], + ) && exact_names(&record.claim.normalized, &["locale", "timezone"]) + && exact_names(&record.claim.unsupported, &["execution_user"]), + "dishonest or incomplete varied/normalized/unsupported claim", + ) +} + +fn check_record(transfer: &Transfer, all: &ValidatedTransfers) -> Result { + require( + transfer.members.len() == 4, + "unexpected build transfer member set", + )?; + let record: BuildRecord = parse(member(transfer, "build-record.json")?)?; + let upload = &transfer.upload; + require( + super::bounded_upload(upload), + "successful build has no bounded completion metadata", + )?; + let invocation = &all.invocation; + require( + record.schema_version == 1 + && record.source_commit == invocation.source_commit + && record.recipe_sha256 == upload.recipe_sha256 + && record.asset == upload.asset + && record.role == upload.role + && record.job == upload.job + && record.attempt == upload.attempt, + "build record differs from trusted dependency identity", + )?; + let input_names = record + .inputs + .keys() + .map(String::as_str) + .collect::>(); + require( + input_names + == BTreeSet::from([ + "cargo_lock", + "materials", + "rust_tools", + "js_settings", + "js_tools", + ]) + && record.inputs.values().all(|value| lower_hex(value, 64)), + "missing or invalid build input digests", + )?; + let artifact = member(transfer, "artifact")?; + require( + record.artifact_size == artifact.len() as u64 && record.artifact_sha256 == digest(artifact), + "final artifact digest or size changed", + )?; + let inventory = member(transfer, "tsjs-inventory.json")?; + parse_json(inventory, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + require( + record.inventory_sha256 == invocation.inventory_sha256 + && digest(inventory) == invocation.inventory_sha256, + "shared JS inventory changed", + )?; + let composition = member(transfer, "composition.json")?; + require( + digest(composition) == record.composition_sha256, + "composition document digest changed", + )?; + let materials_sha256 = record + .inputs + .get("materials") + .ok_or_else(|| collection_error("missing or invalid build input digests"))?; + check_composition_receipt( + composition, + &ReceiptFacts { + artifact_sha256: &record.artifact_sha256, + source_commit: &record.source_commit, + inventory_sha256: &record.inventory_sha256, + materials_sha256, + asset: &record.asset, + }, + )?; + let boundary = &record.boundary; + require( + lower_hex(&boundary.fingerprint, 64) + && boundary.fingerprint == boundary.execution_fingerprint + && lower_hex(&boundary.invocation_digest, 64) + && boundary.confined + && boundary.control_exit == 0 + && boundary.control_accepts == 1 + && boundary.probe_exit == 3 + && matches!(boundary.probe_errno, 1 | 13) + && boundary.probe_accepts == 0, + "missing/failed socket controls or unconfined build invocation", + )?; + let profile = match record.role.as_str() { + "A" => &invocation.profiles[0], + "B" => &invocation.profiles[1], + _ => return Err(collection_error("unknown build role")), + }; + check_ambient(&record, profile)?; + if record.asset == FIXED_ARTIFACTS[0].name { + let facts = inspect_wasm(artifact) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + facts + .require_fastly_entry_abi() + .and_then(|()| facts.require_debug_sections()) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + let runtime = record + .runtime + .as_ref() + .ok_or_else(|| collection_error("missing final-byte runtime proof diagnostic"))?; + // A and B final bytes must be equal, so without the role one build's + // runtime run could stand in for the other's. The role is asserted by + // the producer inside its own record: it catches mix-ups and reuse in + // an honest pipeline, not a malicious producer copying A's runtime + // object and changing its role. Binding strength comes from the trusted + // upload role and job isolation; a per-run Viceroy identity remains a + // named gap. + require( + runtime.role == record.role + && runtime.source_commit == record.source_commit + && runtime.final_wasm_sha256 == record.artifact_sha256 + && runtime.final_wasm_size == record.artifact_size + && runtime.publisher_status == 200 + && runtime.injected_scripts == 1 + && runtime.origin_marker_matched + && runtime.viceroy_version == "0.21.0", + "runtime diagnostic is not bound to accepted final Wasm bytes and publisher request", + )?; + } else { + require( + record.runtime.is_none(), + "native transfer carries a Fastly runtime diagnostic", + )?; + } + Ok(record) +} + +fn compare_pair(a: &BuildRecord, b: &BuildRecord) -> Result<()> { + require( + a.inputs == b.inputs && a.recipe_sha256 == b.recipe_sha256, + "A/B build inputs or recipe differ", + )?; + // The exact invocation digest covers role-specific paths, jobs, environment + // and umask, so a reproduction reusing the canonical one did not vary them. + require( + a.boundary.invocation_digest != b.boundary.invocation_digest, + "A/B share one exact invocation", + )?; + // Each build runs in its own fresh boundary instance. Instance fingerprints + // carry a per-instance nonce, so this proves separate instances, not + // varied invocation content. + require( + a.boundary.execution_fingerprint != b.boundary.execution_fingerprint, + "A/B share one boundary instance", + )?; + let a = &a.ambient; + let b = &b.ambient; + // Normalized dimensions still vary on the host; only the compiler child + // observes the fixed normalized values. The spec requires B to differ in + // TZ and locale, so a qualified reproduction profile must select values + // distinct from canonical; the claim shape cannot express `not-varied` + // for them, and such a profile fails here by design. + require( + a.umask != b.umask + && a.jobs != b.jobs + && a.started_at_seconds != b.started_at_seconds + && a.host_locale != b.host_locale + && a.host_timezone != b.host_timezone, + "supported A/B variation is missing", + )?; + let a_root = Path::new(&a.checkout_root); + let b_root = Path::new(&b.checkout_root); + require( + !a_root.starts_with(b_root) && !b_root.starts_with(a_root), + "A/B checkout roots overlap", + )?; + require( + a.execution_uid == b.execution_uid, + "unsupported execution user variation was claimed", + ) +} + +pub(super) fn diagnose(all: &ValidatedTransfers) -> Result { + let mut builds = BTreeMap::new(); + for transfer in &all.transfers { + require( + FIXED_ARTIFACTS + .iter() + .any(|asset| asset.name == transfer.upload.asset), + "unknown cohort artifact", + )?; + let record = check_record(transfer, all)?; + require( + builds + .insert( + (record.asset.clone(), record.role.clone()), + (record, transfer), + ) + .is_none(), + "duplicate asset/role record", + )?; + } + let mut native = BTreeMap::new(); + for absent in &all.absent { + let outcome = absent + .outcome() + .filter(|_| { + FIXED_ARTIFACTS + .iter() + .skip(1) + .any(|fixed| fixed.name == absent.asset) + && !builds.contains_key(&(absent.asset.clone(), "B".to_owned())) + }) + .ok_or_else(|| { + collection_error("optional B absence is not a bounded distinct native result") + })?; + require( + native.insert(absent.asset.clone(), outcome).is_none(), + "optional B absence is not a bounded distinct native result", + )?; + } + let mut canonical = BTreeMap::new(); + for asset in FIXED_ARTIFACTS { + let (a, a_transfer) = builds + .get(&(asset.name.to_owned(), "A".to_owned())) + .ok_or_else(|| collection_error(format!("missing mandatory A for {}", asset.name)))?; + if let Some((b, b_transfer)) = builds.get(&(asset.name.to_owned(), "B".to_owned())) { + compare_pair(a, b)?; + let same = member(a_transfer, "artifact")? == member(b_transfer, "artifact")?; + if asset.name == FIXED_ARTIFACTS[0].name { + require(same, "final Fastly bytes differ")?; + } else { + require( + native + .insert( + asset.name.to_owned(), + if same { + Outcome::Matched + } else { + Outcome::Mismatched + }, + ) + .is_none(), + "native B has both uploaded and absent results", + )?; + } + } else if asset.name == FIXED_ARTIFACTS[0].name { + return Err(collection_error("missing mandatory Fastly B")); + } else { + require( + native.contains_key(asset.name), + "native B has no bounded result", + )?; + } + canonical.insert( + asset.name.to_owned(), + member(a_transfer, "artifact")?.to_vec(), + ); + } + let mut rebuild = BTreeMap::new(); + for asset in FIXED_ARTIFACTS { + let (a, transfer) = builds + .get(&(asset.name.to_owned(), "A".to_owned())) + .ok_or_else(|| collection_error("canonical diagnostic disappeared"))?; + let (outcome, reproduction) = + if let Some((b, transfer)) = builds.get(&(asset.name.to_owned(), "B".to_owned())) { + let outcome = if asset.name == FIXED_ARTIFACTS[0].name { + Outcome::Matched + } else { + *native + .get(asset.name) + .ok_or_else(|| collection_error("native measurement disappeared"))? + }; + (outcome, build_diagnostic(b, transfer)?) + } else { + all.absent + .iter() + .filter(|absent| absent.asset == asset.name) + .find_map(|absent| { + absent.outcome().map(|outcome| { + ( + outcome, + json!({ + "outcome": outcome, + "job_state": absent.job, + "elapsed_seconds": absent.elapsed_seconds, + "deadline_seconds": absent.deadline_seconds, + }), + ) + }) + }) + .ok_or_else(|| collection_error("bounded native absence disappeared"))? + }; + rebuild.insert( + asset.name.to_owned(), + json!({ + "schema_version":1, + "kind":"candidate-collection-diagnostic", + "release_qualification":false, + "source_commit":a.source_commit, + "recipe_sha256":a.recipe_sha256, + "inventory_sha256":a.inventory_sha256, + "outcome":outcome, + "canonical":build_diagnostic(a,transfer)?, + "reproduction":reproduction, + }), + ); + } + Ok(CollectionDiagnostic { + canonical, + native, + rebuild, + }) +} + +fn build_diagnostic(record: &BuildRecord, transfer: &Transfer) -> Result { + Ok(json!({ + "job":transfer.upload.job, + "attempt":transfer.upload.attempt, + "artifact_id":transfer.upload.artifact_id, + "archive_sha256":transfer.upload.archive_sha256, + "elapsed_seconds":transfer.upload.elapsed_seconds, + "deadline_seconds":transfer.upload.deadline_seconds, + "artifact_sha256":record.artifact_sha256, + "artifact_size":record.artifact_size, + "build_record_sha256":digest(member(transfer,"build-record.json")?), + "composition_sha256":record.composition_sha256, + "ambient":record.ambient, + "claim":record.claim, + })) +} diff --git a/crates/trusted-server-release/src/collection/records/receipt.rs b/crates/trusted-server-release/src/collection/records/receipt.rs new file mode 100644 index 0000000000..0d59431354 --- /dev/null +++ b/crates/trusted-server-release/src/collection/records/receipt.rs @@ -0,0 +1,548 @@ +//! Strict collector parser for the producer's sealed composition receipt. +//! +//! `composition.json` is the exact normalized receipt the producer seals with +//! its owning build (`RecipeCompleted::composition_export`): the Cargo query +//! plan digests, the actual compiler observations and the graph, package +//! origin, native, JavaScript and platform receipts. Every level the collector +//! checks rejects unknown or missing fields. The installed tool subtrees of the +//! platform receipt (`rust`, `apple`, `native_tools`, `external_requirements` +//! runtime facts) are held opaquely; only their artifact and target bindings, +//! Apple evidence presence and the runtime format tag are checked here. +//! +//! The receipt is imported data. These checks bind it to the build record's +//! artifact, source, inventory, materials and target; they do not prove +//! complete source coverage or final executable membership. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Deserialize; +use serde_json::Value; + +use super::{Result, require}; +use crate::collection::collection_error; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; + +/// Most compiler units a receipt may name. +const UNIT_LIMIT: usize = 65_536; +/// Most compiled packages, and so package origins, a receipt may name. +const PACKAGE_LIMIT: usize = 16_384; +/// Most prepared JS modules the producer's final-byte check accepts. +const MODULE_LIMIT: usize = 1024; +/// Classification of a complete module found in the final bytes. +const EMBEDDED_JS: &str = "conservative_embedded_js"; +/// Classification of a prepared module the final bytes do not contain. +const JS_BUILD_MATERIAL: &str = "js_build_material"; + +/// Build record facts, already checked by the collector, that a receipt restates. +pub(super) struct ReceiptBinding<'a> { + /// Exact final artifact digest of the same transfer. + pub(super) artifact_sha256: &'a str, + /// Approved source commit of the trusted invocation. + pub(super) source_commit: &'a str, + /// Shared original JS inventory digest. + pub(super) inventory_sha256: &'a str, + /// Prepared Cargo materials tree digest of the build inputs. + pub(super) materials_sha256: &'a str, + /// Target of the fixed asset; Fastly (`wasm32-wasip1`) embeds every module. + pub(super) target: ReleaseTarget, +} + +// Shape-only fields are deserialized so unknown, missing or mistyped members +// are rejected; the collector reads only the bindings it checks. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Receipt { + metadata_plan_sha256: String, + tree_plan_sha256: String, + observations: Observations, + composition: Composition, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Observations { + packages: Vec, + units: Vec, + build_scripts: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum Context { + Host, + Target, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Package { + id: String, + name: String, + version: String, + source: Option, + manifest_path: String, + license: Option, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Unit { + package_id: String, + target_name: String, + target_kind: Vec, + crate_types: Vec, + context: Context, + features: Vec, + profile: Value, + filenames: Vec, + executable: Option, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BuildScript { + package_id: String, + context: Context, + out_dir: String, + linked_libs: Vec, + linked_paths: Vec, + cfgs: Vec, + environment: Vec<(String, String)>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Composition { + graph: Graph, + package_origins: PackageOrigins, + native: Native, + javascript: Javascript, + platform: Platform, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Graph { + selected_package_id: String, + tree_package_ids: Vec, + observed_package_ids: Vec, + edges: Vec, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Edge { + parent: String, + dependency: String, + kinds: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageOrigins { + source_commit: String, + materials_sha256: String, + packages: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageOrigin { + name: String, + version: String, + manifest_path: String, + prepared_manifest_sha256: String, + origin: Original, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Original { + Workspace { + source_commit: String, + path: String, + }, + Material { + source: String, + original: CargoOrigin, + }, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum CargoOrigin { + Registry { + archive_sha256: String, + }, + Git { + revision: String, + repository_tree: String, + package_tree: String, + package_path: String, + committed_manifest_blob: String, + committed_manifest_sha256: String, + normalized_manifest_sha256: Option, + }, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Native { + contributors: Vec, + build_tool_package_ids: Vec, + material_native_coverage_complete: bool, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Contributor { + package_id: String, + name: String, + version: String, + context: Context, + features: Vec, + classification: String, + original_sha256: String, + source_tree_sha256: String, + source_subtrees: Vec, + linked_libs: Vec, + linked_paths: Vec, + archives: Vec, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Subtree { + path: String, + sha256: String, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Archive { + path: String, + size: u64, + sha256: String, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Javascript { + artifact_sha256: String, + inventory_sha256: String, + modules: Vec, + method: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModuleMembership { + module: Module, + classification: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Module { + id: String, + filename: String, + size: u64, + sha256: String, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Platform { + target: String, + artifact_sha256: String, + rust: Value, + apple: Value, + external_requirements: ExternalRequirements, + native_tools: Value, + material_native_coverage_complete: bool, +} + +#[allow( + dead_code, + reason = "every member is parsed so the strict receipt shape rejects missing or mistyped ones" +)] +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExternalRequirements { + target: String, + artifact_sha256: String, + runtime: Value, +} + +/// Parses one sealed receipt strictly and binds it to its checked build record. +/// +/// # Errors +/// +/// Rejects oversized, duplicate-key, non-finite or mistyped JSON, unknown or +/// missing members, malformed digests, another artifact, source, inventory, +/// materials tree or target, Apple evidence or a runtime format foreign to the +/// target, a complete native coverage claim, no compiled units, malformed or unjoined package +/// origins, malformed JS membership, and a Fastly receipt that does not embed +/// every prepared module. +pub(super) fn check_receipt(bytes: &[u8], binding: &ReceiptBinding<'_>) -> Result<()> { + let value = parse_json(bytes, Role::CargoObservation) + .map_err(|error| error.change_context(ReleaseError::Collection))?; + let receipt: Receipt = serde_json::from_value(value).map_err(collection_error)?; + let composition = &receipt.composition; + let platform = &composition.platform; + let javascript = &composition.javascript; + let origins = &composition.package_origins; + require( + [ + &receipt.metadata_plan_sha256, + &receipt.tree_plan_sha256, + &origins.materials_sha256, + ] + .into_iter() + .all(|digest| lower_hex(digest, 64)) + && native_digests_are_valid(&composition.native), + "composition receipt digests are malformed", + )?; + require( + javascript.artifact_sha256 == binding.artifact_sha256 + && platform.artifact_sha256 == binding.artifact_sha256 + && platform.external_requirements.artifact_sha256 == binding.artifact_sha256, + "composition receipt is not bound to the final artifact", + )?; + require( + origins.source_commit == binding.source_commit + && origins + .packages + .values() + .all(|package| match &package.origin { + Original::Workspace { source_commit, .. } => { + source_commit == binding.source_commit + } + Original::Material { .. } => true, + }), + "composition receipt is not bound to the approved source", + )?; + require( + javascript.inventory_sha256 == binding.inventory_sha256, + "composition receipt is not bound to the shared JS inventory", + )?; + require( + origins.materials_sha256 == binding.materials_sha256, + "composition receipt is not bound to the build materials", + )?; + let target = binding.target.triple(); + require( + platform.target == target && platform.external_requirements.target == target, + "composition receipt names another target", + )?; + check_platform_shape(platform, binding.target)?; + require( + !composition.native.material_native_coverage_complete + && !platform.material_native_coverage_complete, + "composition receipt claims complete material native coverage", + )?; + let units = &receipt.observations.units; + require( + !units.is_empty() && units.len() <= UNIT_LIMIT, + "composition receipt has no compiled units within its bound", + )?; + require( + origins_are_joined(&receipt), + "composition receipt package origins are malformed or ambiguous", + )?; + check_javascript(javascript, binding.target == ReleaseTarget::Wasm32Wasip1) +} + +/// Apple evidence and the runtime format follow the target, as the producer writes them. +/// +/// `PlatformEvidence::collect` retains Apple tool evidence exactly for Darwin +/// targets, and `ExternalRequirements` is built from the inspected executable +/// format (`from_wasm`, `from_elf`, `from_macho`), serialized with tag +/// `format` as `wasm`, `elf` or `macho`. +fn check_platform_shape(platform: &Platform, target: ReleaseTarget) -> Result<()> { + require( + platform.apple.is_null() != target.is_darwin(), + "composition receipt Apple evidence does not match its target", + )?; + let format = match target { + ReleaseTarget::Wasm32Wasip1 => "wasm", + ReleaseTarget::X86_64UnknownLinuxGnu => "elf", + ReleaseTarget::Aarch64AppleDarwin | ReleaseTarget::X86_64AppleDarwin => "macho", + }; + require( + platform.external_requirements.runtime["format"] == format, + "composition receipt runtime format does not match its target", + ) +} + +/// Native measurement digests are exact SHA-256 identities. +fn native_digests_are_valid(native: &Native) -> bool { + native.contributors.iter().all(|contributor| { + lower_hex(&contributor.original_sha256, 64) + && lower_hex(&contributor.source_tree_sha256, 64) + && contributor + .source_subtrees + .iter() + .all(|subtree| lower_hex(&subtree.sha256, 64)) + && contributor + .archives + .iter() + .all(|archive| lower_hex(&archive.sha256, 64)) + }) +} + +/// Every observed package has exactly one well-formed origin, and nothing else does. +/// +/// Package IDs are unique because duplicate object keys are rejected at parse +/// time and observed IDs must be distinct; prepared manifest paths must also be +/// distinct, as the producer's origin join requires. +fn origins_are_joined(receipt: &Receipt) -> bool { + let observed = &receipt.observations.packages; + let origins = &receipt.composition.package_origins.packages; + let observed_ids = observed + .iter() + .map(|package| package.id.as_str()) + .collect::>(); + let manifests = origins + .values() + .map(|origin| origin.manifest_path.as_str()) + .collect::>(); + let joined = |id: &str| origins.contains_key(id); + !origins.is_empty() + && origins.len() <= PACKAGE_LIMIT + && observed_ids.len() == observed.len() + && observed_ids.len() == origins.len() + && manifests.len() == origins.len() + && observed.iter().all(|package| { + origins.get(&package.id).is_some_and(|origin| { + origin.name == package.name && origin.version == package.version + }) + }) + && origins.values().all(origin_is_well_formed) + && receipt + .observations + .units + .iter() + .all(|unit| joined(&unit.package_id)) + && receipt + .observations + .build_scripts + .iter() + .all(|script| joined(&script.package_id)) + && receipt + .composition + .native + .contributors + .iter() + .all(|contributor| joined(&contributor.package_id)) + && joined(&receipt.composition.graph.selected_package_id) +} + +fn origin_is_well_formed(origin: &PackageOrigin) -> bool { + lower_hex(&origin.prepared_manifest_sha256, 64) + && !origin.manifest_path.is_empty() + && match &origin.origin { + Original::Workspace { path, .. } => path == &origin.manifest_path, + Original::Material { source, original } => { + !source.is_empty() + && match original { + CargoOrigin::Registry { archive_sha256 } => lower_hex(archive_sha256, 64), + CargoOrigin::Git { + committed_manifest_sha256, + normalized_manifest_sha256, + .. + } => { + lower_hex(committed_manifest_sha256, 64) + && normalized_manifest_sha256 + .as_deref() + .is_none_or(|digest| lower_hex(digest, 64)) + } + } + } + } +} + +fn check_javascript(javascript: &Javascript, fastly: bool) -> Result<()> { + let modules = &javascript.modules; + let ids = modules + .iter() + .map(|entry| entry.module.id.as_str()) + .collect::>(); + let filenames = modules + .iter() + .map(|entry| entry.module.filename.as_str()) + .collect::>(); + require( + !modules.is_empty() + && modules.len() <= MODULE_LIMIT + && ids.len() == modules.len() + && filenames.len() == modules.len() + && modules.iter().all(|entry| { + entry.module.size > 0 + && lower_hex(&entry.module.sha256, 64) + && [EMBEDDED_JS, JS_BUILD_MATERIAL].contains(&entry.classification.as_str()) + }), + "composition receipt JS membership is malformed", + )?; + require( + !fastly + || modules + .iter() + .all(|entry| entry.classification == EMBEDDED_JS), + "composition receipt omits a complete Fastly JS module", + ) +} diff --git a/crates/trusted-server-release/src/collection/tests.rs b/crates/trusted-server-release/src/collection/tests.rs new file mode 100644 index 0000000000..fed7ee88b2 --- /dev/null +++ b/crates/trusted-server-release/src/collection/tests.rs @@ -0,0 +1,1925 @@ +use std::collections::BTreeMap; +use std::io::Write as _; + +use flate2::Crc; +use serde_json::{Value, json}; + +use super::*; + +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 bounded_zip_accepts_exact_members_and_streaming_descriptor() { + for descriptor in [false, true] { + let bytes = zip(&[("artifact", b"final bytes")], descriptor); + let declared = BTreeMap::from([("artifact".to_owned(), 11)]); + let members = archive::read(&bytes, &declared).expect("should validate bounded ZIP"); + assert_eq!( + members["artifact"], b"final bytes", + "should retain exact bytes" + ); + } +} + +#[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!( + archive::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!( + archive::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!( + archive::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!( + archive::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!( + archive::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!( + archive::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!( + archive::read(&bytes, &declared).is_err(), + "should reject encryption or unsupported method" + ); + } + let bytes = zip(&[("artifact", b"x")], false); + assert!( + archive::read(&bytes, &BTreeMap::from([("artifact".to_owned(), u64::MAX)])).is_err(), + "should reject declaration before allocation" + ); + for length in 0..bytes.len() { + assert!( + archive::read(&bytes[..length], &declared).is_err(), + "should reject truncation at {length}" + ); + } +} + +struct FakeSource { + metadata: Value, + archive: Vec, + warning: bool, + requested: Vec, + repositories: Vec, +} + +impl ArtifactSource for FakeSource { + fn metadata(&mut self, repository: &str, id: u64) -> Result, Report> { + self.repositories.push(repository.to_owned()); + self.requested.push(id); + Ok(serde_json::to_vec(&self.metadata).expect("should serialize metadata")) + } + fn download(&mut self, repository: &str, id: u64) -> Result> { + self.repositories.push(repository.to_owned()); + self.requested.push(id); + Ok(Download { + archive: self.archive.clone(), + transfer_warning: self.warning, + }) + } +} + +fn transfer_fixture() -> (TrustedDependencies, FakeSource) { + let archive = zip(&[("artifact", b"x")], false); + let dependencies = TrustedDependencies { + invocation: Invocation { + repository: "example/project".to_owned(), + repository_id: 17, + run_id: 29, + source_commit: "a".repeat(40), + inventory_sha256: "b".repeat(64), + profiles: records::RoleProfile::fixtures(), + }, + required: vec![Upload { + asset: "trusted-server-fastly.wasm".to_owned(), + role: "A".to_owned(), + job: "fastly-a".to_owned(), + attempt: 1, + recipe_sha256: "c".repeat(64), + elapsed_seconds: 10, + deadline_seconds: 1800, + artifact_name: "upload".to_owned(), + artifact_id: 41, + archive_sha256: digest(&archive), + members: BTreeMap::from([("artifact".to_owned(), 1)]), + }], + optional: vec![], + }; + let source = FakeSource { + metadata: json!({"id":41,"name":"upload","expired":false,"digest":format!("sha256:{}",digest(&archive)),"workflow_run":{"id":29,"head_sha":"a".repeat(40),"repository_id":17,"head_repository_id":17}}), + archive, + warning: false, + requested: vec![], + repositories: vec![], + }; + (dependencies, source) +} + +#[test] +fn transfer_checks_exact_dependency_id_and_archive_bytes() { + let (dependencies, mut source) = transfer_fixture(); + let checked = validate_transfers(&dependencies, &mut source).expect("should validate transfer"); + assert_eq!( + checked.transfers[0].members["artifact"], b"x", + "should retain exact bytes" + ); + assert_eq!( + source.requested, + [41, 41], + "should re-fetch exact selected ID" + ); + assert_eq!( + source.repositories, + ["example/project", "example/project"], + "should use trusted repository for metadata and download" + ); +} + +#[test] +fn transfer_rejects_stale_metadata_warnings_reused_ids_and_changed_bytes() { + let mut cases = Vec::new(); + for (pointer, value) in [ + ("/id", json!(42)), + ("/name", json!("other-upload")), + ("/expired", json!(true)), + ("/workflow_run/id", json!(30)), + ("/workflow_run/head_sha", json!("d".repeat(40))), + ("/workflow_run/repository_id", json!(18)), + ("/workflow_run/head_repository_id", json!(18)), + ("/digest", json!(format!("sha256:{}", "e".repeat(64)))), + ] { + let (dependencies, mut source) = transfer_fixture(); + *source + .metadata + .pointer_mut(pointer) + .expect("should find fixture field") = value; + cases.push((dependencies, source)); + } + let (dependencies, mut source) = transfer_fixture(); + source.warning = true; + cases.push((dependencies, source)); + let (dependencies, mut source) = transfer_fixture(); + source.archive[0] ^= 1; + cases.push((dependencies, source)); + let (mut dependencies, source) = transfer_fixture(); + let mut reused = dependencies.required[0].clone(); + reused.role = "B".to_owned(); + dependencies.required.push(reused); + cases.push((dependencies, source)); + for (dependencies, mut source) in cases { + assert!( + validate_transfers(&dependencies, &mut source).is_err(), + "should reject transfer substitution" + ); + } +} + +fn wasm() -> Vec { + let mut bytes = crate::inspect::tests::builders::command_module( + &[("fastly_http_req", "call"), ("fastly_http_resp", "call")], + crate::inspect::tests::builders::Entry::Nullary, + true, + ); + for name in [".debug_info", ".debug_line"] { + bytes.extend([0, (name.len() + 2) as u8, name.len() as u8]); + bytes.extend(name.as_bytes()); + bytes.push(1); + } + bytes +} + +/// Fills the fictional sealed composition receipt with one build's bindings. +/// +/// The fixture has the producer's `composition_export()` shape; only the +/// target, final artifact, approved source and shared inventory vary per build. +pub(super) fn composition_receipt( + target: &str, + artifact_sha256: &str, + source_commit: &str, + inventory_sha256: &str, +) -> Vec { + let mut receipt: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/collection/composition.json" + )) + .expect("should parse fictional composition receipt"); + for (pointer, value) in [ + ("/composition/platform/target", target), + ("/composition/platform/rust/target", target), + ("/composition/platform/external_requirements/target", target), + ("/composition/javascript/artifact_sha256", artifact_sha256), + ("/composition/platform/artifact_sha256", artifact_sha256), + ( + "/composition/platform/external_requirements/artifact_sha256", + artifact_sha256, + ), + ("/composition/javascript/inventory_sha256", inventory_sha256), + ("/composition/package_origins/source_commit", source_commit), + ] { + *receipt + .pointer_mut(pointer) + .expect("should find receipt binding") = json!(value); + } + let platform = &mut receipt["composition"]["platform"]; + if target.ends_with("-apple-darwin") { + platform["apple"] = json!({"sdk_version": "15.0", "qualification": "unqualified"}); + platform["external_requirements"]["runtime"] = json!({ + "format": "macho", + "loader": "/usr/lib/dyld", + "cpu": "fictional", + "dylibs": ["/usr/lib/libSystem.B.dylib"], + "minimum_os": "13.0", + "sdk": "15.0", + }); + } else if target.ends_with("-linux-gnu") { + platform["external_requirements"]["runtime"] = json!({ + "format": "elf", + "interpreter": "/lib64/ld-linux-x86-64.so.2", + "needed": ["libc.so.6"], + "version_requirements": [], + "max_glibc": "2.34", + }); + } + let packages = receipt + .pointer_mut("/composition/package_origins/packages") + .and_then(Value::as_object_mut) + .expect("should find package origins"); + for origin in packages.values_mut().map(|package| &mut package["origin"]) { + if origin["kind"] == "workspace" { + origin["source_commit"] = json!(source_commit); + } + } + serde_json::to_vec(&receipt).expect("should serialize composition receipt") +} + +pub(super) fn cohort_fixture() -> ValidatedTransfers { + let inventory = b"{\"schema_version\":1}".to_vec(); + let mut invocation = transfer_fixture().0.invocation; + invocation.inventory_sha256 = digest(&inventory); + let mut transfers = Vec::new(); + for (index, asset) in crate::policy::FIXED_ARTIFACTS.iter().enumerate() { + for role in if index == 0 { + vec!["A", "B"] + } else { + vec!["A"] + } { + let artifact = if index == 0 { + wasm() + } else { + b"native final bytes".to_vec() + }; + let composition = composition_receipt( + asset.target.triple(), + &digest(&artifact), + &invocation.source_commit, + &invocation.inventory_sha256, + ); + let mut record: Value = serde_json::from_str(include_str!( + "../../tests/fixtures/collection/build-record.json" + )) + .expect("should parse fictional build record"); + record["asset"] = json!(asset.name); + record["role"] = json!(role); + record["job"] = json!(format!("job-{index}-{role}")); + record["artifact_sha256"] = json!(digest(&artifact)); + record["artifact_size"] = json!(artifact.len()); + record["inventory_sha256"] = json!(digest(&inventory)); + record["composition_sha256"] = json!(digest(&composition)); + if role == "B" { + record["boundary"]["fingerprint"] = json!("4".repeat(64)); + record["boundary"]["execution_fingerprint"] = json!("4".repeat(64)); + record["boundary"]["invocation_digest"] = json!("6".repeat(64)); + record["ambient"]["umask"] = json!(23); + record["ambient"]["jobs"] = json!(2); + record["ambient"]["checkout_root"] = json!("/external-release-B"); + record["ambient"]["started_at_seconds"] = json!(101); + record["ambient"]["host_locale"] = json!("en_US.UTF-8"); + record["ambient"]["host_timezone"] = json!("Pacific/Auckland"); + } + if index == 0 { + record["runtime"] = json!({"role":role,"source_commit":invocation.source_commit,"final_wasm_sha256":digest(&artifact),"final_wasm_size":artifact.len(),"publisher_status":200,"injected_scripts":1,"origin_marker_matched":true,"viceroy_version":"0.21.0"}); + } + let upload = Upload { + asset: asset.name.to_owned(), + role: role.to_owned(), + job: format!("job-{index}-{role}"), + attempt: 1, + recipe_sha256: "c".repeat(64), + elapsed_seconds: 10, + deadline_seconds: 1800, + artifact_name: "upload".to_owned(), + artifact_id: (transfers.len() + 1) as u64, + archive_sha256: "f".repeat(64), + members: BTreeMap::new(), + }; + transfers.push(Transfer { + upload, + members: BTreeMap::from([ + ("artifact".to_owned(), artifact), + ( + "build-record.json".to_owned(), + serde_json::to_vec(&record).expect("should serialize build record"), + ), + ("tsjs-inventory.json".to_owned(), inventory.clone()), + ("composition.json".to_owned(), composition), + ]), + }); + } + } + let absent = crate::policy::FIXED_ARTIFACTS + .iter() + .skip(1) + .map(|asset| no_upload(asset.name, QUEUED, 1800)) + .collect(); + ValidatedTransfers { + invocation, + transfers, + absent, + } +} + +/// Still queued, with no runner and no conclusion, when the collector deadline expired. +const QUEUED: ObservedJob = ObservedJob { + started: false, + conclusion: None, +}; + +/// A started job ended by its own failure before the upload step. +const FAILED_BEFORE_UPLOAD: ObservedJob = ObservedJob { + started: true, + conclusion: Some(JobConclusion::Failure), +}; + +/// A no-upload optional reproduction observed within a fictional 1800 s deadline. +fn no_upload(asset: &str, job: ObservedJob, elapsed_seconds: u64) -> NoUpload { + NoUpload { + asset: asset.to_owned(), + job, + elapsed_seconds, + deadline_seconds: 1800, + } +} + +fn mutate_record(transfers: &mut ValidatedTransfers, index: usize, pointer: &str, value: Value) { + let bytes = transfers.transfers[index] + .members + .get_mut("build-record.json") + .expect("should find record"); + let mut record: Value = serde_json::from_slice(bytes).expect("should parse record"); + *record + .pointer_mut(pointer) + .expect("should find record field") = value; + *bytes = serde_json::to_vec(&record).expect("should serialize changed record"); +} + +#[test] +fn mandatory_fastly_and_native_a_pass_with_never_started_native_b_absent() { + let checked = + diagnose_collection(&cohort_fixture()).expect("should diagnose complete mandatory cohort"); + assert_eq!( + checked.canonical.len(), + 4, + "should retain exactly four canonical artifacts" + ); + assert_eq!( + checked.native.len(), + 3, + "should record all three native measurements" + ); + assert!( + checked + .native + .values() + .all(|outcome| *outcome == records::Outcome::Unavailable), + "should honestly retain never-started native B as unavailable" + ); +} + +#[test] +fn collection_rejects_missing_a_and_every_mandatory_gate_even_with_optional_b_absent() { + let mut missing = cohort_fixture(); + missing.transfers.remove(2); + assert!( + diagnose_collection(&missing).is_err(), + "should reject missing native A" + ); + for (pointer, value) in [ + ("/inventory_sha256", json!("9".repeat(64))), + ("/composition_sha256", json!("9".repeat(64))), + ("/boundary/confined", json!(false)), + ("/boundary/control_exit", json!(1)), + ("/boundary/control_accepts", json!(0)), + ("/boundary/probe_exit", json!(0)), + ("/boundary/probe_accepts", json!(1)), + ("/boundary/execution_fingerprint", json!("9".repeat(64))), + ("/boundary/invocation_digest", json!("5".repeat(63))), + ("/boundary/invocation_digest", json!("A".repeat(64))), + ("/runtime", Value::Null), + ("/runtime/final_wasm_sha256", json!("9".repeat(64))), + ("/runtime/publisher_status", json!(500)), + ("/runtime/injected_scripts", json!(0)), + ("/runtime/origin_marker_matched", json!(false)), + ("/source_commit", json!("9".repeat(40))), + ("/job", json!("wrong-job")), + ("/attempt", json!(2)), + ] { + let mut fixture = cohort_fixture(); + mutate_record(&mut fixture, 0, pointer, value); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject mandatory gate {pointer}" + ); + } + let mut fixture = cohort_fixture(); + fixture.transfers[3] + .members + .get_mut("artifact") + .expect("should have native A") + .push(0); + assert!( + diagnose_collection(&fixture).is_err(), + "should not hide native A byte changes behind optional B absence" + ); +} + +#[test] +fn collection_rejects_unchanged_supported_variations_and_dishonest_claims() { + for (pointer, value) in [ + ("/ambient/umask", json!(18)), + ("/ambient/jobs", json!(4)), + ("/ambient/checkout_root", json!("/external-release-A")), + ("/ambient/started_at_seconds", json!(100)), + ("/ambient/child_locale", json!("en_US.UTF-8")), + ("/ambient/child_timezone", json!("Pacific/Auckland")), + ("/ambient/execution_uid", json!(1001)), + ("/claim/varied", json!(["umask", "jobs", "checkout_root"])), + ( + "/claim/normalized", + json!(["locale", "timezone", "start_time"]), + ), + ("/claim/unsupported", json!(["execution_user", "jobs"])), + ] { + let mut fixture = cohort_fixture(); + mutate_record(&mut fixture, 1, pointer, value); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject dishonest or unchanged variation {pointer}" + ); + } +} + +#[test] +fn collection_compares_complete_fastly_final_bytes_without_exclusions() { + let mut fixture = cohort_fixture(); + let artifact = fixture.transfers[1] + .members + .get_mut("artifact") + .expect("should find Fastly B"); + *artifact.last_mut().expect("should have bytes") ^= 1; + let sha = digest(artifact); + let size = artifact.len(); + mutate_record(&mut fixture, 1, "/artifact_sha256", json!(sha)); + mutate_record(&mut fixture, 1, "/runtime/final_wasm_sha256", json!(sha)); + mutate_record(&mut fixture, 1, "/runtime/final_wasm_size", json!(size)); + let composition = composition_receipt( + crate::policy::FIXED_ARTIFACTS[0].target.triple(), + &sha, + &fixture.invocation.source_commit, + &fixture.invocation.inventory_sha256, + ); + let composition_sha = digest(&composition); + fixture.transfers[1] + .members + .insert("composition.json".to_owned(), composition); + mutate_record( + &mut fixture, + 1, + "/composition_sha256", + json!(composition_sha), + ); + let error = diagnose_collection(&fixture).expect_err("should reject one changed final byte"); + assert!( + format!("{error:?}").contains("final Fastly bytes differ"), + "should reach complete final-byte equality gate" + ); +} + +#[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!( + archive::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!( + archive::read(&bytes, &BTreeMap::from([("artifact".to_owned(), 1)])).is_err(), + "should reject expanded bytes beyond declared bound" + ); + } +} + +#[test] +fn native_b_records_match_mismatch_failed_and_unavailable_without_masking_a() { + for same in [true, false] { + let fixture = with_uploaded_native_b(same); + let diagnostic = diagnose_collection(&fixture) + .expect("should measure native B without requiring equality"); + assert_eq!( + diagnostic.native[crate::policy::FIXED_ARTIFACTS[1].name], + if same { + records::Outcome::Matched + } else { + records::Outcome::Mismatched + }, + "should report actual complete-byte native result" + ); + } + for (job, elapsed, outcome) in [ + (FAILED_BEFORE_UPLOAD, 7, records::Outcome::Failed), + (QUEUED, 1800, records::Outcome::Unavailable), + ] { + let mut fixture = cohort_fixture(); + fixture.absent[0] = no_upload(crate::policy::FIXED_ARTIFACTS[1].name, job, elapsed); + assert_eq!( + diagnose_collection(&fixture) + .expect("should retain bounded native B failure") + .native[crate::policy::FIXED_ARTIFACTS[1].name], + outcome, + "should report honest bounded outcome" + ); + fixture.absent[0].elapsed_seconds = 1801; + assert!( + diagnose_collection(&fixture).is_err(), + "should reject unbounded optional B outcome" + ); + } +} + +#[test] +fn uploaded_build_deadlines_are_bounded_before_rest_or_comparison() { + let (mut dependencies, mut source) = transfer_fixture(); + dependencies.required[0].elapsed_seconds = 1801; + assert!( + validate_transfers(&dependencies, &mut source).is_err(), + "should reject a successful upload outside its job deadline" + ); + assert!( + source.requested.is_empty(), + "should reject before fetching any unbounded result" + ); + let mut fixture = cohort_fixture(); + fixture.transfers[1].upload.deadline_seconds = 0; + assert!( + diagnose_collection(&fixture).is_err(), + "should reject missing bounded completion metadata" + ); +} + +#[test] +fn optional_native_b_cannot_hide_any_canonical_composition_or_js_failure() { + for index in [0, 2, 3, 4] { + for pointer in ["/composition_sha256", "/inventory_sha256"] { + let mut fixture = cohort_fixture(); + mutate_record(&mut fixture, index, pointer, json!("9".repeat(64))); + assert!( + diagnose_collection(&fixture).is_err(), + "should block failed mandatory A {index} {pointer}" + ); + } + let mut fixture = cohort_fixture(); + fixture.transfers[index] + .members + .insert("tsjs-inventory.json".to_owned(), b"{}".to_vec()); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject changed actual shared inventory bytes in A {index}" + ); + let mut fixture = cohort_fixture(); + fixture.transfers[index].members.remove("composition.json"); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject absent canonical composition despite optional B unavailability" + ); + } +} + +#[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!( + archive::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!( + archive::read(&bytes, &declared).is_err(), + "should reject case-colliding declared identities" + ); +} + +#[test] +fn dependency_repository_cannot_change_the_fixed_api_path() { + for repository in [ + "../project", + "example/..", + "example/project?other=1", + "example/project#fragment", + "example/project%2fother", + "example/", + "/project", + ] { + let (mut dependencies, mut source) = transfer_fixture(); + dependencies.invocation.repository = repository.to_owned(); + let result = validate_transfers(&dependencies, &mut source); + assert!( + result.is_err(), + "should reject unsafe repository endpoint {repository}" + ); + assert!( + source.requested.is_empty(), + "should reject before any endpoint call" + ); + } +} + +#[test] +fn candidate_rebuild_details_preserve_observations_and_absence_without_authority() { + let checked = diagnose_collection(&cohort_fixture()).expect("should derive candidate details"); + let fastly = checked + .rebuild + .get(crate::policy::FIXED_ARTIFACTS[0].name) + .expect("should retain Fastly rebuild diagnostic"); + assert_eq!( + fastly["release_qualification"], false, + "should never imply official qualification" + ); + assert_eq!( + fastly["outcome"], "matched", + "should report complete Fastly equality" + ); + assert_eq!( + fastly["canonical"]["artifact_id"], 1, + "should retain trusted A upload identity" + ); + assert_eq!( + fastly["reproduction"]["artifact_id"], 2, + "should retain trusted B upload identity" + ); + assert_eq!( + fastly["reproduction"]["ambient"]["jobs"], 2, + "should retain checked observed B profile" + ); + let native = checked + .rebuild + .get(crate::policy::FIXED_ARTIFACTS[1].name) + .expect("should retain native rebuild diagnostic"); + assert_eq!( + native["outcome"], "unavailable", + "should retain never-started outcome" + ); + let b = native["reproduction"] + .as_object() + .expect("should retain honest bounded absence"); + assert_eq!( + b.len(), + 4, + "should emit only outcome, observed job state, elapsed and deadline without invented identity or observations" + ); + assert_eq!( + b["job_state"], + json!({"started": false, "conclusion": null}), + "should retain the observed never-started job state honestly" + ); + for field in [ + "artifact_id", + "artifact_name", + "job", + "attempt", + "archive_sha256", + "ambient", + "artifact_sha256", + ] { + assert!(!b.contains_key(field), "should omit unavailable B {field}"); + } +} + +struct FakeRun(BTreeMap); +impl ArtifactSource for FakeRun { + fn metadata(&mut self, repository: &str, id: u64) -> Result, Report> { + self.0 + .get_mut(&id) + .expect("should request exact current-run ID") + .metadata(repository, id) + } + fn download(&mut self, repository: &str, id: u64) -> Result> { + self.0 + .get_mut(&id) + .expect("should request exact current-run ID") + .download(repository, id) + } +} + +#[test] +fn exact_current_run_archives_feed_the_complete_candidate_comparison() { + let fixture = cohort_fixture(); + let mut dependencies = TrustedDependencies { + invocation: fixture.invocation, + required: Vec::new(), + optional: fixture + .absent + .into_iter() + .map(OptionalBuild::NotUploaded) + .collect(), + }; + let mut source = FakeRun(BTreeMap::new()); + for transfer in fixture.transfers { + let mut upload = transfer.upload; + let members = transfer + .members + .iter() + .map(|(name, body)| (name.as_str(), body.as_slice())) + .collect::>(); + let archive = zip(&members, true); + upload.archive_sha256 = digest(&archive); + upload.members = transfer + .members + .iter() + .map(|(name, body)| (name.clone(), body.len() as u64)) + .collect(); + source.0.insert(upload.artifact_id,FakeSource {metadata:json!({"id":upload.artifact_id,"name":upload.artifact_name,"expired":false,"digest":format!("sha256:{}",upload.archive_sha256),"workflow_run":{"id":dependencies.invocation.run_id,"head_sha":dependencies.invocation.source_commit,"repository_id":dependencies.invocation.repository_id,"head_repository_id":dependencies.invocation.repository_id}}),archive,warning:false,requested:Vec::new(),repositories:Vec::new()}); + dependencies.required.push(upload); + } + let transfers = validate_transfers(&dependencies, &mut source) + .expect("should re-download exact complete current-run transfers"); + let checked = diagnose_collection(&transfers) + .expect("should compare declared full final bytes from validated archives"); + assert_eq!( + checked.canonical.len(), + 4, + "should retain full canonical cohort" + ); + assert!( + source.0.values().all(|source| source.requested.len() == 2), + "should corroborate REST and re-download every exact-ID upload" + ); +} + +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 +} + +#[test] +fn unix_zip32_deflate_upload_profile_accepts_flat_files_and_rejects_nested_names() { + let body = b"x"; + let compressed = b"\x01\x01\x00\xfe\xffx"; + let mut bytes = raw_deflate_zip(body, compressed, true); + let end = bytes.len() - 22; + let central = u32::from_le_bytes( + bytes[end + 16..end + 20] + .try_into() + .expect("should hold the central directory offset"), + ) as usize; + bytes[central + 4..central + 6].copy_from_slice(&0x032d_u16.to_le_bytes()); + let attributes = (0o100644_u32 << 16) | 0x20; + bytes[central + 38..central + 42].copy_from_slice(&attributes.to_le_bytes()); + let declared = BTreeMap::from([("artifact".to_owned(), body.len() as u64)]); + + assert_eq!( + archive::read(&bytes, &declared) + .expect("should accept regular Unix files with ZIP32 signed streaming descriptors")["artifact"], + body, + "should retain exact fictional member bytes" + ); + + bytes[30..38].copy_from_slice(b"a/binary"); + bytes[central + 46..central + 54].copy_from_slice(b"a/binary"); + let nested = BTreeMap::from([("a/binary".to_owned(), body.len() as u64)]); + assert!( + archive::read(&bytes, &nested).is_err(), + "should reject nested member names without relaxing the flat-file policy" + ); +} + +#[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!( + archive::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!( + archive::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!( + archive::read( + &raw_deflate_zip(body, &final_block[..length], descriptor), + &declared + ) + .is_err(), + "should reject every truncated final DEFLATE block" + ); + } + assert_eq!( + archive::read(&valid, &declared) + .expect("should accept explicit final DEFLATE block")["artifact"], + body, + "should retain exact terminated stream output" + ); + } + } +} + +/// Selects one reproduction profile and makes Fastly B observe it exactly, so +/// only the A/B pair comparison can reject the record. +fn fixture_with_selected_reproduction( + profile: records::RoleProfile, + ambient: &Value, +) -> ValidatedTransfers { + let mut fixture = cohort_fixture(); + fixture.invocation.profiles[1] = profile; + for (field, value) in ambient.as_object().expect("should hold ambient fields") { + mutate_record(&mut fixture, 1, &format!("/ambient/{field}"), value.clone()); + } + fixture +} + +#[test] +fn reproduction_selected_without_a_supported_variation_is_rejected_by_the_pair_check() { + for (dimension, profile, ambient) in [ + ( + "umask", + records::RoleProfile::for_tests(18, 2, "en_US.UTF-8", "Pacific/Auckland"), + json!({"umask": 18}), + ), + ( + "jobs", + records::RoleProfile::for_tests(23, 4, "en_US.UTF-8", "Pacific/Auckland"), + json!({"jobs": 4}), + ), + ( + "host locale", + records::RoleProfile::for_tests(23, 2, "C", "Pacific/Auckland"), + json!({"host_locale": "C"}), + ), + ( + "host timezone", + records::RoleProfile::for_tests(23, 2, "en_US.UTF-8", "UTC"), + json!({"host_timezone": "UTC"}), + ), + ( + "start time", + records::RoleProfile::for_tests(23, 2, "en_US.UTF-8", "Pacific/Auckland"), + json!({"started_at_seconds": 100}), + ), + ] { + let fixture = fixture_with_selected_reproduction(profile, &ambient); + let error = diagnose_collection(&fixture) + .expect_err("should reject a reproduction that omits a supported variation"); + assert!( + format!("{error:?}").contains("A/B variation is missing"), + "should reach the pair check for unvaried {dimension}: {error:?}" + ); + } +} + +#[test] +fn reproduction_reusing_the_canonical_exact_invocation_is_rejected() { + // Distinct boundary instances do not make one exact invocation two. + let mut fixture = cohort_fixture(); + mutate_record( + &mut fixture, + 1, + "/boundary/invocation_digest", + json!("5".repeat(64)), + ); + let error = diagnose_collection(&fixture) + .expect_err("should reject identical role-specific invocation digests"); + assert!( + format!("{error:?}").contains("A/B share one exact invocation"), + "should reject a reproduction claiming the canonical invocation: {error:?}" + ); +} + +#[test] +fn reproduction_reusing_the_canonical_boundary_instance_is_rejected() { + let mut fixture = cohort_fixture(); + for pointer in ["/boundary/fingerprint", "/boundary/execution_fingerprint"] { + mutate_record(&mut fixture, 1, pointer, json!("3".repeat(64))); + } + let error = diagnose_collection(&fixture) + .expect_err("should reject one boundary instance serving both builds"); + assert!( + format!("{error:?}").contains("A/B share one boundary instance"), + "should reject a reproduction run in the canonical boundary: {error:?}" + ); +} + +/// Rebinds a changed imported composition summary so only its content is wrong. +fn rebind_composition(fixture: &mut ValidatedTransfers, index: usize, pointer: &str, value: Value) { + let members = &mut fixture.transfers[index].members; + let mut composition: Value = serde_json::from_slice(&members["composition.json"]) + .expect("should parse fictional composition"); + *composition + .pointer_mut(pointer) + .expect("should find composition field") = value; + let bytes = serde_json::to_vec(&composition).expect("should serialize changed composition"); + let hash = digest(&bytes); + members.insert("composition.json".to_owned(), bytes); + mutate_record(fixture, index, "/composition_sha256", json!(hash)); +} + +/// Adds an uploaded first-native reproduction with matched or mismatched bytes. +fn with_uploaded_native_b(same: bool) -> ValidatedTransfers { + let mut fixture = cohort_fixture(); + let mut reproduction = Transfer { + upload: fixture.transfers[2].upload.clone(), + members: fixture.transfers[2].members.clone(), + }; + reproduction.upload.role = "B".to_owned(); + reproduction.upload.job = "native-b".to_owned(); + reproduction.upload.artifact_id = 99; + fixture.transfers.push(reproduction); + fixture.absent.remove(0); + let index = fixture.transfers.len() - 1; + for (pointer, value) in [ + ("/role", json!("B")), + ("/job", json!("native-b")), + ("/boundary/fingerprint", json!("4".repeat(64))), + ("/boundary/execution_fingerprint", json!("4".repeat(64))), + ("/boundary/invocation_digest", json!("6".repeat(64))), + ("/ambient/umask", json!(23)), + ("/ambient/jobs", json!(2)), + ("/ambient/checkout_root", json!("/external-release-B")), + ("/ambient/started_at_seconds", json!(101)), + ("/ambient/host_locale", json!("en_US.UTF-8")), + ("/ambient/host_timezone", json!("Pacific/Auckland")), + ] { + mutate_record(&mut fixture, index, pointer, value); + } + if !same { + let artifact = fixture.transfers[index] + .members + .get_mut("artifact") + .expect("should find native B"); + artifact.push(1); + let (hash, size) = (digest(artifact), artifact.len()); + mutate_record(&mut fixture, index, "/artifact_sha256", json!(hash)); + mutate_record(&mut fixture, index, "/artifact_size", json!(size)); + for pointer in RECEIPT_ARTIFACT_BINDINGS { + rebind_composition(&mut fixture, index, pointer, json!(hash)); + } + } + fixture +} + +/// Every receipt field the producer binds to the exact final artifact digest. +const RECEIPT_ARTIFACT_BINDINGS: [&str; 3] = [ + "/composition/javascript/artifact_sha256", + "/composition/platform/artifact_sha256", + "/composition/platform/external_requirements/artifact_sha256", +]; + +/// Fixture receipt pointer to the registry dependency's package origin. +const DEPENDENCY_ORIGIN: &str = "/composition/package_origins/packages/registry+https:~1~1registry.example.com~1index#example-dep@1.0.0"; +/// Fixture receipt pointer to the workspace package's origin. +const WORKSPACE_ORIGIN: &str = + "/composition/package_origins/packages/path+file:~1~1~1workspace~1crates~1example-app#0.1.0"; + +/// Receipt changes that each break one collector composition check. +fn composition_gates() -> Vec<(String, Value)> { + let mut gates = RECEIPT_ARTIFACT_BINDINGS + .iter() + .map(|pointer| ((*pointer).to_owned(), json!("9".repeat(64)))) + .collect::>(); + gates.extend([ + ( + "/composition/package_origins/source_commit".to_owned(), + json!("9".repeat(40)), + ), + ( + format!("{WORKSPACE_ORIGIN}/origin/source_commit"), + json!("9".repeat(40)), + ), + ( + "/composition/javascript/inventory_sha256".to_owned(), + json!("9".repeat(64)), + ), + ( + "/composition/package_origins/materials_sha256".to_owned(), + json!("9".repeat(64)), + ), + ( + "/composition/platform/target".to_owned(), + json!("example-unknown-none"), + ), + ( + "/composition/platform/external_requirements/target".to_owned(), + json!("example-unknown-none"), + ), + ( + "/composition/native/material_native_coverage_complete".to_owned(), + json!(true), + ), + ( + "/composition/platform/material_native_coverage_complete".to_owned(), + json!(true), + ), + ("/observations/units".to_owned(), json!([])), + ( + "/composition/package_origins/packages".to_owned(), + json!({}), + ), + ( + format!("{DEPENDENCY_ORIGIN}/manifest_path"), + json!("crates/example-app/Cargo.toml"), + ), + ( + format!("{DEPENDENCY_ORIGIN}/prepared_manifest_sha256"), + json!("not-a-digest"), + ), + ("/metadata_plan_sha256".to_owned(), json!("7".repeat(63))), + ("/tree_plan_sha256".to_owned(), json!("8".repeat(65))), + ( + "/composition/javascript/modules/0/module/sha256".to_owned(), + json!("not-a-digest"), + ), + ("/composition/javascript/modules".to_owned(), json!([])), + ( + "/composition/graph/selected_package_id".to_owned(), + json!("path+file:///workspace/crates/another#0.1.0"), + ), + ( + "/observations/units/0/package_id".to_owned(), + json!("path+file:///workspace/crates/another#0.1.0"), + ), + ( + "/composition/native/contributors/0/original_sha256".to_owned(), + json!("c3".repeat(31)), + ), + ]); + gates +} + +#[test] +fn every_composition_receipt_gate_rejects_its_changed_fact() { + for (pointer, value) in composition_gates() { + let mut fixture = cohort_fixture(); + rebind_composition(&mut fixture, 0, &pointer, value); + let error = diagnose_collection(&fixture) + .expect_err("should reject a changed composition receipt fact"); + assert!( + format!("{error:?}").contains("composition receipt"), + "should reject receipt {pointer} at a composition receipt gate: {error:?}" + ); + } +} + +#[test] +fn composition_receipt_shape_is_strict() { + let fixture = cohort_fixture(); + let receipt: Value = serde_json::from_slice(&fixture.transfers[0].members["composition.json"]) + .expect("should parse fixture receipt"); + let summary = json!({ + "artifact_sha256": digest(&fixture.transfers[0].members["artifact"]), + "source_commit": fixture.invocation.source_commit, + "inventory_sha256": fixture.invocation.inventory_sha256, + "contributing_sources": ["d".repeat(64)], + "compiled_units": 1, + }); + let mut extra_top = receipt.clone(); + extra_top["coverage_complete"] = json!(true); + let mut extra_nested = receipt.clone(); + extra_nested["composition"]["javascript"]["function_reachability"] = json!(true); + let mut missing = receipt.clone(); + missing["composition"] + .as_object_mut() + .expect("should find composition") + .remove("native"); + let mut extra_origin = receipt.clone(); + extra_origin + .pointer_mut(&format!("{DEPENDENCY_ORIGIN}/origin/original")) + .and_then(Value::as_object_mut) + .expect("should find the registry original") + .insert("verified".to_owned(), json!(true)); + let mut missing_apple = receipt.clone(); + missing_apple["composition"]["platform"] + .as_object_mut() + .expect("should find platform") + .remove("apple"); + let serialized = |value: &Value| serde_json::to_vec(value).expect("should serialize receipt"); + let text = String::from_utf8(serialized(&receipt)).expect("should be UTF-8"); + let duplicate = text + .replacen( + "\"metadata_plan_sha256\"", + &format!( + "\"metadata_plan_sha256\":\"{}\",\"metadata_plan_sha256\"", + "7".repeat(64) + ), + 1, + ) + .into_bytes(); + for (name, bytes) in [ + ("collector summary", serialized(&summary)), + ("extra top-level field", serialized(&extra_top)), + ("extra nested field", serialized(&extra_nested)), + ("missing component", serialized(&missing)), + ("extra tagged origin field", serialized(&extra_origin)), + ("missing opaque platform member", serialized(&missing_apple)), + ("duplicate key", duplicate), + ] { + let mut changed = cohort_fixture(); + let hash = digest(&bytes); + changed.transfers[0] + .members + .insert("composition.json".to_owned(), bytes); + mutate_record(&mut changed, 0, "/composition_sha256", json!(hash)); + let error = format!( + "{:?}", + diagnose_collection(&changed).expect_err("should reject a malformed receipt") + ); + assert!( + !error.contains("composition receipt") && !error.contains("digest changed"), + "should reject a receipt with {name} while parsing it: {error}" + ); + } +} + +#[test] +fn receipt_coverage_claims_apple_evidence_and_runtime_format_follow_the_target() { + // Transfers 0, 2, 3 and 4 are Fastly, both Darwin CLIs and the Linux CLI. + let cases = [ + ( + 0, + "/composition/native/material_native_coverage_complete", + json!(true), + "claims complete material native coverage", + ), + ( + 4, + "/composition/platform/material_native_coverage_complete", + json!(true), + "claims complete material native coverage", + ), + ( + 0, + "/composition/platform/apple", + json!({"sdk_version": "15.0"}), + "Apple evidence does not match its target", + ), + ( + 4, + "/composition/platform/apple", + json!({"sdk_version": "15.0"}), + "Apple evidence does not match its target", + ), + ( + 2, + "/composition/platform/apple", + Value::Null, + "Apple evidence does not match its target", + ), + ( + 0, + "/composition/platform/external_requirements/runtime/format", + json!("elf"), + "runtime format does not match its target", + ), + ( + 3, + "/composition/platform/external_requirements/runtime/format", + json!("wasm"), + "runtime format does not match its target", + ), + ( + 4, + "/composition/platform/external_requirements/runtime/format", + json!("macho"), + "runtime format does not match its target", + ), + ]; + for (index, pointer, value, expected) in cases { + let mut fixture = cohort_fixture(); + rebind_composition(&mut fixture, index, pointer, value); + let error = diagnose_collection(&fixture).expect_err("should reject the receipt"); + assert!( + format!("{error:?}").contains(expected), + "should reject transfer {index} receipt {pointer} with {expected}: {error:?}" + ); + } +} + +#[test] +fn receipt_entry_point_binds_producer_facts_for_every_fixed_asset() { + let (artifact, source, inventory, materials) = ( + "a".repeat(64), + "b".repeat(40), + "c".repeat(64), + "e".repeat(64), + ); + let facts = |asset: &'static str| ReceiptFacts { + artifact_sha256: &artifact, + source_commit: &source, + inventory_sha256: &inventory, + materials_sha256: &materials, + asset, + }; + for fixed in &crate::policy::FIXED_ARTIFACTS { + let receipt = composition_receipt(fixed.target.triple(), &artifact, &source, &inventory); + check_composition_receipt(&receipt, &facts(fixed.name)).unwrap_or_else(|error| { + panic!("should accept the receipt for {}: {error:?}", fixed.name) + }); + let other_materials = "9".repeat(64); + let error = check_composition_receipt( + &receipt, + &ReceiptFacts { + materials_sha256: &other_materials, + ..facts(fixed.name) + }, + ) + .expect_err("should reject other build materials"); + assert!( + format!("{error:?}").contains("not bound to the build materials"), + "should reject {} receipt for other materials: {error:?}", + fixed.name + ); + } + let fastly = &crate::policy::FIXED_ARTIFACTS[0]; + let receipt = composition_receipt(fastly.target.triple(), &artifact, &source, &inventory); + let error = check_composition_receipt(&receipt, &facts(crate::policy::FIXED_ARTIFACTS[3].name)) + .expect_err("should reject a receipt for another asset target"); + assert!( + format!("{error:?}").contains("composition receipt names another target"), + "should derive the target from the asset: {error:?}" + ); + let error = check_composition_receipt(&receipt, &facts("unknown-asset")) + .expect_err("should reject an unknown asset"); + assert!( + format!("{error:?}").contains("unknown cohort artifact"), + "should reject an asset outside the fixed set: {error:?}" + ); +} + +#[test] +fn fastly_receipt_must_embed_every_js_module_while_native_may_not() { + let pointer = "/composition/javascript/modules/0/classification"; + let mut fastly = cohort_fixture(); + rebind_composition(&mut fastly, 0, pointer, json!("js_build_material")); + let error = diagnose_collection(&fastly).expect_err("should reject unembedded Fastly JS"); + assert!( + format!("{error:?}").contains("composition receipt omits a complete Fastly JS module"), + "should reject a Fastly receipt with an unembedded module: {error:?}" + ); + let mut native = cohort_fixture(); + rebind_composition(&mut native, 2, pointer, json!("js_build_material")); + diagnose_collection(&native).expect("should accept CLI-discarded JS build material"); +} + +/// Every optional reproduction state: never started, failed, matched, mismatched. +fn every_optional_reproduction_state() -> Vec<(&'static str, ValidatedTransfers)> { + let mut failed = cohort_fixture(); + for absent in &mut failed.absent { + absent.job = FAILED_BEFORE_UPLOAD; + absent.elapsed_seconds = 7; + } + vec![ + ("unavailable", cohort_fixture()), + ("failed", failed), + ("matched", with_uploaded_native_b(true)), + ("mismatched", with_uploaded_native_b(false)), + ] +} + +#[test] +fn every_optional_reproduction_state_is_accepted_with_honest_outcomes() { + for (state, fixture) in every_optional_reproduction_state() { + let diagnostic = diagnose_collection(&fixture) + .unwrap_or_else(|error| panic!("should accept optional B state {state}: {error:?}")); + assert_eq!( + serde_json::to_value(diagnostic.native[crate::policy::FIXED_ARTIFACTS[1].name]) + .expect("should serialize outcome"), + json!(state), + "should record exactly the {state} outcome" + ); + assert_eq!( + diagnostic.canonical.len(), + 4, + "should preserve all canonical artifacts once mandatory gates pass" + ); + } +} + +#[test] +fn no_optional_reproduction_state_hides_a_mandatory_canonical_failure() { + let record_gates = [ + ("/boundary/confined", json!(false)), + ("/boundary/control_exit", json!(1)), + ("/boundary/control_accepts", json!(0)), + ("/boundary/control_accepts", json!(2)), + ("/boundary/probe_exit", json!(0)), + ("/boundary/probe_errno", json!(111)), + ("/boundary/probe_errno", json!(0)), + ("/boundary/probe_accepts", json!(1)), + ("/boundary/execution_fingerprint", json!("9".repeat(64))), + ("/boundary/invocation_digest", json!("5".repeat(63))), + ("/inventory_sha256", json!("9".repeat(64))), + ("/composition_sha256", json!("9".repeat(64))), + ("/recipe_sha256", json!("9".repeat(64))), + ("/artifact_size", json!(1)), + ("/ambient/child_locale", json!("en_US.UTF-8")), + ("/claim/varied", json!(["umask", "jobs", "checkout_root"])), + ]; + let composition_gates = composition_gates(); + for canonical in [0, 2, 3, 4] { + for (state, fixture) in every_optional_reproduction_state() { + for (pointer, value) in &record_gates { + let mut changed = fixture_clone(&fixture); + mutate_record(&mut changed, canonical, pointer, value.clone()); + assert!( + diagnose_collection(&changed).is_err(), + "should block canonical {canonical} {pointer} with optional B {state}" + ); + } + for (pointer, value) in &composition_gates { + let mut changed = fixture_clone(&fixture); + rebind_composition(&mut changed, canonical, pointer, value.clone()); + assert!( + diagnose_collection(&changed).is_err(), + "should block canonical {canonical} composition {pointer} with optional B {state}" + ); + } + let mut changed = fixture_clone(&fixture); + changed.transfers[canonical] + .members + .insert("tsjs-inventory.json".to_owned(), b"{}".to_vec()); + assert!( + diagnose_collection(&changed).is_err(), + "should block changed JS inventory bytes in canonical {canonical} with optional B {state}" + ); + let mut changed = fixture_clone(&fixture); + changed.transfers[canonical] + .members + .get_mut("artifact") + .expect("should find canonical artifact") + .push(0); + assert!( + diagnose_collection(&changed).is_err(), + "should block changed final bytes in canonical {canonical} with optional B {state}" + ); + } + } +} + +#[test] +fn fastly_runtime_proof_is_required_for_both_roles_and_absent_from_native() { + for index in [0, 1] { + for (pointer, value) in [ + ("/runtime", Value::Null), + ("/runtime/source_commit", json!("9".repeat(40))), + ("/runtime/final_wasm_size", json!(1)), + ("/runtime/viceroy_version", json!("0.0.0")), + ("/runtime/viceroy_version", json!("0.17.0")), + ] { + let mut fixture = cohort_fixture(); + mutate_record(&mut fixture, index, pointer, value); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject Fastly role {index} without bound final-byte runtime {pointer}" + ); + } + } + let runtime = serde_json::from_slice::( + &cohort_fixture().transfers[0].members["build-record.json"], + ) + .expect("should parse Fastly record")["runtime"] + .clone(); + for index in [2, 3, 4] { + let mut fixture = cohort_fixture(); + mutate_record(&mut fixture, index, "/runtime", runtime.clone()); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject a native record carrying a Fastly runtime diagnostic" + ); + } +} + +#[test] +fn fastly_runtime_proof_must_belong_to_its_own_build_role() { + let runtime_of = |fixture: &ValidatedTransfers, index: usize| { + serde_json::from_slice::(&fixture.transfers[index].members["build-record.json"]) + .expect("should parse Fastly record")["runtime"] + .clone() + }; + let fixture = cohort_fixture(); + assert_eq!( + runtime_of(&fixture, 0)["role"], + "A", + "should bind the canonical runtime proof to Role::Canonical" + ); + assert_eq!( + runtime_of(&fixture, 1)["role"], + "B", + "should bind the reproduction runtime proof to Role::Reproduction" + ); + // Equal final bytes make the proofs otherwise interchangeable, so each + // case swaps in the other role's run or an unbound role. + for (index, other) in [(0, 1), (1, 0)] { + let mut fixture = cohort_fixture(); + let reused = runtime_of(&fixture, other); + mutate_record(&mut fixture, index, "/runtime", reused); + let error = diagnose_collection(&fixture) + .expect_err("should reject one role's runtime run reused for the other"); + assert!( + format!("{error:?}").contains("runtime diagnostic"), + "should reject reused runtime proof at the runtime gate: {error:?}" + ); + for role in [json!("C"), json!(""), Value::Null] { + let mut fixture = cohort_fixture(); + mutate_record(&mut fixture, index, "/runtime/role", role.clone()); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject Fastly record {index} with runtime role {role}" + ); + } + let mut fixture = cohort_fixture(); + let mut unbound = runtime_of(&fixture, index); + unbound + .as_object_mut() + .expect("should hold runtime fields") + .remove("role"); + mutate_record(&mut fixture, index, "/runtime", unbound); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject Fastly record {index} whose runtime proof carries no role" + ); + } +} + +/// Optional reproduction jobs that never uploaded, with their spec outcomes. +/// +/// `unavailable` means no reproduction ran: still queued at the deadline, or +/// skipped/cancelled by the scheduler before any step started. `failed` means +/// the job started, or its observed conclusion itself states failure. +fn no_upload_job_states() -> Vec<(&'static str, ObservedJob, u64, records::Outcome)> { + let started = |conclusion| ObservedJob { + started: true, + conclusion, + }; + let not_started = |conclusion| ObservedJob { + started: false, + conclusion, + }; + vec![ + ( + "queued at deadline", + QUEUED, + 1800, + records::Outcome::Unavailable, + ), + ( + "scheduler skipped", + not_started(Some(JobConclusion::Skipped)), + 3, + records::Outcome::Unavailable, + ), + ( + "scheduler cancelled before start", + not_started(Some(JobConclusion::Cancelled)), + 3, + records::Outcome::Unavailable, + ), + ( + "failed before any step", + not_started(Some(JobConclusion::Failure)), + 3, + records::Outcome::Failed, + ), + ( + "timed out before any step", + not_started(Some(JobConclusion::TimedOut)), + 1800, + records::Outcome::Failed, + ), + ( + "failed before upload", + FAILED_BEFORE_UPLOAD, + 7, + records::Outcome::Failed, + ), + ( + "cancelled after start", + started(Some(JobConclusion::Cancelled)), + 7, + records::Outcome::Failed, + ), + ( + "timed out after start", + started(Some(JobConclusion::TimedOut)), + 1800, + records::Outcome::Failed, + ), + ( + "succeeded without upload output", + started(Some(JobConclusion::Success)), + 7, + records::Outcome::Failed, + ), + ( + "still running at deadline", + started(None), + 1800, + records::Outcome::Failed, + ), + ] +} + +#[test] +fn optional_reproduction_job_states_map_to_bounded_honest_outcomes() { + for (state, job, elapsed, expected) in no_upload_job_states() { + let mut fixture = cohort_fixture(); + fixture.absent[0] = no_upload(crate::policy::FIXED_ARTIFACTS[1].name, job, elapsed); + let diagnostic = diagnose_collection(&fixture) + .unwrap_or_else(|error| panic!("should accept optional B {state}: {error:?}")); + assert_eq!( + diagnostic.native[crate::policy::FIXED_ARTIFACTS[1].name], + expected, + "should map optional B {state} to its spec outcome" + ); + assert_eq!( + diagnostic.canonical.len(), + 4, + "should keep every canonical artifact with optional B {state}" + ); + let reproduction = + &diagnostic.rebuild[crate::policy::FIXED_ARTIFACTS[1].name]["reproduction"]; + assert_eq!( + reproduction["outcome"], + serde_json::to_value(expected).expect("should serialize outcome"), + "should record the derived outcome for {state}" + ); + assert_eq!( + reproduction["job_state"]["started"], job.started, + "should record whether {state} started" + ); + for field in ["artifact_id", "artifact_name", "archive_sha256", "ambient"] { + assert!( + reproduction.get(field).is_none(), + "should not synthesize {field} for optional B {state}" + ); + } + } +} + +#[test] +fn contradictory_premature_or_unbounded_optional_job_states_are_rejected() { + let cases = [ + ( + "success without starting", + ObservedJob { + started: false, + conclusion: Some(JobConclusion::Success), + }, + 7, + 1800, + ), + ( + "skipped after starting", + ObservedJob { + started: true, + conclusion: Some(JobConclusion::Skipped), + }, + 7, + 1800, + ), + ("queued before the deadline", QUEUED, 1799, 1800), + ( + "running before the deadline", + ObservedJob { + started: true, + conclusion: None, + }, + 1799, + 1800, + ), + ("waited past the deadline", QUEUED, 1801, 1800), + ("missing deadline", FAILED_BEFORE_UPLOAD, 0, 0), + ("deadline beyond the cohort cap", QUEUED, 5401, 5401), + ]; + for (state, job, elapsed, deadline) in cases { + let absent = NoUpload { + asset: crate::policy::FIXED_ARTIFACTS[1].name.to_owned(), + job, + elapsed_seconds: elapsed, + deadline_seconds: deadline, + }; + let mut fixture = cohort_fixture(); + fixture.absent[0] = absent.clone(); + assert!( + diagnose_collection(&fixture).is_err(), + "should reject optional B {state} in the measurement" + ); + let (mut dependencies, mut source) = transfer_fixture(); + dependencies.optional = vec![OptionalBuild::NotUploaded(absent)]; + assert!( + validate_transfers(&dependencies, &mut source).is_err(), + "should reject optional B {state} before any transfer" + ); + assert!( + source.requested.is_empty(), + "should reject optional B {state} before fetching any artifact" + ); + } +} + +#[test] +fn optional_upload_identity_requires_a_started_successful_job() { + let uploaded_as_optional = |job| { + let (mut dependencies, source) = transfer_fixture(); + let mut upload = dependencies.required.remove(0); + upload.role = "B".to_owned(); + upload.asset = crate::policy::FIXED_ARTIFACTS[1].name.to_owned(); + dependencies.optional = vec![OptionalBuild::Uploaded { upload, job }]; + (dependencies, source) + }; + let (dependencies, mut source) = uploaded_as_optional(ObservedJob { + started: true, + conclusion: Some(JobConclusion::Success), + }); + validate_transfers(&dependencies, &mut source) + .expect("should accept an upload from a started successful optional job"); + for (state, job, _, _) in no_upload_job_states() + .into_iter() + .filter(|(state, ..)| *state != "succeeded without upload output") + { + let (dependencies, mut source) = uploaded_as_optional(job); + assert!( + validate_transfers(&dependencies, &mut source).is_err(), + "should reject an upload identity claimed by optional B {state}" + ); + assert!( + source.requested.is_empty(), + "should reject the claimed upload for {state} before fetching it" + ); + } +} + +#[test] +fn dependency_channels_only_accept_their_own_build_roles() { + let succeeded = ObservedJob { + started: true, + conclusion: Some(JobConclusion::Success), + }; + let native = crate::policy::FIXED_ARTIFACTS[1].name; + let fastly = crate::policy::FIXED_ARTIFACTS[0].name; + // The optional channel carries only native reproductions. + for (asset, role) in [(native, "A"), (fastly, "B"), (fastly, "A")] { + let (mut dependencies, mut source) = transfer_fixture(); + let mut upload = dependencies.required.remove(0); + upload.asset = asset.to_owned(); + upload.role = role.to_owned(); + dependencies.optional = vec![OptionalBuild::Uploaded { + upload, + job: succeeded, + }]; + assert!( + validate_transfers(&dependencies, &mut source).is_err(), + "should reject {asset} role {role} supplied through the optional channel" + ); + assert!( + source.requested.is_empty(), + "should reject optional {asset} role {role} before any fetch" + ); + } + // The mandatory channel carries every A and only the Fastly reproduction. + let (dependencies, mut source) = transfer_fixture(); + validate_transfers(&dependencies, &mut source).expect("should accept a mandatory Fastly A"); + for (asset, role, accepted) in [(fastly, "B", true), (native, "B", false)] { + let (mut dependencies, mut source) = transfer_fixture(); + dependencies.required[0].asset = asset.to_owned(); + dependencies.required[0].role = role.to_owned(); + let result = validate_transfers(&dependencies, &mut source); + assert_eq!( + result.is_ok(), + accepted, + "should only accept mandatory B for the Fastly asset ({asset})" + ); + if !accepted { + assert!( + source.requested.is_empty(), + "should reject mandatory native B before any fetch" + ); + } + } +} + +#[test] +fn missing_mandatory_canonical_rejects_under_every_optional_reproduction_state() { + let mut states = every_optional_reproduction_state(); + for (state, job, elapsed, _) in no_upload_job_states() { + let mut fixture = cohort_fixture(); + for absent in &mut fixture.absent { + absent.job = job; + absent.elapsed_seconds = elapsed; + } + states.push((state, fixture)); + } + for (state, fixture) in &states { + for asset in crate::policy::FIXED_ARTIFACTS { + let mut changed = fixture_clone(fixture); + changed.transfers.retain(|transfer| { + transfer.upload.asset != asset.name || transfer.upload.role != "A" + }); + let error = diagnose_collection(&changed) + .expect_err("should reject a missing, failed or cancelled mandatory A"); + assert!( + format!("{error:?}").contains("missing mandatory A"), + "should block missing A {} with optional B {state}: {error:?}", + asset.name + ); + } + } +} + +/// Copies one fixture so each mandatory gate mutates an independent cohort. +fn fixture_clone(fixture: &ValidatedTransfers) -> ValidatedTransfers { + ValidatedTransfers { + invocation: fixture.invocation.clone(), + transfers: fixture + .transfers + .iter() + .map(|transfer| Transfer { + upload: transfer.upload.clone(), + members: transfer.members.clone(), + }) + .collect(), + absent: fixture.absent.clone(), + } +} diff --git a/crates/trusted-server-release/src/collection/transfer.rs b/crates/trusted-server-release/src/collection/transfer.rs new file mode 100644 index 0000000000..42c421cdeb --- /dev/null +++ b/crates/trusted-server-release/src/collection/transfer.rs @@ -0,0 +1,649 @@ +//! Fixed-origin read-only artifact transport; dependency qualification stays separate. +//! +//! Each operation owns an async runtime and uses asynchronous DNS, built-in +//! `WebPKI` roots, no proxies and no automatic redirects. Host DNS configuration +//! remains a bootstrap OS input; this transport does not authenticate that OS or +//! confer workflow, source, tool-acquisition or signing authority. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::future::Future; +use std::time::{Duration, Instant}; + +use error_stack::Report; +use reqwest::header::{ + ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_ENCODING, HeaderValue, LOCATION, +}; +use reqwest::{Client, ClientBuilder, Response, StatusCode, Url}; +use sha2::{Digest as _, Sha256}; + +use super::{ + ARCHIVE_LIMIT, ArtifactSource, Download, OptionalBuild, TrustedDependencies, collection_error, +}; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +const REPOSITORY: &str = "IABTechLab/trusted-server"; +const REPOSITORY_ID: u64 = 950860906; +const API_ROOT: &str = "https://api.github.com/repos/IABTechLab/trusted-server/"; // allow-domain: api.github.com +// The documented Actions artifact storage suffix is accepted only in a +// Location returned by the exact authenticated API download request. +const STORAGE_SUFFIX: &str = ".blob.core.windows.net"; +const RESPONSE_WALL: Duration = Duration::from_secs(120); +const MAX_ATTEMPT: Duration = Duration::from_secs(900); +const CANCEL_POLL: Duration = Duration::from_millis(20); +const SHUTDOWN_GRACE: Duration = Duration::from_millis(100); +const URL_LIMIT: usize = 8192; +const BODY_CHUNK: usize = 64 * 1024; + +type Result = core::result::Result>; + +struct Endpoints { + api: Url, + storage_suffix: &'static str, + storage_port: Option, + #[cfg(test)] + addresses: Vec<(&'static str, std::net::SocketAddr)>, + #[cfg(test)] + resolver: Option>, +} + +impl Endpoints { + fn official() -> Result { + Ok(Self { + api: Url::parse(API_ROOT) + .map_err(|_| collection_error("compiled artifact API origin is invalid"))?, + storage_suffix: STORAGE_SUFFIX, + storage_port: None, + #[cfg(test)] + addresses: Vec::new(), + #[cfg(test)] + resolver: None, + }) + } + + fn signed_location(&self, text: &str) -> Result { + if text.len() > URL_LIMIT + || text + .bytes() + .any(|byte| !byte.is_ascii_graphic() || byte == b'\\') + { + return Err(collection_error("artifact download location is invalid")); + } + let url = Url::parse(text) + .map_err(|_| collection_error("artifact download location is invalid"))?; + let account = url + .host_str() + .and_then(|host| host.strip_suffix(self.storage_suffix)); + if url.scheme() != self.api.scheme() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url.port() != self.storage_port + || !account.is_some_and(|account| { + !account.is_empty() + && account + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) + { + return Err(collection_error( + "artifact download location has an unapproved origin", + )); + } + Ok(url) + } + + fn client(&self) -> Result { + let builder = Client::builder() + .use_rustls_tls() + .tls_built_in_root_certs(false) + .tls_built_in_webpki_certs(true) + .hickory_dns(true) + .https_only(self.api.scheme() == "https") + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .pool_max_idle_per_host(0) + .connect_timeout(Duration::from_secs(15)) + .user_agent("trusted-server-release"); + self.build_client(builder) + } + + #[cfg(not(test))] + fn build_client(&self, builder: ClientBuilder) -> Result { + builder + .build() + .map_err(|_| collection_error("cannot initialize artifact HTTPS client")) + } + + #[cfg(test)] + fn build_client(&self, mut builder: ClientBuilder) -> Result { + for (host, address) in &self.addresses { + builder = builder.resolve(host, *address); + } + if let Some(resolver) = &self.resolver { + builder = builder.dns_resolver2(std::sync::Arc::clone(resolver)); + } + builder + .build() + .map_err(|_| collection_error("cannot initialize artifact HTTPS client")) + } + + #[cfg(test)] + fn fixture(api: &str, storage_suffix: &'static str, storage_port: Option) -> Self { + Self { + api: Url::parse(api).expect("should parse fictional API endpoint"), + storage_suffix, + storage_port, + addresses: Vec::new(), + resolver: None, + } + } +} + +struct Credential(HeaderValue); + +impl Credential { + fn new(token: &str) -> Result { + if token.is_empty() + || token.len() > URL_LIMIT + || !token.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(collection_error("artifact API credential is invalid")); + } + let mut value = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| collection_error("artifact API credential is invalid"))?; + value.set_sensitive(true); + Ok(Self(value)) + } +} + +impl fmt::Debug for Credential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Credential([REDACTED])") + } +} + +struct SelectedArtifact { + name: String, + sha256: String, +} + +struct Selection { + repository: String, + repository_id: u64, + run_id: u64, + source_commit: String, + artifacts: BTreeMap, +} + +impl Selection { + fn from_dependencies(dependencies: &TrustedDependencies) -> Result { + let invocation = &dependencies.invocation; + if invocation.repository != REPOSITORY || invocation.repository_id != REPOSITORY_ID { + return Err(collection_error( + "artifact dependency has another repository identity", + )); + } + let mut artifacts = BTreeMap::new(); + for upload in dependencies + .required + .iter() + .chain( + dependencies + .optional + .iter() + .filter_map(|optional| match optional { + OptionalBuild::Uploaded { upload, .. } => Some(upload), + OptionalBuild::NotUploaded(_) => None, + }), + ) + { + if artifacts + .insert( + upload.artifact_id, + SelectedArtifact { + name: upload.artifact_name.clone(), + sha256: upload.archive_sha256.clone(), + }, + ) + .is_some() + { + return Err(collection_error("artifact dependency ID is reused")); + } + } + let selection = Self { + repository: invocation.repository.clone(), + repository_id: invocation.repository_id, + run_id: invocation.run_id, + source_commit: invocation.source_commit.clone(), + artifacts, + }; + selection.check()?; + Ok(selection) + } + + fn check(&self) -> Result<()> { + if self.repository_id == 0 + || self.run_id == 0 + || !lower_hex(&self.source_commit, 40) + || self.artifacts.is_empty() + || self.artifacts.len() > 8 + || self.artifacts.iter().any(|(id, artifact)| { + *id == 0 + || !lower_hex(&artifact.sha256, 64) + || artifact.name.is_empty() + || artifact.name.len() > 128 + || artifact.name.chars().any(char::is_control) + }) + { + return Err(collection_error( + "artifact selection has invalid bounded identities", + )); + } + Ok(()) + } + + fn selected(&self, repository: &str, id: u64) -> Result<&SelectedArtifact> { + if repository != self.repository { + return Err(collection_error("artifact request has another repository")); + } + self.artifacts + .get(&id) + .ok_or_else(|| collection_error("artifact request ID was not selected")) + } + + fn metadata(&self, id: u64, raw: &[u8]) -> Result<()> { + let expected = self.selected(&self.repository, id)?; + let value = parse_json(raw, Role::Metadata) + .map_err(|_| collection_error("artifact metadata is not strict bounded JSON"))?; + if value["id"].as_u64() != Some(id) + || value["name"].as_str() != Some(expected.name.as_str()) + || value["expired"].as_bool() != Some(false) + || value["digest"].as_str() != Some(format!("sha256:{}", expected.sha256).as_str()) + || value["workflow_run"]["id"].as_u64() != Some(self.run_id) + || value["workflow_run"]["head_sha"].as_str() != Some(self.source_commit.as_str()) + || value["workflow_run"]["repository_id"].as_u64() != Some(self.repository_id) + || value["workflow_run"]["head_repository_id"].as_u64() != Some(self.repository_id) + { + return Err(collection_error( + "artifact REST metadata differs from selected invocation", + )); + } + Ok(()) + } +} + +/// Bounded fixed-origin source; it cannot construct trusted workflow dependencies. +pub(crate) struct HttpArtifactSource { + selection: Selection, + credential: Credential, + endpoints: Endpoints, + deadline: Instant, + cancellation: Cancellation, + verified_metadata: BTreeSet, +} + +impl fmt::Debug for HttpArtifactSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HttpArtifactSource") + .field("selected_artifacts", &self.selection.artifacts.len()) + .field("credential", &self.credential) + .finish_non_exhaustive() + } +} + +impl HttpArtifactSource { + /// Binds transport to dependency-selected exact identities and an explicit token. + /// + /// Qualified dependency creation remains unavailable. Token possession alone + /// cannot establish producer job/attempt, source or workflow authority. + /// + /// # Errors + /// + /// Rejects invalid fixed repository/selection identities, credentials or bounds. + pub(crate) fn from_dependencies( + dependencies: &TrustedDependencies, + token: &str, + deadline: Instant, + cancellation: Cancellation, + ) -> Result { + Self::new( + Selection::from_dependencies(dependencies)?, + token, + deadline, + cancellation, + Endpoints::official()?, + ) + } + + fn new( + selection: Selection, + token: &str, + deadline: Instant, + cancellation: Cancellation, + endpoints: Endpoints, + ) -> Result { + selection.check()?; + if deadline + .checked_duration_since(Instant::now()) + .is_none_or(|remaining| remaining > MAX_ATTEMPT) + || cancellation.is_cancelled() + { + return Err(collection_error( + "artifact transport attempt is expired or cancelled", + )); + } + Ok(Self { + selection, + credential: Credential::new(token)?, + endpoints, + deadline, + cancellation, + verified_metadata: BTreeSet::new(), + }) + } + + fn run(&self, operation: impl Future>) -> Result { + if self.cancellation.is_cancelled() || Instant::now() >= self.deadline { + return Err(collection_error( + "artifact transport attempt is expired or cancelled", + )); + } + if tokio::runtime::Handle::try_current().is_ok() { + return Err(collection_error( + "synchronous artifact transport cannot run inside another async runtime", + )); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| collection_error("cannot initialize artifact transport runtime"))?; + let deadline = self.deadline.min(Instant::now() + RESPONSE_WALL); + let result = runtime.block_on(async { + tokio::select! { + biased; + () = wait_for_cancel(&self.cancellation) => Err(collection_error("artifact transport was cancelled")), + () = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => Err(collection_error("artifact transport exceeded its deadline")), + result = operation => result, + } + }); + // Asynchronous DNS/HTTP tasks belong to this operation's runtime. No + // blocking OS resolver is selected, and no pool spans operations. + runtime.shutdown_timeout(SHUTDOWN_GRACE); + result + } + + fn api_url(&self, id: u64, download: bool) -> Result { + self.endpoints + .api + .join(&format!( + "actions/artifacts/{id}{}", + if download { "/zip" } else { "" } + )) + .map_err(|_| collection_error("artifact API path is invalid")) + } + + async fn api_request(&self, id: u64, download: bool) -> Result { + let url = self.api_url(id, download)?; + let response = self + .endpoints + .client()? + .get(url.clone()) + .header(AUTHORIZATION, self.credential.0.clone()) + .header(ACCEPT, "application/vnd.github+json") + .header(ACCEPT_ENCODING, "identity") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|_| collection_error("artifact API HTTPS request failed"))?; + if response.url() != &url { + return Err(collection_error( + "artifact API changed its fixed request URL", + )); + } + Ok(response) + } +} + +impl ArtifactSource for HttpArtifactSource { + fn metadata(&mut self, repository: &str, id: u64) -> Result> { + self.selection.selected(repository, id)?; + self.verified_metadata.remove(&id); + let raw = self.run(async { + let response = self.api_request(id, false).await?; + require_status(&response, StatusCode::OK)?; + read_response(response, Role::Metadata.limit() as usize, self).await + })?; + self.selection.metadata(id, &raw.bytes)?; + self.verified_metadata.insert(id); + Ok(raw.bytes) + } + + fn download(&mut self, repository: &str, id: u64) -> Result { + let expected = &self.selection.selected(repository, id)?.sha256; + if !self.verified_metadata.remove(&id) { + return Err(collection_error( + "artifact download lacks checked exact-ID REST metadata", + )); + } + let raw = self.run(async { + let response = self.api_request(id, true).await?; + require_status(&response, StatusCode::FOUND)?; + let mut locations = response.headers().get_all(LOCATION).iter(); + let text = locations + .next() + .and_then(|location| location.to_str().ok()) + .ok_or_else(|| { + collection_error("artifact API omitted its single download location") + })?; + if locations.next().is_some() { + return Err(collection_error( + "artifact API returned duplicate download locations", + )); + } + let location = self.endpoints.signed_location(text)?; + drop(response); + let response = self + .endpoints + .client()? + .get(location.clone()) + .header(ACCEPT_ENCODING, "identity") + .send() + .await + .map_err(|_| collection_error("artifact storage HTTPS request failed"))?; + if response.url() != &location { + return Err(collection_error( + "artifact storage changed its approved request URL", + )); + } + require_status(&response, StatusCode::OK)?; + read_response(response, ARCHIVE_LIMIT, self).await + })?; + if raw.sha256 != *expected { + return Err(collection_error( + "artifact archive differs from the selected upload digest", + )); + } + Ok(Download { + archive: raw.bytes, + transfer_warning: false, + }) + } +} + +struct Body { + bytes: Vec, + sha256: String, +} + +fn require_status(response: &Response, expected: StatusCode) -> Result<()> { + if response.status() != expected { + return Err(collection_error(format!( + "artifact endpoint returned unexpected HTTP status {}", + response.status().as_u16() + ))); + } + Ok(()) +} + +async fn read_response( + mut response: Response, + limit: usize, + owner: &HttpArtifactSource, +) -> Result { + if response + .content_length() + .is_some_and(|size| size > limit as u64) + || response.headers().get_all(CONTENT_ENCODING).iter().count() > 1 + || response + .headers() + .get_all(CONTENT_ENCODING) + .iter() + .any(|encoding| encoding != "identity") + { + return Err(collection_error( + "artifact response has an oversized, ambiguous or transformed body", + )); + } + let mut bytes = Vec::new(); + let mut hash = Sha256::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| collection_error("artifact response body is truncated or unreadable"))? + { + for part in chunk.chunks(BODY_CHUNK) { + if owner.cancellation.is_cancelled() || Instant::now() >= owner.deadline { + return Err(collection_error( + "artifact streaming attempt expired or was cancelled", + )); + } + append_chunk(&mut bytes, &mut hash, part, limit)?; + } + } + Ok(Body { + bytes, + sha256: hex::encode(hash.finalize()), + }) +} + +fn append_chunk(bytes: &mut Vec, hash: &mut Sha256, chunk: &[u8], limit: usize) -> Result<()> { + if bytes.len() > limit || chunk.len() > limit.saturating_sub(bytes.len()) { + return Err(collection_error( + "artifact response exceeds its streaming byte bound", + )); + } + let required = bytes.len() + chunk.len(); + if required > bytes.capacity() { + let capacity = bytes + .capacity() + .saturating_mul(2) + .max(BODY_CHUNK) + .min(limit) + .max(required); + bytes + .try_reserve_exact(capacity - bytes.len()) + .map_err(|_| collection_error("cannot allocate bounded artifact response"))?; + } + bytes.extend_from_slice(chunk); + hash.update(chunk); + Ok(()) +} + +async fn wait_for_cancel(cancellation: &Cancellation) { + while !cancellation.is_cancelled() { + tokio::time::sleep(CANCEL_POLL).await; + } +} + +#[cfg(test)] +mod ordinary; +#[cfg(test)] +mod tests; + +/// Explicit ordinary-CI request; this test-only data cannot mint dependencies. +#[cfg(test)] +pub(crate) struct OrdinaryArtifactSelection { + /// Run whose REST head identity is independently supplied by the CI context. + pub(crate) run_id: u64, + /// REST run head, distinct from a PR's checked-out merge source. + pub(crate) run_head_commit: String, + /// Exact ID returned by the selected upload step. + pub(crate) artifact_id: u64, + /// Exact artifact name selected by that upload step. + pub(crate) artifact_name: String, + /// Original ZIP digest returned by that upload step. + pub(crate) archive_sha256: String, +} + +/// Raw corroborated ordinary-CI bytes, without workflow or signing authority. +#[cfg(test)] +pub(crate) struct OrdinaryArtifactTransfer { + metadata: Vec, + archive: Vec, +} + +#[cfg(test)] +impl OrdinaryArtifactTransfer { + /// Returns exact original REST body bytes rather than a reserialization. + pub(crate) fn metadata(&self) -> &[u8] { + &self.metadata + } + /// Checks flat ZIP members under the unchanged collector archive contract. + /// + /// # Errors + /// + /// Rejects unsafe, undeclared, truncated or oversized ZIP members. + pub(crate) fn members( + &self, + expected: &BTreeMap, + ) -> Result>> { + super::archive::read(&self.archive, expected) + } +} + +/// Exercises the actual fixed-origin backend with unqualified CI selections. +/// +/// # Errors +/// +/// Rejects changed identity, transport, metadata or archive-digest bindings. This +/// returns diagnostic bytes only and cannot construct [`TrustedDependencies`]. +#[cfg(test)] +pub(crate) fn retrieve_ordinary_ci( + selection: OrdinaryArtifactSelection, + token: &str, + deadline: Instant, + cancellation: Cancellation, +) -> Result { + let id = selection.artifact_id; + let mut source = HttpArtifactSource::new( + Selection { + repository: REPOSITORY.to_owned(), + repository_id: REPOSITORY_ID, + run_id: selection.run_id, + source_commit: selection.run_head_commit, + artifacts: BTreeMap::from([( + id, + SelectedArtifact { + name: selection.artifact_name, + sha256: selection.archive_sha256, + }, + )]), + }, + token, + deadline, + cancellation, + Endpoints::official()?, + )?; + let metadata = source.metadata(REPOSITORY, id)?; + let archive = source.download(REPOSITORY, id)?.archive; + Ok(OrdinaryArtifactTransfer { metadata, archive }) +} diff --git a/crates/trusted-server-release/src/collection/transfer/ordinary.rs b/crates/trusted-server-release/src/collection/transfer/ordinary.rs new file mode 100644 index 0000000000..d14671bc70 --- /dev/null +++ b/crates/trusted-server-release/src/collection/transfer/ordinary.rs @@ -0,0 +1,104 @@ +//! Actual ordinary-CI upload/archive interoperability, without release authority. + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use super::{OrdinaryArtifactSelection, retrieve_ordinary_ci}; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::process::Cancellation; + +const MEMBERS: [(&str, &[u8]); 4] = [ + ("artifact", b"fictional transfer executable bytes\n"), + ( + "build-record.json", + b"{\"kind\":\"fictional-build-record\",\"release_qualification\":false}\n", + ), + ( + "composition.json", + b"{\"kind\":\"fictional-composition\",\"complete\":false}\n", + ), + ( + "tsjs-inventory.json", + b"{\"kind\":\"fictional-javascript-inventory\",\"modules\":[]}\n", + ), +]; + +fn required(name: &str) -> String { + std::env::var(name).expect("should provide the explicitly selected ordinary-CI fixture input") +} + +#[test] +#[ignore = "requires the explicit ordinary-CI four-file upload, exact upload outputs and read-only Actions token"] +fn real_uploaded_archive() { + assert_eq!( + std::env::var("GITHUB_ACTIONS").as_deref(), + Ok("true"), + "should run only in the explicit hosted ordinary-CI fixture" + ); + let run_id = required("TS_RELEASE_TRANSFER_RUN_ID") + .parse::() + .expect("should select an exact numeric workflow run ID"); + assert_eq!( + required("GITHUB_RUN_ID") + .parse::() + .expect("should observe the current workflow run ID"), + run_id, + "should select the current ordinary-CI workflow run" + ); + let head = required("TS_RELEASE_TRANSFER_RUN_HEAD_SHA"); + let artifact_id = required("TS_RELEASE_TRANSFER_ARTIFACT_ID") + .parse::() + .expect("should select the exact numeric upload artifact ID"); + let name = required("TS_RELEASE_TRANSFER_ARTIFACT_NAME"); + let digest = required("TS_RELEASE_TRANSFER_ARCHIVE_SHA256"); + let digest = digest.strip_prefix("sha256:").unwrap_or(&digest); + assert!( + lower_hex(&head, 40) && lower_hex(digest, 64), + "should provide exact run-head and original archive identities" + ); + let token = required("GH_TOKEN"); + let expected: BTreeMap = MEMBERS + .iter() + .map(|(name, bytes)| ((*name).to_owned(), bytes.len() as u64)) + .collect(); + let deadline = Instant::now() + Duration::from_secs(5 * 60); + for _ in 0..2 { + // Each call creates an independent transport and downloads the original + // selected ZIP again; no action-extracted directory substitutes for it. + let observed = retrieve_ordinary_ci( + OrdinaryArtifactSelection { + run_id, + run_head_commit: head.clone(), + artifact_id, + artifact_name: name.clone(), + archive_sha256: digest.to_owned(), + }, + &token, + deadline, + Cancellation::default(), + ) + .expect("should retrieve the exact ordinary-CI upload through the bounded real backend"); + let metadata = parse_json(observed.metadata(), Role::Metadata) + .expect("should parse the exact original REST response bytes"); + assert_eq!( + metadata["id"].as_u64(), + Some(artifact_id), + "should corroborate the exact upload ID in raw REST metadata" + ); + let members = observed.members(&expected).expect( + "should accept the original upload ZIP under the unchanged flat archive contract", + ); + assert_eq!( + members.len(), + MEMBERS.len(), + "should retain exactly four fictional flat members" + ); + for (name, bytes) in MEMBERS { + assert_eq!( + members.get(name).map(Vec::as_slice), + Some(bytes), + "should retain the exact fictional bytes for {name}" + ); + } + } +} diff --git a/crates/trusted-server-release/src/collection/transfer/tests.rs b/crates/trusted-server-release/src/collection/transfer/tests.rs new file mode 100644 index 0000000000..73b74bb2d9 --- /dev/null +++ b/crates/trusted-server-release/src/collection/transfer/tests.rs @@ -0,0 +1,836 @@ +use super::*; + +#[test] +fn signed_download_location_rejects_unapproved_authority_and_normalization() { + let endpoints = Endpoints::fixture( + "https://api.example.com/repos/example/project/", + ".blob.example.com", + None, + ); + for text in [ + "http://account.blob.example.com/archive.zip?sig=fictional", + "https://account.blob.example.com:444/archive.zip", + "https://user:secret@account.blob.example.com/archive.zip", + "https://account.blob.example.com/archive.zip#fragment", + "https://account.blob.example.com.evil.example.com/archive.zip", + "https://nested.account.blob.example.com/archive.zip", + "https://127.0.0.1/archive.zip", + r"https://account.blob.example.com\archive.zip", + " https://account.blob.example.com/archive.zip", + "https://account.blob.example.com/archive.zip ", + ] { + assert!( + endpoints.signed_location(text).is_err(), + "should reject unapproved signed URL" + ); + } + assert!( + endpoints + .signed_location("https://account.blob.example.com/archive.zip?sig=fictional") + .is_ok(), + "should accept one authenticated storage hop within reviewed host scope" + ); +} + +#[test] +fn streamed_append_rejects_a_byte_over_limit_before_growing_buffer() { + let mut body = Vec::new(); + let mut hash = Sha256::new(); + append_chunk(&mut body, &mut hash, b"1234", 4).expect("should allow exact bound"); + assert!( + append_chunk(&mut body, &mut hash, b"5", 4).is_err(), + "should reject one extra byte" + ); + assert_eq!(body, b"1234", "should not append excess bytes"); +} + +fn selection(archive: &[u8]) -> Selection { + Selection { + repository: "example/project".to_owned(), + repository_id: 17, + run_id: 29, + source_commit: "a".repeat(40), + artifacts: BTreeMap::from([( + 41, + SelectedArtifact { + name: "upload".to_owned(), + sha256: hex::encode(Sha256::digest(archive)), + }, + )]), + } +} + +fn metadata(archive: &[u8]) -> Vec { + serde_json::to_vec_pretty(&serde_json::json!({"id":41,"name":"upload","expired":false,"digest":format!("sha256:{}",hex::encode(Sha256::digest(archive))),"workflow_run":{"id":29,"repository_id":17,"head_repository_id":17,"head_sha":"a".repeat(40)}})).expect("should encode raw API fixture") +} + +#[test] +fn exact_raw_metadata_correlates_repository_run_source_id_and_digest() { + let archive = b"fictional archive"; + let expected = selection(archive); + let raw = metadata(archive); + expected + .metadata(41, &raw) + .expect("should accept exact selected metadata"); + for (pointer, replacement) in [ + ("/id", serde_json::json!(42)), + ("/name", serde_json::json!("other")), + ("/expired", serde_json::json!(true)), + ("/digest", serde_json::json!("sha256:changed")), + ("/workflow_run/id", serde_json::json!(30)), + ("/workflow_run/head_sha", serde_json::json!("c".repeat(40))), + ("/workflow_run/repository_id", serde_json::json!(18)), + ("/workflow_run/head_repository_id", serde_json::json!(18)), + ] { + let mut value: serde_json::Value = + serde_json::from_slice(&raw).expect("should parse fixture"); + *value.pointer_mut(pointer).expect("should select field") = replacement; + let changed = serde_json::to_vec(&value).expect("should encode changed fixture"); + assert!( + expected.metadata(41, &changed).is_err(), + "should reject changed metadata identity" + ); + } + assert!( + expected.metadata(41, b"{\"id\":41,\"id\":41}").is_err(), + "should reject duplicate JSON keys" + ); +} + +struct Plan { + wire: Vec, + stall: Duration, +} + +fn response(status: &str, headers: &[(&str, String)], body: &[u8]) -> Plan { + let mut wire = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ) + .into_bytes(); + for (name, value) in headers { + wire.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } + wire.extend_from_slice(b"\r\n"); + wire.extend_from_slice(body); + Plan { + wire, + stall: Duration::ZERO, + } +} + +struct Server { + address: std::net::SocketAddr, + requests: std::sync::mpsc::Receiver, + stop: std::sync::Arc, + thread: Option>, +} + +impl Server { + fn start(plans: Vec) -> Self { + let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("should bind transport fixture"); + let address = listener.local_addr().expect("should read fixture address"); + listener + .set_nonblocking(true) + .expect("should bound fixture accepts"); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let shutdown = std::sync::Arc::clone(&stop); + let (sender, requests) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + let mut plans = plans.into_iter(); + while !shutdown.load(std::sync::atomic::Ordering::SeqCst) { + let mut stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(_) => return, + }; + // Accepted sockets inherit the listener's nonblocking mode on macOS. + stream + .set_nonblocking(false) + .expect("should read complete fixture requests in blocking mode"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("should bound fixture reads"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("should bound fixture writes"); + let mut raw = Vec::new(); + let mut byte = [0; 1]; + while raw.len() < 16 * 1024 && !raw.ends_with(b"\r\n\r\n") { + match std::io::Read::read(&mut stream, &mut byte) { + Ok(1) => raw.push(byte[0]), + _ => break, + } + } + if !raw.ends_with(b"\r\n\r\n") { + continue; + } + sender + .send(String::from_utf8_lossy(&raw).into_owned()) + .expect("should capture request"); + if let Some(plan) = plans.next() { + let _ = std::io::Write::write_all(&mut stream, &plan.wire); + let until = Instant::now() + plan.stall; + while Instant::now() < until + && !shutdown.load(std::sync::atomic::Ordering::SeqCst) + { + std::thread::sleep(Duration::from_millis(2)); + } + } + } + }); + Self { + address, + requests, + stop, + thread: Some(thread), + } + } + + fn request(&self) -> String { + self.requests + .recv_timeout(Duration::from_secs(2)) + .expect("should receive fixture request") + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + thread.join().expect("should join fixture server"); + } + } +} + +#[test] +fn fixture_waits_for_fragmented_request_headers() { + let server = Server::start(vec![response("200 OK", &[], b"complete")]); + let mut client = std::net::TcpStream::connect(server.address) + .expect("should connect fragmented request fixture"); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("should bound fragmented response read"); + std::io::Write::write_all(&mut client, b"GET / HTTP/1.1\r\n") + .expect("should send first header fragment"); + assert!( + matches!( + server.requests.recv_timeout(Duration::from_millis(100)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "should wait for complete headers before capturing request or consuming response" + ); + std::io::Write::write_all( + &mut client, + b"Host: api.example.com\r\nAuthorization: Bearer fictional\r\n\r\n", + ) + .expect("should send final header fragment"); + let request = server.request(); + assert!( + request.ends_with("Authorization: Bearer fictional\r\n\r\n"), + "should capture final header fragment: {request:?}" + ); + let mut reply = String::new(); + std::io::Read::read_to_string(&mut client, &mut reply) + .expect("should receive response after complete headers"); + assert!(reply.ends_with("complete"), "should preserve response plan"); +} + +#[test] +fn fixture_does_not_consume_response_for_aborted_headers() { + let server = Server::start(vec![response("200 OK", &[], b"complete")]); + let mut aborted = std::net::TcpStream::connect(server.address) + .expect("should connect aborted request fixture"); + std::io::Write::write_all(&mut aborted, b"GET /incomplete HTTP/1.1\r\n") + .expect("should send incomplete headers"); + aborted + .shutdown(std::net::Shutdown::Both) + .expect("should abort incomplete request"); + let mut client = std::net::TcpStream::connect(server.address) + .expect("should connect complete request fixture"); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("should bound complete response read"); + std::io::Write::write_all( + &mut client, + b"GET /complete HTTP/1.1\r\nHost: api.example.com\r\n\r\n", + ) + .expect("should send complete request"); + assert!( + server.request().starts_with("GET /complete "), + "should capture complete requests only" + ); + let mut reply = String::new(); + std::io::Read::read_to_string(&mut client, &mut reply) + .expect("should receive unconsumed response"); + assert!( + reply.ends_with("complete"), + "should preserve response after aborted headers" + ); +} + +fn endpoints(api: &Server, storage: Option<&Server>) -> Endpoints { + let mut endpoints = Endpoints::fixture( + &format!( + "http://api.example.com:{}/repos/example/project/", + api.address.port() + ), + ".blob.example.com", + storage.map(|server| server.address.port()), + ); + endpoints.addresses.push(("api.example.com", api.address)); + if let Some(storage) = storage { + endpoints + .addresses + .push(("account.blob.example.com", storage.address)); + } + endpoints +} + +fn source(archive: &[u8], endpoints: Endpoints) -> HttpArtifactSource { + HttpArtifactSource::new( + selection(archive), + "fictional-api-token", + Instant::now() + Duration::from_secs(5), + Cancellation::default(), + endpoints, + ) + .expect("should construct diagnostic test source") +} + +#[test] +fn actual_transport_preserves_raw_metadata_and_separates_storage_credentials() { + let archive = b"fictional archive"; + let raw = metadata(archive); + let storage = Server::start(vec![response("200 OK", &[], archive)]); + let location = format!( + "http://account.blob.example.com:{}/archive.zip?sig=fictional-signed-query", + storage.address.port() + ); + let api = Server::start(vec![ + response("200 OK", &[], &raw), + response("302 Found", &[("Location", location)], b""), + ]); + let mut source = source(archive, endpoints(&api, Some(&storage))); + assert_eq!( + source + .metadata("example/project", 41) + .expect("should fetch exact metadata"), + raw, + "should return exact original response bytes" + ); + assert_eq!( + source + .download("example/project", 41) + .expect("should download selected archive") + .archive, + archive, + "should bind exact archive bytes" + ); + for request in [api.request(), api.request()] { + let request = request.to_ascii_lowercase(); + assert!( + request.contains("authorization: bearer fictional-api-token"), + "should authenticate fixed API requests only: {request:?}" + ); + assert!( + request.contains("accept-encoding: identity"), + "should request original transfer bytes" + ); + } + let request = storage.request().to_ascii_lowercase(); + assert!( + !request.contains("authorization:") && !request.contains("fictional-api-token"), + "should omit GitHub credential on signed storage hop" + ); + assert!( + !format!("{source:?}").contains("fictional-api-token"), + "should redact credential in source Debug" + ); + assert!( + source.download("example/project", 41).is_err(), + "should require fresh checked metadata before another download" + ); +} + +#[test] +fn unexpected_repository_or_artifact_id_and_missing_metadata_make_no_network_request() { + let api = Server::start(vec![]); + let mut source = source(b"archive", endpoints(&api, None)); + assert!( + source.metadata("other/project", 41).is_err(), + "should reject another repository" + ); + assert!( + source.metadata("example/project", 42).is_err(), + "should reject an unselected artifact ID" + ); + assert!( + source.download("example/project", 41).is_err(), + "should require exact checked metadata before download" + ); + assert!( + api.requests.try_recv().is_err(), + "should not contact server for invalid selection" + ); +} + +#[test] +fn transport_rejects_redirects_wrong_status_truncation_and_oversized_body_declarations() { + let oversized = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + Role::Metadata.limit() + 1 + ) + .into_bytes(); + for plan in [ + response( + "302 Found", + &[("Location", "http://other.example.com/metadata".to_owned())], + b"", + ), + response("410 Gone", &[], b"gone"), + Plan { + wire: b"HTTP/1.1 200 OK\r\nContent-Length: 99\r\nConnection: close\r\n\r\nshort" + .to_vec(), + stall: Duration::ZERO, + }, + Plan { + wire: oversized, + stall: Duration::ZERO, + }, + response( + "200 OK", + &[("Content-Encoding", "gzip".to_owned())], + b"encoded", + ), + response( + "200 OK", + &[ + ("Content-Encoding", "identity".to_owned()), + ("Content-Encoding", "gzip".to_owned()), + ], + &metadata(b"archive"), + ), + ] { + let api = Server::start(vec![plan]); + let mut source = source(b"archive", endpoints(&api, None)); + assert!( + source.metadata("example/project", 41).is_err(), + "should reject unsafe or incomplete API response" + ); + assert!( + source.download("example/project", 41).is_err(), + "should not authorize download after rejected metadata" + ); + } +} + +#[test] +fn transport_bounds_chunked_body_without_trusting_content_length() { + let mut wire = + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec(); + let chunk = vec![b'x'; Role::Metadata.limit() as usize + 1]; + wire.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + wire.extend_from_slice(&chunk); + wire.extend_from_slice(b"\r\n0\r\n\r\n"); + let api = Server::start(vec![Plan { + wire, + stall: Duration::ZERO, + }]); + let mut source = source(b"archive", endpoints(&api, None)); + assert!( + source.metadata("example/project", 41).is_err(), + "should reject one byte over role bound without Content-Length" + ); +} + +#[test] +fn cancellation_and_attempt_deadline_bound_header_and_body_stalls() { + for (cancel, wire) in [ + (false, Vec::new()), + (true, Vec::new()), + ( + false, + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nx".to_vec(), + ), + ( + true, + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nx".to_vec(), + ), + ] { + let api = Server::start(vec![Plan { + wire, + stall: Duration::from_secs(2), + }]); + let mut source = source(b"archive", endpoints(&api, None)); + source.deadline = Instant::now() + Duration::from_millis(if cancel { 1000 } else { 80 }); + let flag = source.cancellation.clone(); + let trigger = cancel.then(|| { + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(80)); + flag.cancel(); + }) + }); + let started = Instant::now(); + assert!( + source.metadata("example/project", 41).is_err(), + "should reject stalled response" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "should cancel or time out while headers/body are pending" + ); + if let Some(trigger) = trigger { + trigger.join().expect("should join cancellation trigger"); + } + } +} + +#[test] +fn storage_redirect_loops_duplicate_locations_and_digest_changes_fail_closed() { + for mode in ["second-hop", "duplicate", "digest"] { + let archive = b"selected archive"; + let storage = Server::start(vec![if mode == "second-hop" { + response( + "302 Found", + &[( + "Location", + "http://other.example.com/archive.zip?sig=fictional-hidden-query".to_owned(), + )], + b"", + ) + } else { + response("200 OK", &[], b"changed archive") + }]); + let location = format!( + "http://account.blob.example.com:{}/archive.zip?sig=fictional-hidden-query", + storage.address.port() + ); + let mut locations = vec![("Location", location.clone())]; + if mode == "duplicate" { + locations.push(("Location", location)); + } + let api = Server::start(vec![ + response("200 OK", &[], &metadata(archive)), + response("302 Found", &locations, b""), + ]); + let mut source = source(archive, endpoints(&api, Some(&storage))); + source + .metadata("example/project", 41) + .expect("should fetch exact metadata"); + let error = source + .download("example/project", 41) + .err() + .expect("should reject unsafe or changed archive"); + let text = format!("{error:?} {source:?}"); + assert!( + !text.contains("fictional-hidden-query") && !text.contains("fictional-api-token"), + "should redact signed query and API credential from errors and Debug" + ); + if mode == "duplicate" { + assert!( + storage.requests.try_recv().is_err(), + "should reject ambiguous Location before storage contact" + ); + } + } +} + +struct PendingDns { + dropped: std::sync::Arc, +} + +struct DnsGuard(std::sync::Arc); + +impl Drop for DnsGuard { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } +} + +impl reqwest::dns::Resolve for PendingDns { + fn resolve(&self, _: reqwest::dns::Name) -> reqwest::dns::Resolving { + let guard = DnsGuard(std::sync::Arc::clone(&self.dropped)); + Box::pin(async move { + let _guard = guard; + std::future::pending().await + }) + } +} + +#[test] +fn cancellation_during_dns_drops_resolution_with_the_operation_runtime() { + let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut endpoints = Endpoints::fixture( + "https://api.example.com/repos/example/project/", + ".blob.example.com", + None, + ); + endpoints.resolver = Some(std::sync::Arc::new(PendingDns { + dropped: std::sync::Arc::clone(&dropped), + })); + let mut source = source(b"archive", endpoints); + let cancellation = source.cancellation.clone(); + let trigger = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(80)); + cancellation.cancel(); + }); + let started = Instant::now(); + assert!( + source.metadata("example/project", 41).is_err(), + "should cancel a pending async DNS operation" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "should bound DNS cancellation and runtime shutdown" + ); + assert!( + dropped.load(std::sync::atomic::Ordering::SeqCst), + "should drop resolver work before returning from operation" + ); + trigger.join().expect("should join cancellation trigger"); +} + +#[test] +fn source_rejects_bad_credentials_expired_attempts_and_invalid_identity_sets() { + for token in ["", "fictional token", "fictional\ncredential"] { + assert!( + Credential::new(token).is_err(), + "should reject invalid explicit credential" + ); + } + let api = Server::start(vec![]); + assert!( + HttpArtifactSource::new( + selection(b"archive"), + "fictional-token", + Instant::now() - Duration::from_secs(1), + Cancellation::default(), + endpoints(&api, None) + ) + .is_err(), + "should reject expired attempt before transport" + ); + let mut invalid = selection(b"archive"); + invalid.run_id = 0; + assert!( + invalid.check().is_err(), + "should reject missing current run identity" + ); + let cancellation = Cancellation::default(); + cancellation.cancel(); + assert!( + HttpArtifactSource::new( + selection(b"archive"), + "fictional-token", + Instant::now() + Duration::from_secs(1), + cancellation, + endpoints(&api, None) + ) + .is_err(), + "should reject cancelled attempt before transport" + ); + assert!( + api.requests.try_recv().is_err(), + "should not contact API for invalid attempt" + ); +} + +struct TlsEndpoint { + address: std::net::SocketAddr, + stop: std::sync::Arc, + thread: Option>, +} + +impl TlsEndpoint { + fn start( + cert: rustls::pki_types::CertificateDer<'static>, + key: rustls::pki_types::PrivateKeyDer<'static>, + ) -> Self { + let config = rustls::ServerConfig::builder_with_provider(std::sync::Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("should select test TLS protocols") + .with_no_client_auth() + .with_single_cert(vec![cert], key) + .expect("should configure fictional TLS endpoint"); + let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("should bind fictional TLS endpoint"); + let address = listener.local_addr().expect("should read TLS address"); + listener + .set_nonblocking(true) + .expect("should bound TLS accept"); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let shutdown = std::sync::Arc::clone(&stop); + let thread = std::thread::spawn(move || { + while !shutdown.load(std::sync::atomic::Ordering::SeqCst) { + let stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(_) => return, + }; + stream + .set_nonblocking(false) + .expect("should perform fixture TLS handshake in blocking mode"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("should bound TLS reads"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("should bound TLS writes"); + let connection = rustls::ServerConnection::new(std::sync::Arc::new(config.clone())) + .expect("should create test TLS session"); + let mut stream = rustls::StreamOwned::new(connection, stream); + let mut raw = Vec::new(); + let mut byte = [0; 1]; + while raw.len() < 16 * 1024 && !raw.ends_with(b"\r\n\r\n") { + match std::io::Read::read(&mut stream, &mut byte) { + Ok(1) => raw.push(byte[0]), + _ => break, + } + } + if raw.ends_with(b"\r\n\r\n") { + let plan = response("200 OK", &[], &metadata(b"archive")); + let _ = std::io::Write::write_all(&mut stream, &plan.wire); + let _ = std::io::Write::flush(&mut stream); + } + } + }); + Self { + address, + stop, + thread: Some(thread), + } + } +} + +impl Drop for TlsEndpoint { + fn drop(&mut self) { + self.stop.store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + thread.join().expect("should join TLS endpoint"); + } + } +} + +#[test] +fn isolated_child_ignores_proxy_config_cwd_and_ambient_ca_roots() { + let root = tempfile::tempdir().expect("should create poisoned configuration fixture"); + let certificate = rcgen::generate_simple_self_signed(vec!["api.example.com".to_owned()]) + .expect("should generate fictional CA fixture"); + let ca = root.path().join("fictional-ca.pem"); + std::fs::write(&ca, certificate.cert.pem()).expect("should write fictional CA"); + std::fs::create_dir(root.path().join("gh")).expect("should create poisoned gh config"); + std::fs::write( + root.path().join("gh/config.yml"), + "http_unix_socket: /fictional/socket\napi_host: other.example.com\n", + ) + .expect("should write ignored config"); + let tls = TlsEndpoint::start( + certificate.cert.der().clone(), + rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.key_pair.serialize_der().into()), + ); + let program = std::env::current_exe().expect("should locate test binary"); + let mut command = crate::process::BoundedCommand::new( + &program, + root.path(), + crate::process::ProcessLimits { + wall: Duration::from_secs(10), + stdout_max: 16 * 1024, + stderr_max: 16 * 1024, + }, + ) + .expect("should create bounded child") + .arguments( + [ + "--ignored", + "--exact", + "collection::transfer::tests::environment_configuration_child", + "--nocapture", + ] + .map(std::ffi::OsString::from), + ); + for (name, value) in [ + ("TS_RELEASE_TRANSFER_ENV_CHILD", "1".to_owned()), + ( + "TS_RELEASE_TRANSFER_TLS_PORT", + tls.address.port().to_string(), + ), + ("HTTP_PROXY", "http://proxy.example.com:9".to_owned()), + ("HTTPS_PROXY", "http://proxy.example.com:9".to_owned()), + ("ALL_PROXY", "http://proxy.example.com:9".to_owned()), + ("NO_PROXY", String::new()), + ("SSL_CERT_FILE", ca.display().to_string()), + ("SSL_CERT_DIR", root.path().display().to_string()), + ("CURL_CA_BUNDLE", ca.display().to_string()), + ("REQUESTS_CA_BUNDLE", ca.display().to_string()), + ("GH_HOST", "other.example.com".to_owned()), + ("GH_REPO", "other/project".to_owned()), + ( + "GH_CONFIG_DIR", + root.path().join("gh").display().to_string(), + ), + ("HOME", root.path().display().to_string()), + ] { + command = command + .environment(name, value) + .expect("should set child-only poison environment"); + } + command + .run() + .expect("should supervise isolated policy test") + .require_success() + .expect("should reject ambient CA and ignore proxy/config channels"); +} + +#[test] +#[ignore = "invoked only by isolated_child_ignores_proxy_config_cwd_and_ambient_ca_roots"] +fn environment_configuration_child() { + assert_eq!( + std::env::var("TS_RELEASE_TRANSFER_ENV_CHILD").expect("should receive child marker"), + "1", + "should run only isolated environment fixture" + ); + let api = Server::start(vec![response("200 OK", &[], &metadata(b"archive"))]); + let mut plain = source(b"archive", endpoints(&api, None)); + plain + .metadata("example/project", 41) + .expect("should ignore ambient proxies and gh/config/cwd"); + let port = std::env::var("TS_RELEASE_TRANSFER_TLS_PORT") + .expect("should receive TLS fixture port") + .parse::() + .expect("should parse TLS fixture port"); + let mut endpoints = Endpoints::fixture( + &format!("https://api.example.com:{port}/repos/example/project/"), + ".blob.example.com", + None, + ); + endpoints.addresses.push(( + "api.example.com", + std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port)), + )); + let mut tls = source(b"archive", endpoints); + assert!( + tls.metadata("example/project", 41).is_err(), + "should reject fictional TLS certificate even when ambient CA variables name it" + ); +} + +#[test] +fn streaming_allocation_capacity_stays_inside_selected_byte_bound() { + let mut bytes = Vec::new(); + let mut hash = Sha256::new(); + append_chunk(&mut bytes, &mut hash, b"1234", 4).expect("should append bounded body"); + assert!( + bytes.capacity() <= 4, + "should not grow allocation beyond selected body bound" + ); +} diff --git a/crates/trusted-server-release/src/collection/workflow_jobs.rs b/crates/trusted-server-release/src/collection/workflow_jobs.rs new file mode 100644 index 0000000000..6bfd8f316e --- /dev/null +++ b/crates/trusted-server-release/src/collection/workflow_jobs.rs @@ -0,0 +1,430 @@ +//! Pure GitHub Actions jobs adapter for the trusted collector, without authority. +//! +//! It turns one bounded raw "list jobs for a workflow run attempt" REST body, +//! the upload step outputs and the collector's own wait clock into the typed +//! [`Upload`] and [`OptionalBuild`] inputs of [`TrustedDependencies`]. Every +//! expected job name, its asset, role and recipe come from an explicit +//! [`ExpectedTopology`], never from the response. Nothing here performs HTTP, +//! reads workflow files or constructs [`TrustedDependencies`]: that constructor +//! stays closed behind the uninhabited [`QualifiedCollection`]. +//! +//! Parsing follows the REST precedent of [`super::transfer`]: the body passes +//! the strict bounded [`parse_json`] (UTF-8, metadata byte limit, no duplicate +//! keys, nesting limit) and only the fields read here are checked; other +//! fields, such as `started_at`, are ignored. +//! +//! [`TrustedDependencies`]: super::TrustedDependencies +//! [`QualifiedCollection`]: super::QualifiedCollection + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde_json::Value; + +use super::{ + JobConclusion, NoUpload, ObservedJob, OptionalBuild, Upload, bounded, collection_error, +}; +use crate::documents::{self, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::policy::{FIXED_ARTIFACTS, ReleaseTarget}; +use crate::recipe::Role; + +#[cfg(test)] +mod tests; + +/// One REST page; a larger or paginated run is rejected rather than merged. +const JOB_PAGE_LIMIT: usize = 100; +/// GitHub's documented maximum number of steps in one job. +const STEP_LIMIT: usize = 1000; +/// Same bound the collector applies to an upload's job name. +const JOB_NAME_LIMIT: usize = 128; + +/// The only job state that may carry an upload identity. +const SUCCEEDED: ObservedJob = ObservedJob { + started: true, + conclusion: Some(JobConclusion::Success), +}; + +type Result = core::result::Result>; + +/// The exact workflow run attempt and source the collector belongs to. +#[derive(Clone, Debug)] +pub(super) struct RunIdentity { + run_id: u64, + run_attempt: u64, + head_sha: String, +} + +/// One build job the trusted workflow topology expects. +#[derive(Clone, Debug)] +pub(super) struct ExpectedJob { + asset: String, + role: Role, + name: String, + recipe_sha256: String, +} + +impl ExpectedJob { + /// Every canonical build and the Fastly reproduction are mandatory. + /// + /// The Fastly asset is identified by its compiled + /// [`ReleaseTarget::Wasm32Wasip1`] target, not by its position in + /// [`FIXED_ARTIFACTS`]. Native reproductions are optional measurements + /// (spec R-REPEAT): they record an outcome but never block their asset. + fn mandatory(&self) -> bool { + self.role == Role::Canonical + || FIXED_ARTIFACTS.iter().any(|fixed| { + fixed.name == self.asset && fixed.target == ReleaseTarget::Wasm32Wasip1 + }) + } +} + +/// The complete expected build-job topology for one run attempt. +#[derive(Clone, Debug)] +pub(super) struct ExpectedTopology { + run: RunIdentity, + jobs: Vec, + deadline_seconds: u64, +} + +impl ExpectedTopology { + /// Checks that the topology names each fixed build exactly once. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Collection`] for a zero run ID or attempt, a + /// malformed source commit or recipe digest, an uncapped deadline, an + /// unknown asset, an unsafe or reused job name, or a topology that does + /// not contain exactly one canonical and one reproduction job per + /// [`FIXED_ARTIFACTS`] entry. + pub(super) fn new( + run: RunIdentity, + jobs: Vec, + deadline_seconds: u64, + ) -> Result { + if run.run_id == 0 || run.run_attempt == 0 || !lower_hex(&run.head_sha, 40) { + return Err(collection_error("expected run identity is invalid")); + } + if !bounded(0, deadline_seconds) { + return Err(collection_error("collector deadline is not bounded")); + } + let mut names = BTreeSet::new(); + let mut slots = BTreeSet::new(); + for job in &jobs { + if !safe_job_name(&job.name) + || !lower_hex(&job.recipe_sha256, 64) + || !FIXED_ARTIFACTS.iter().any(|fixed| fixed.name == job.asset) + || !names.insert(job.name.as_str()) + || !slots.insert((job.asset.as_str(), job.role.spec_label())) + { + return Err(collection_error( + "expected job is invalid, unknown or not unique", + )); + } + } + if slots.len() != FIXED_ARTIFACTS.len() * 2 { + return Err(collection_error( + "expected topology must name every fixed canonical and reproduction job", + )); + } + Ok(Self { + run, + jobs, + deadline_seconds, + }) + } +} + +/// The upload step outputs of one job: exact ID, name, digest and members. +/// +/// The adapter passes these through unchanged and never derives them from the +/// jobs response; [`super::validate_transfers`] then corroborates them. +#[derive(Clone, Debug)] +pub(super) struct UploadOutput { + artifact_id: u64, + artifact_name: String, + archive_sha256: String, + members: BTreeMap, +} + +/// Upload step outputs keyed by the expected job name that produced them. +#[derive(Clone, Debug)] +pub(super) struct JobUpload { + job: String, + output: UploadOutput, +} + +/// Typed collector inputs; deliberately not [`super::TrustedDependencies`]. +#[derive(Debug)] +pub(super) struct AdaptedJobs { + required: Vec, + optional: Vec, +} + +/// Binds every expected job to one observed job and its upload outputs. +/// +/// `elapsed_seconds` is the collector's own bounded wait when it fetched +/// `response`. A job without a terminal conclusion is accepted only once that +/// wait reached the topology deadline, and only for an optional native +/// reproduction. Mandatory jobs must have started, succeeded and uploaded. +/// +/// An optional native reproduction never blocks (spec R-REPEAT). Its upload +/// output is used only when the job started and succeeded. Otherwise that +/// output is dropped unused and the job becomes a [`NoUpload`] with its +/// derived outcome (`failed` for a started or self-failed job), so a +/// reproduction that did not succeed can never lend its upload identity to +/// the collection. +/// +/// Only a single fresh attempt is attestable: every expected job must have +/// run in the expected attempt, which holds for attempt 1 and for "re-run all +/// jobs". A "re-run failed jobs" attempt is rejected, because GitHub lists +/// its carried-over jobs with their older `run_attempt` (or omits them), and +/// their uploads were not produced by this attempt. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Collection`] for a non-strict, oversized or +/// incomplete response; a job from another run, attempt or source; a missing, +/// duplicated or reused expected job; an unknown or contradictory GitHub +/// state; a premature or unbounded wait; a mandatory job without a successful +/// upload; or upload outputs naming no expected job or one twice. +pub(super) fn adapt_jobs( + topology: &ExpectedTopology, + response: &[u8], + uploads: &[JobUpload], + elapsed_seconds: u64, +) -> Result { + if !bounded(elapsed_seconds, topology.deadline_seconds) { + return Err(collection_error("collector wait exceeds its deadline")); + } + let observed = observe_jobs(topology, response)?; + let mut outputs = BTreeMap::new(); + for upload in uploads { + if !topology.jobs.iter().any(|job| job.name == upload.job) + || outputs + .insert(upload.job.as_str(), &upload.output) + .is_some() + { + return Err(collection_error( + "upload output does not name exactly one expected job", + )); + } + } + let at_deadline = elapsed_seconds == topology.deadline_seconds; + let mut required = Vec::new(); + let mut optional = Vec::new(); + for expected in &topology.jobs { + let job = *observed + .get(expected.name.as_str()) + .ok_or_else(|| collection_error("expected job is missing from the run"))?; + if job.conclusion.is_none() && !at_deadline { + return Err(collection_error( + "job has no terminal conclusion before the collector deadline", + )); + } + let output = outputs.get(expected.name.as_str()).copied(); + if expected.mandatory() { + let output = output + .filter(|_| job == SUCCEEDED) + .ok_or_else(|| collection_error("mandatory job did not succeed with an upload"))?; + required.push(upload(topology, expected, output, elapsed_seconds)); + } else if let Some(output) = output.filter(|_| job == SUCCEEDED) { + optional.push(OptionalBuild::Uploaded { + upload: upload(topology, expected, output, elapsed_seconds), + job, + }); + } else { + // Any upload output of an unsuccessful optional job is dropped + // here: it never becomes upload identity. + let absent = NoUpload { + asset: expected.asset.clone(), + job, + elapsed_seconds, + deadline_seconds: topology.deadline_seconds, + }; + if absent.outcome().is_none() { + return Err(collection_error( + "optional job state has no honest failed or unavailable outcome", + )); + } + optional.push(OptionalBuild::NotUploaded(absent)); + } + } + Ok(AdaptedJobs { required, optional }) +} + +fn upload( + topology: &ExpectedTopology, + expected: &ExpectedJob, + output: &UploadOutput, + elapsed_seconds: u64, +) -> Upload { + Upload { + asset: expected.asset.clone(), + role: expected.role.spec_label().to_owned(), + job: expected.name.clone(), + attempt: topology.run.run_attempt, + recipe_sha256: expected.recipe_sha256.clone(), + elapsed_seconds, + deadline_seconds: topology.deadline_seconds, + artifact_name: output.artifact_name.clone(), + artifact_id: output.artifact_id, + archive_sha256: output.archive_sha256.clone(), + members: output.members.clone(), + } +} + +/// Parses the strict jobs page and observes each expected job exactly once. +/// +/// The page must be complete: `total_count` must equal the listed jobs and at +/// most [`JOB_PAGE_LIMIT`] may be listed. The future fetch must therefore +/// request `per_page=100`; GitHub's default page of 30 would be rejected as +/// incomplete for a larger run rather than merged across pages. +/// +/// `run_attempt` is optional in GitHub's job schema but required here on every +/// listed job, together with the exact `run_id` and `head_sha`. A job listed +/// with another attempt, as a "re-run failed jobs" attempt lists its +/// carried-over jobs, rejects the whole response: only a single fresh attempt +/// is attestable (see [`adapt_jobs`]). +fn observe_jobs<'topology>( + topology: &'topology ExpectedTopology, + response: &[u8], +) -> Result> { + let value = parse_json(response, documents::Role::Metadata) + .map_err(|_| collection_error("workflow jobs response is not strict bounded JSON"))?; + let jobs = value["jobs"] + .as_array() + .filter(|jobs| jobs.len() <= JOB_PAGE_LIMIT) + .filter(|jobs| value["total_count"].as_u64() == Some(jobs.len() as u64)) + .ok_or_else(|| collection_error("workflow jobs response is not one complete page"))?; + let run = &topology.run; + let mut ids = BTreeSet::new(); + let mut observed = BTreeMap::new(); + for job in jobs { + let name = job["name"] + .as_str() + .ok_or_else(|| collection_error("workflow job has no name"))?; + let id = job["id"] + .as_u64() + .filter(|id| *id > 0) + .ok_or_else(|| collection_error("workflow job has no valid ID"))?; + // Every listed job must belong to this run attempt and source, so a + // response for another run cannot be mixed in through unrelated jobs. + if !ids.insert(id) + || job["run_id"].as_u64() != Some(run.run_id) + || job["head_sha"].as_str() != Some(run.head_sha.as_str()) + { + return Err(collection_error( + "workflow job is reused or belongs to another run or source", + )); + } + match job["run_attempt"].as_u64() { + Some(attempt) if attempt == run.run_attempt => {} + Some(_) => { + return Err(collection_error( + "workflow job ran in another attempt; only a single fresh attempt is attestable", + )); + } + None => return Err(collection_error("workflow job has no run_attempt")), + } + let Some(expected) = topology.jobs.iter().find(|expected| expected.name == name) else { + continue; + }; + if observed + .insert(expected.name.as_str(), observe_job(job)?) + .is_some() + { + return Err(collection_error("expected job name occurs more than once")); + } + } + Ok(observed) +} + +/// Derives the started flag from executed steps and maps the conclusion. +/// +/// GitHub's `started_at` is ignored: a job skipped or cancelled before any +/// step may carry it. +fn observe_job(job: &Value) -> Result { + let status = job["status"] + .as_str() + .ok_or_else(|| collection_error("workflow job has no status"))?; + let waiting = match status { + "queued" | "pending" | "waiting" | "requested" => true, + "in_progress" | "completed" => false, + _ => return Err(collection_error("workflow job status is unknown")), + }; + let conclusion = match (status, &job["conclusion"]) { + ("completed", Value::String(text)) => Some(job_conclusion(text)?), + (_, Value::Null) if status != "completed" => None, + _ => { + return Err(collection_error( + "workflow job status and conclusion disagree", + )); + } + }; + let steps = job["steps"] + .as_array() + .filter(|steps| steps.len() <= STEP_LIMIT) + .ok_or_else(|| collection_error("workflow job has no bounded step list"))?; + let mut numbers = BTreeSet::new(); + let mut started = false; + for step in steps { + let number = step["number"].as_u64(); + if step["name"].as_str().is_none() || !number.is_some_and(|number| numbers.insert(number)) { + return Err(collection_error("workflow step is unnamed or renumbered")); + } + started |= step_executed(step)?; + } + if started && waiting { + return Err(collection_error( + "waiting workflow job reports an executed step", + )); + } + Ok(ObservedJob { + started, + conclusion, + }) +} + +/// Maps a GitHub job conclusion to [`JobConclusion`]. +/// +/// `neutral`, `action_required`, `stale`, `startup_failure` and any other value +/// are rejected: the spec defines no failed/unavailable meaning for them, so +/// the collector fails closed rather than guessing an outcome. +fn job_conclusion(text: &str) -> Result { + match text { + "success" => Ok(JobConclusion::Success), + "failure" => Ok(JobConclusion::Failure), + "cancelled" => Ok(JobConclusion::Cancelled), + "skipped" => Ok(JobConclusion::Skipped), + "timed_out" => Ok(JobConclusion::TimedOut), + _ => Err(collection_error( + "workflow job conclusion has no collector meaning", + )), + } +} + +/// Reports whether one step actually ran. +/// +/// A step ran when it is in progress or completed with any conclusion other +/// than `skipped`. Unknown or contradictory step states are rejected. +fn step_executed(step: &Value) -> Result { + match (step["status"].as_str(), &step["conclusion"]) { + (Some("queued" | "pending"), Value::Null) => Ok(false), + (Some("in_progress"), Value::Null) => Ok(true), + (Some("completed"), Value::String(conclusion)) => match conclusion.as_str() { + "skipped" => Ok(false), + "success" | "failure" | "cancelled" => Ok(true), + _ => Err(collection_error( + "workflow step conclusion has no collector meaning", + )), + }, + _ => Err(collection_error( + "workflow step status and conclusion disagree", + )), + } +} + +fn safe_job_name(name: &str) -> bool { + !name.is_empty() && name.len() <= JOB_NAME_LIMIT && !name.chars().any(char::is_control) +} diff --git a/crates/trusted-server-release/src/collection/workflow_jobs/tests.rs b/crates/trusted-server-release/src/collection/workflow_jobs/tests.rs new file mode 100644 index 0000000000..4933fa72d8 --- /dev/null +++ b/crates/trusted-server-release/src/collection/workflow_jobs/tests.rs @@ -0,0 +1,644 @@ +use serde_json::{Value, json}; + +use super::*; +use crate::collection::records::Outcome; + +mod support; +mod transfer; + +use support::{ + DEADLINE, RUN_ATTEMPT, RUN_ID, all_uploads, archive, assert_rejected, executed_steps, + expected_jobs, head_sha, job, optional_outcomes, response, run_identity, skipped_steps, step, + successful_jobs, topology, upload, with_native_reproductions, +}; + +#[test] +fn successful_run_binds_every_fixed_asset_to_its_exact_job_and_upload() { + let adapted = adapt_jobs( + &topology(), + &response(&successful_jobs()), + &all_uploads(), + 600, + ) + .expect("should adapt a complete successful run"); + + assert_eq!( + adapted.required.len(), + 5, + "should require four canonical builds and the Fastly reproduction" + ); + assert_eq!( + adapted.optional.len(), + 3, + "should carry one optional native reproduction per native asset" + ); + for upload in &adapted.required { + assert_eq!(upload.attempt, RUN_ATTEMPT, "should bind the run attempt"); + assert_eq!(upload.elapsed_seconds, 600, "should keep the observed wait"); + assert_eq!( + upload.deadline_seconds, DEADLINE, + "should keep the deadline" + ); + assert!( + upload.role == "A" || upload.asset == FIXED_ARTIFACTS[0].name, + "should place only canonical builds and the Fastly reproduction in the mandatory channel" + ); + let expected = all_uploads() + .into_iter() + .find(|candidate| candidate.job == upload.job) + .expect("should find the upload supplied for this job"); + assert_eq!( + upload.artifact_id, expected.output.artifact_id, + "should keep the exact upload step identity" + ); + } + assert!( + adapted.optional.iter().all(|build| matches!( + build, + OptionalBuild::Uploaded { upload, job: ObservedJob { started: true, conclusion: Some(JobConclusion::Success) } } + if upload.role == "B" && upload.asset != FIXED_ARTIFACTS[0].name + )), + "should feed successful native reproduction uploads into the optional channel" + ); +} + +#[test] +fn only_the_wasm_asset_reproduction_is_mandatory() { + for expected in expected_jobs() { + let wasm = FIXED_ARTIFACTS.iter().any(|fixed| { + fixed.name == expected.asset && fixed.target == ReleaseTarget::Wasm32Wasip1 + }); + assert_eq!( + expected.mandatory(), + expected.role == Role::Canonical || wasm, + "should make every canonical build and only the Wasm reproduction mandatory" + ); + } +} + +/// Label, job status, conclusion, steps, elapsed wait and derived outcome. +type NativeReproductionState = (&'static str, &'static str, Value, Vec, u64, Outcome); + +/// Every native reproduction job state with its derived outcome. +fn native_reproduction_states() -> Vec { + let partially_cancelled = vec![ + step(1, "completed", json!("success")), + step(2, "completed", json!("cancelled")), + step(3, "completed", json!("skipped")), + ]; + let running = vec![ + step(1, "completed", json!("success")), + step(2, "in_progress", Value::Null), + ]; + vec![ + ( + "succeeded without an upload", + "completed", + json!("success"), + executed_steps(), + 600, + Outcome::Failed, + ), + ( + "failed", + "completed", + json!("failure"), + executed_steps(), + 600, + Outcome::Failed, + ), + ( + "timed out", + "completed", + json!("timed_out"), + executed_steps(), + 600, + Outcome::Failed, + ), + ( + "started then cancelled", + "completed", + json!("cancelled"), + partially_cancelled, + 600, + Outcome::Failed, + ), + ( + "skipped by the scheduler", + "completed", + json!("skipped"), + Vec::new(), + 600, + Outcome::Unavailable, + ), + ( + "cancelled before any step", + "completed", + json!("cancelled"), + skipped_steps(), + 600, + Outcome::Unavailable, + ), + ( + "still queued at the deadline", + "queued", + Value::Null, + Vec::new(), + DEADLINE, + Outcome::Unavailable, + ), + ( + "still running at the deadline", + "in_progress", + Value::Null, + running, + DEADLINE, + Outcome::Failed, + ), + ] +} + +#[test] +fn native_reproduction_job_states_derive_bounded_honest_outcomes() { + for (label, status, conclusion, steps, elapsed, outcome) in native_reproduction_states() { + let (jobs, uploads) = with_native_reproductions(status, &conclusion, &steps); + let adapted = adapt_jobs(&topology(), &response(&jobs), &uploads, elapsed) + .unwrap_or_else(|error| panic!("should adapt native reproduction {label}: {error:?}")); + assert_eq!( + adapted.required.len(), + 5, + "should keep every mandatory job when native reproduction {label}" + ); + assert_eq!( + optional_outcomes(&adapted), + vec![Some(outcome); 3], + "should derive the expected outcome when native reproduction {label}" + ); + } +} + +#[test] +fn started_at_without_an_executed_step_is_not_started() { + // GitHub sets `started_at` on these jobs; only executed steps count. + for steps in [Vec::new(), skipped_steps()] { + let (jobs, uploads) = with_native_reproductions("completed", &json!("cancelled"), &steps); + let adapted = adapt_jobs(&topology(), &response(&jobs), &uploads, 600) + .expect("should adapt cancelled native reproductions"); + assert!( + adapted.optional.iter().all(|build| matches!( + build, + OptionalBuild::NotUploaded(absent) if !absent.job.started + )), + "should not treat started_at as an executed step" + ); + } +} + +#[test] +fn a_job_without_a_conclusion_is_rejected_before_the_deadline_and_accepted_at_it() { + for (status, steps) in [ + ("queued", Vec::new()), + ( + "in_progress", + vec![ + step(1, "completed", json!("success")), + step(2, "in_progress", Value::Null), + ], + ), + ] { + let (jobs, uploads) = with_native_reproductions(status, &Value::Null, &steps); + assert_rejected( + adapt_jobs(&topology(), &response(&jobs), &uploads, DEADLINE - 1), + "before the collector deadline", + ); + adapt_jobs(&topology(), &response(&jobs), &uploads, DEADLINE) + .expect("should accept a conclusion-less observation at the deadline"); + } + assert_rejected( + adapt_jobs( + &topology(), + &response(&successful_jobs()), + &all_uploads(), + DEADLINE + 1, + ), + "exceeds its deadline", + ); +} + +#[test] +fn mandatory_jobs_must_start_succeed_and_upload_under_every_wait() { + let mut cases = Vec::new(); + for (status, conclusion, steps) in [ + ("completed", json!("failure"), executed_steps()), + ("completed", json!("cancelled"), skipped_steps()), + ("completed", json!("skipped"), Vec::new()), + ("queued", Value::Null, Vec::new()), + ("waiting", Value::Null, Vec::new()), + ("requested", Value::Null, Vec::new()), + ] { + for index in [0, 1, 2] { + let mut jobs = successful_jobs(); + let name = expected_jobs()[index].name.clone(); + jobs[index] = job( + 100 + index as u64, + &name, + status, + conclusion.clone(), + steps.clone(), + ); + cases.push((jobs, all_uploads())); + } + } + for index in [0, 1, 2] { + let mut uploads = all_uploads(); + uploads.remove(index); + cases.push((successful_jobs(), uploads)); + } + for (jobs, uploads) in cases { + let unconcluded = jobs.iter().any(|job| job["conclusion"].is_null()); + for elapsed in [600, DEADLINE] { + let reason = if unconcluded && elapsed < DEADLINE { + "before the collector deadline" + } else { + "mandatory job did not succeed with an upload" + }; + assert_rejected( + adapt_jobs(&topology(), &response(&jobs), &uploads, elapsed), + reason, + ); + } + } +} + +#[test] +fn completed_job_without_a_conclusion_is_rejected_for_every_role() { + for index in [0, 1, 3] { + let mut jobs = successful_jobs(); + jobs[index]["conclusion"] = Value::Null; + for elapsed in [600, DEADLINE] { + assert_rejected( + adapt_jobs(&topology(), &response(&jobs), &all_uploads(), elapsed), + "job status and conclusion disagree", + ); + } + } +} + +#[test] +fn jobs_from_another_run_or_source_are_rejected() { + for (field, value) in [ + ("run_id", json!(RUN_ID + 1)), + ("head_sha", json!("d".repeat(40))), + ] { + for index in [0, 3, 8] { + let mut jobs = successful_jobs(); + jobs[index][field] = value.clone(); + assert_rejected( + adapt_jobs(&topology(), &response(&jobs), &all_uploads(), 600), + "another run or source", + ); + } + } +} + +#[test] +fn only_a_single_fresh_attempt_is_attestable() { + // A "re-run failed jobs" attempt lists carried-over jobs with their older + // attempt, whether they are expected or unrelated. + for index in [0, 3, 8] { + let mut carried_over = successful_jobs(); + carried_over[index]["run_attempt"] = json!(RUN_ATTEMPT - 1); + assert_rejected( + adapt_jobs(&topology(), &response(&carried_over), &all_uploads(), 600), + "only a single fresh attempt is attestable", + ); + let mut unattributed = successful_jobs(); + unattributed[index] + .as_object_mut() + .expect("should be a job object") + .remove("run_attempt"); + assert_rejected( + adapt_jobs(&topology(), &response(&unattributed), &all_uploads(), 600), + "has no run_attempt", + ); + } +} + +#[test] +fn duplicate_missing_or_extra_job_names_are_rejected() { + let expected = expected_jobs(); + let mut duplicate = successful_jobs(); + duplicate.push(job( + 901, + &expected[0].name, + "completed", + json!("success"), + executed_steps(), + )); + let mut extra_skipped = successful_jobs(); + extra_skipped.push(job( + 902, + &expected[3].name, + "completed", + json!("skipped"), + Vec::new(), + )); + let mut missing_mandatory = successful_jobs(); + missing_mandatory.remove(0); + let mut missing_optional = successful_jobs(); + missing_optional.remove(3); + let mut reused_id = successful_jobs(); + reused_id[1]["id"] = json!(100); + for (jobs, reason) in [ + (duplicate, "occurs more than once"), + (extra_skipped, "occurs more than once"), + (missing_mandatory, "missing from the run"), + (missing_optional, "missing from the run"), + (reused_id, "is reused"), + ] { + assert_rejected( + adapt_jobs(&topology(), &response(&jobs), &all_uploads(), 600), + reason, + ); + } +} + +#[test] +fn upload_outputs_must_name_exactly_one_expected_job() { + let mut unknown = all_uploads(); + unknown.push(upload("unrelated JavaScript job", 990)); + let mut duplicate = all_uploads(); + duplicate.push(upload(&expected_jobs()[0].name, 991)); + for uploads in [unknown, duplicate] { + assert_rejected( + adapt_jobs(&topology(), &response(&successful_jobs()), &uploads, 600), + "does not name exactly one expected job", + ); + } +} + +#[test] +fn unsuccessful_optional_reproduction_upload_output_is_dropped_without_blocking() { + for (status, conclusion, steps, elapsed, outcome) in [ + ( + "completed", + json!("failure"), + executed_steps(), + 600, + Outcome::Failed, + ), + ( + "completed", + json!("timed_out"), + executed_steps(), + 600, + Outcome::Failed, + ), + ( + "in_progress", + Value::Null, + vec![step(1, "in_progress", Value::Null)], + DEADLINE, + Outcome::Failed, + ), + ( + "completed", + json!("cancelled"), + skipped_steps(), + 600, + Outcome::Unavailable, + ), + ( + "queued", + Value::Null, + Vec::new(), + DEADLINE, + Outcome::Unavailable, + ), + ] { + let (jobs, _) = with_native_reproductions(status, &conclusion, &steps); + let adapted = adapt_jobs(&topology(), &response(&jobs), &all_uploads(), elapsed) + .expect("should not let an optional reproduction block the collection"); + assert_eq!( + adapted.required.len(), + 5, + "should keep every mandatory upload" + ); + assert_eq!( + optional_outcomes(&adapted), + vec![Some(outcome); 3], + "should record the derived outcome instead of the upload identity" + ); + } +} + +#[test] +fn unknown_or_inconsistent_github_states_are_rejected() { + let mut cases = Vec::new(); + for conclusion in [ + "neutral", + "action_required", + "stale", + "startup_failure", + "SUCCESS", + ] { + cases.push(( + with_native_reproductions("completed", &json!(conclusion), &executed_steps()), + "job conclusion has no collector meaning", + )); + } + for (status, conclusion, steps, reason) in [ + ( + "completed", + Value::Null, + executed_steps(), + "job status and conclusion disagree", + ), + ( + "in_progress", + json!("success"), + executed_steps(), + "job status and conclusion disagree", + ), + ( + "queued", + Value::Null, + executed_steps(), + "waiting workflow job reports an executed step", + ), + ("unknown", Value::Null, Vec::new(), "job status is unknown"), + ( + "completed", + json!("failure"), + vec![step(1, "completed", json!("neutral"))], + "step conclusion has no collector meaning", + ), + ( + "completed", + json!("failure"), + vec![step(1, "completed", Value::Null)], + "step status and conclusion disagree", + ), + ( + "completed", + json!("failure"), + vec![step(1, "queued", json!("success"))], + "step status and conclusion disagree", + ), + ( + "completed", + json!("failure"), + vec![ + step(1, "completed", json!("success")), + step(1, "completed", json!("success")), + ], + "unnamed or renumbered", + ), + ] { + cases.push(( + with_native_reproductions(status, &conclusion, &steps), + reason, + )); + } + let (mut jobs, uploads) = with_native_reproductions("completed", &json!("failure"), &[]); + jobs[3] + .as_object_mut() + .expect("should be a job object") + .remove("steps"); + cases.push(((jobs, uploads), "no bounded step list")); + for ((jobs, uploads), reason) in cases { + assert_rejected( + adapt_jobs(&topology(), &response(&jobs), &uploads, DEADLINE), + reason, + ); + } +} + +#[test] +fn a_truncated_default_page_is_rejected_as_incomplete() { + // A fetch without `per_page=100` lists at most 30 of a larger run. + let jobs = successful_jobs(); + let truncated = serde_json::to_vec(&json!({"total_count": jobs.len() + 31, "jobs": jobs})) + .expect("should serialize fictional jobs response"); + assert_rejected( + adapt_jobs(&topology(), &truncated, &all_uploads(), 600), + "not one complete page", + ); +} + +#[test] +fn malformed_oversized_incomplete_or_duplicate_key_responses_are_rejected() { + const STRICT: &str = "not strict bounded JSON"; + const PAGE: &str = "not one complete page"; + let mut cases = vec![ + (b"not json".to_vec(), STRICT), + ( + b"{\"total_count\":0,\"jobs\":[],\"jobs\":[]}".to_vec(), + STRICT, + ), + ( + serde_json::to_vec(&json!({"jobs": successful_jobs()})).expect("should serialize"), + PAGE, + ), + ( + serde_json::to_vec(&json!({"total_count": 99, "jobs": successful_jobs()})) + .expect("should serialize"), + PAGE, + ), + ( + serde_json::to_vec(&json!([successful_jobs()])).expect("should serialize"), + PAGE, + ), + ]; + let mut oversized = response(&successful_jobs()); + oversized.extend(vec![b' '; 1024 * 1024]); + cases.push((oversized, STRICT)); + let many = (0..101) + .map(|index| { + job( + 2000 + index, + &format!("unrelated {index}"), + "completed", + json!("success"), + Vec::new(), + ) + }) + .collect::>(); + cases.push((response(&many), PAGE)); + let duplicate_job_key = String::from_utf8(response(&successful_jobs())) + .expect("should be UTF-8 fixture") + .replacen("\"name\":", "\"name\":\"shadow\",\"name\":", 1); + cases.push((duplicate_job_key.into_bytes(), STRICT)); + for (body, reason) in cases { + assert_rejected(adapt_jobs(&topology(), &body, &all_uploads(), 600), reason); + } +} + +#[test] +fn expected_topology_must_name_every_fixed_build_exactly_once() { + let mut missing = expected_jobs(); + missing.pop(); + assert_rejected( + ExpectedTopology::new(run_identity(), missing, DEADLINE), + "must name every fixed canonical and reproduction job", + ); + let mut cases = Vec::new(); + let mut duplicate_slot = expected_jobs(); + duplicate_slot[7].role = Role::Canonical; + cases.push(duplicate_slot); + let mut duplicate_name = expected_jobs(); + duplicate_name[7].name = duplicate_name[6].name.clone(); + cases.push(duplicate_name); + let mut unknown_asset = expected_jobs(); + unknown_asset[7].asset = "trusted-server-other".to_owned(); + cases.push(unknown_asset); + for name in [String::new(), "x".repeat(129), "line\nbreak".to_owned()] { + let mut invalid_name = expected_jobs(); + invalid_name[7].name = name; + cases.push(invalid_name); + } + let mut invalid_recipe = expected_jobs(); + invalid_recipe[7].recipe_sha256 = "C".repeat(64); + cases.push(invalid_recipe); + for jobs in cases { + assert_rejected( + ExpectedTopology::new(run_identity(), jobs, DEADLINE), + "expected job is invalid, unknown or not unique", + ); + } + const IDENTITY: &str = "expected run identity is invalid"; + const DEADLINE_BOUND: &str = "collector deadline is not bounded"; + for (identity, deadline, reason) in [ + ( + RunIdentity { + run_id: 0, + ..run_identity() + }, + DEADLINE, + IDENTITY, + ), + ( + RunIdentity { + run_attempt: 0, + ..run_identity() + }, + DEADLINE, + IDENTITY, + ), + ( + RunIdentity { + head_sha: "a".repeat(39), + ..run_identity() + }, + DEADLINE, + IDENTITY, + ), + (run_identity(), 0, DEADLINE_BOUND), + (run_identity(), 5401, DEADLINE_BOUND), + ] { + assert_rejected( + ExpectedTopology::new(identity, expected_jobs(), deadline), + reason, + ); + } +} diff --git a/crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs b/crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs new file mode 100644 index 0000000000..1c3f79d45b --- /dev/null +++ b/crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs @@ -0,0 +1,216 @@ +//! Fictional GitHub Actions jobs fixtures shared by the adapter tests. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use crate::collection::records::Outcome; +use crate::collection::tests::zip; +use crate::collection::workflow_jobs::{ + AdaptedJobs, ExpectedJob, ExpectedTopology, JobUpload, Result, RunIdentity, UploadOutput, +}; +use crate::collection::{OptionalBuild, digest}; +use crate::policy::FIXED_ARTIFACTS; +use crate::recipe::Role; + +/// Fictional run identity used by every fixture job. +pub(super) const RUN_ID: u64 = 7001; +pub(super) const RUN_ATTEMPT: u64 = 2; +pub(super) const DEADLINE: u64 = 1800; + +pub(super) fn head_sha() -> String { + "a".repeat(40) +} + +fn job_name(asset: &str, role: Role) -> String { + let label = match role { + Role::Canonical => "canonical", + Role::Reproduction => "reproduction", + }; + format!("build {asset} {label}") +} + +pub(super) fn expected_jobs() -> Vec { + FIXED_ARTIFACTS + .iter() + .flat_map(|asset| { + [Role::Canonical, Role::Reproduction].map(|role| ExpectedJob { + asset: asset.name.to_owned(), + role, + name: job_name(asset.name, role), + recipe_sha256: "c".repeat(64), + }) + }) + .collect() +} + +pub(super) fn run_identity() -> RunIdentity { + RunIdentity { + run_id: RUN_ID, + run_attempt: RUN_ATTEMPT, + head_sha: head_sha(), + } +} + +pub(super) fn topology() -> ExpectedTopology { + ExpectedTopology::new(run_identity(), expected_jobs(), DEADLINE) + .expect("should accept the complete fictional topology") +} + +pub(super) fn step(number: u64, status: &str, conclusion: Value) -> Value { + let mut step = json!({ + "name": format!("Step {number}"), + "status": status, + "number": number, + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:01:00Z", + }); + step["conclusion"] = conclusion; + step +} + +pub(super) fn executed_steps() -> Vec { + (1..=3) + .map(|number| step(number, "completed", json!("success"))) + .collect() +} + +pub(super) fn skipped_steps() -> Vec { + (1..=3) + .map(|number| step(number, "completed", json!("skipped"))) + .collect() +} + +pub(super) fn job( + id: u64, + name: &str, + status: &str, + conclusion: Value, + steps: Vec, +) -> Value { + let mut job = json!({ + "id": id, + "name": name, + "run_id": RUN_ID, + "run_attempt": RUN_ATTEMPT, + "head_sha": head_sha(), + "status": status, + "started_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:05:00Z", + "html_url": "https://example.com/job", + }); + job["conclusion"] = conclusion; + job["steps"] = Value::Array(steps); + job +} + +/// Every expected job succeeded with executed steps, plus one unrelated job. +pub(super) fn successful_jobs() -> Vec { + let mut jobs = expected_jobs() + .iter() + .enumerate() + .map(|(index, expected)| { + job( + 100 + index as u64, + &expected.name, + "completed", + json!("success"), + executed_steps(), + ) + }) + .collect::>(); + jobs.push(job( + 900, + "unrelated JavaScript job", + "completed", + json!("success"), + executed_steps(), + )); + jobs +} + +pub(super) fn response(jobs: &[Value]) -> Vec { + serde_json::to_vec(&json!({"total_count": jobs.len(), "jobs": jobs})) + .expect("should serialize fictional jobs response") +} + +pub(super) fn upload(name: &str, artifact_id: u64) -> JobUpload { + JobUpload { + job: name.to_owned(), + output: UploadOutput { + artifact_id, + artifact_name: format!("upload-{artifact_id}"), + archive_sha256: digest(&archive()), + members: BTreeMap::from([("artifact".to_owned(), 1)]), + }, + } +} + +pub(super) fn archive() -> Vec { + zip(&[("artifact", b"x")], false) +} + +pub(super) fn all_uploads() -> Vec { + expected_jobs() + .iter() + .enumerate() + .map(|(index, expected)| upload(&expected.name, 500 + index as u64)) + .collect() +} + +pub(super) fn is_native_reproduction(expected: &ExpectedJob) -> bool { + expected.role == Role::Reproduction && !expected.mandatory() +} + +/// Replaces every native reproduction job and drops its upload. +pub(super) fn with_native_reproductions( + status: &str, + conclusion: &Value, + steps: &[Value], +) -> (Vec, Vec) { + let expected = expected_jobs(); + let mut jobs = successful_jobs(); + for (index, slot) in expected.iter().enumerate() { + if is_native_reproduction(slot) { + jobs[index] = job( + 100 + index as u64, + &slot.name, + status, + conclusion.clone(), + steps.to_vec(), + ); + } + } + let uploads = all_uploads() + .into_iter() + .filter(|upload| { + !expected + .iter() + .any(|slot| slot.name == upload.job && is_native_reproduction(slot)) + }) + .collect(); + (jobs, uploads) +} + +/// Requires a rejection whose report names the expected reason. +/// +/// A rejection for an unrelated reason would let a mutated fixture pass +/// without exercising the check under test. +pub(super) fn assert_rejected(result: Result, reason: &str) { + let error = result.expect_err("should reject the observation"); + assert!( + format!("{error:?}").contains(reason), + "should reject because {reason:?}, got {error:?}" + ); +} + +pub(super) fn optional_outcomes(adapted: &AdaptedJobs) -> Vec> { + adapted + .optional + .iter() + .map(|build| match build { + OptionalBuild::Uploaded { .. } => None, + OptionalBuild::NotUploaded(absent) => absent.outcome(), + }) + .collect() +} diff --git a/crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs b/crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs new file mode 100644 index 0000000000..1b8185accc --- /dev/null +++ b/crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs @@ -0,0 +1,99 @@ +//! Adapter output fed through the existing exact-ID transfer validation. + +use super::*; +use crate::collection::records::RoleProfile; +use crate::collection::{ + ArtifactSource, Download, Invocation, TrustedDependencies, digest, validate_transfers, +}; + +struct RecordingSource { + archive: Vec, + requested: Vec, +} + +impl ArtifactSource for RecordingSource { + fn metadata(&mut self, _repository: &str, id: u64) -> Result> { + self.requested.push(id); + Ok(serde_json::to_vec(&json!({ + "id": id, + "name": format!("upload-{id}"), + "expired": false, + "digest": format!("sha256:{}", digest(&self.archive)), + "workflow_run": { + "id": RUN_ID, + "head_sha": head_sha(), + "repository_id": 17, + "head_repository_id": 17, + }, + })) + .expect("should serialize fictional metadata")) + } + + fn download(&mut self, _repository: &str, id: u64) -> Result { + self.requested.push(id); + Ok(Download { + archive: self.archive.clone(), + transfer_warning: false, + }) + } +} + +#[test] +fn adapted_jobs_feed_exact_uploads_and_absent_reproductions_into_transfer_validation() { + let (never_started, mandatory_uploads) = with_native_reproductions("queued", &Value::Null, &[]); + let (failed, _) = with_native_reproductions("completed", &json!("failure"), &executed_steps()); + // The failed native reproductions also report upload outputs, which the + // adapter must drop rather than fetch. + for (label, jobs, uploads, elapsed) in [ + ( + "never started", + never_started, + mandatory_uploads.clone(), + DEADLINE, + ), + ("failed with upload output", failed, all_uploads(), 600), + ] { + let adapted = adapt_jobs(&topology(), &response(&jobs), &uploads, elapsed) + .unwrap_or_else(|error| panic!("should adapt native reproductions {label}: {error:?}")); + let dependencies = TrustedDependencies { + invocation: Invocation { + repository: "example/project".to_owned(), + repository_id: 17, + run_id: RUN_ID, + source_commit: head_sha(), + inventory_sha256: "b".repeat(64), + profiles: RoleProfile::fixtures(), + }, + required: adapted.required, + optional: adapted.optional, + }; + let mut source = RecordingSource { + archive: archive(), + requested: Vec::new(), + }; + + let validated = validate_transfers(&dependencies, &mut source) + .expect("should validate adapted exact-ID transfers"); + + assert_eq!( + validated.transfers.len(), + 5, + "should fetch every mandatory upload when native reproductions {label}" + ); + assert_eq!( + validated.absent.len(), + 3, + "should keep absent native reproductions when {label}" + ); + let mut fetched = source.requested.clone(); + fetched.dedup(); + let mandatory = mandatory_uploads + .iter() + .map(|upload| upload.output.artifact_id); + assert_eq!( + fetched, + mandatory.collect::>(), + "should fetch only mandatory upload identities when native reproductions {label}" + ); + } +} diff --git a/crates/trusted-server-release/src/composition/native/tests.rs b/crates/trusted-server-release/src/composition/native/tests.rs index c5473fbb2e..ac12e03e6c 100644 --- a/crates/trusted-server-release/src/composition/native/tests.rs +++ b/crates/trusted-server-release/src/composition/native/tests.rs @@ -636,6 +636,50 @@ fn checks_attempt_deadlines_during_collection_and_rehash() { assert!(result.is_err(), "should reject a cancelled capture attempt"); } +#[test] +fn native_spdx_join_rejects_changed_source_features_context_and_duplicate_contributors() { + for mutation in [ + "source", + "features", + "context", + "duplicate", + "missing", + "name", + "version", + "classification", + "unknown", + ] { + let mut fixture = Fixture::new("ring"); + let mut inventory = fixture + .collect() + .expect("should collect checked native input"); + let origins = crate::composition::origin::VerifiedPackageOrigins::fixture_native_registry( + &fixture.observations, + ); + match mutation { + "source" => inventory.contributors[0].original_sha256 = "b".repeat(64), + "features" => inventory.contributors[0] + .features + .push("changed".to_owned()), + "context" => inventory.contributors[0].context = UnitContext::Host, + "duplicate" => inventory + .contributors + .push(inventory.contributors[0].clone()), + "missing" => fixture.observations.packages.clear(), + "name" => inventory.contributors[0].name = "other".to_owned(), + "version" => inventory.contributors[0].version = "99.0.0".to_owned(), + "classification" => inventory.contributors[0].classification = "unknown", + "unknown" => inventory.contributors[0].package_id = "unknown opaque ID".to_owned(), + _ => unreachable!("should select known mutation"), + } + assert!( + crate::spdx::native::validate_for_tests(&inventory, &fixture.observations, &origins) + .is_err(), + "should reject native join mutation {mutation}" + ); + } +} + pub(super) fn fixture_for_projection( name: &str, context: UnitContext, @@ -662,3 +706,93 @@ pub(super) fn fixture_for_projection( .expect("should collect owner-checked native fixture"); (inventory, fixture.observations) } + +#[test] +fn native_spdx_requires_each_reviewed_consuming_context_and_unambiguous_features() { + let mut fixture = Fixture::new("ring"); + let mut inventory = fixture.collect().expect("should collect native inputs"); + let origins = crate::composition::origin::VerifiedPackageOrigins::fixture_native_registry( + &fixture.observations, + ); + inventory.contributors.clear(); + assert!( + crate::spdx::native::validate_for_tests(&inventory, &fixture.observations, &origins) + .is_err(), + "should reject missing measured receipt for observed native unit" + ); + let inventory = fixture.collect().expect("should collect complete inputs"); + fixture.observations.units.push(RustUnit { + package_id: fixture.observations.packages[0].id.clone(), + target_name: "ring".to_owned(), + target_kind: vec!["lib".to_owned()], + crate_types: vec!["lib".to_owned()], + context: UnitContext::Target, + features: vec!["alloc".to_owned()], + profile: json!({"test":false}), + filenames: vec![], + executable: None, + }); + assert!( + crate::spdx::native::validate_for_tests(&inventory, &fixture.observations, &origins) + .is_err(), + "should reject ambiguous effective features within one native context" + ); +} + +#[test] +fn native_spdx_rejects_aliases_of_the_same_verified_original_context() { + let mut fixture = Fixture::new("ring"); + let mut inventory = fixture.collect().expect("should collect native inputs"); + let mut alias = inventory.contributors[0].clone(); + alias.package_id = "relocated opaque alias".to_owned(); + fixture.observations.packages.push(RustPackage { + id: alias.package_id.clone(), + name: "ring".to_owned(), + version: "0.17.14".to_owned(), + source: Some("registry+https://example.com/index".to_owned()), + manifest_path: fixture.observations.packages[0].manifest_path.clone(), + license: None, + }); + fixture.observations.units.push(RustUnit { + package_id: alias.package_id.clone(), + target_name: "ring".to_owned(), + target_kind: vec!["lib".to_owned()], + crate_types: vec!["lib".to_owned()], + context: UnitContext::Target, + features: alias.features.clone(), + profile: json!({"test":false}), + filenames: vec![], + executable: None, + }); + inventory.contributors.push(alias); + let origins = crate::composition::origin::VerifiedPackageOrigins::fixture_native_registry( + &fixture.observations, + ); + assert!( + crate::spdx::native::validate_for_tests(&inventory, &fixture.observations, &origins) + .is_err(), + "should reject ambiguous aliases of one stable original/context identity" + ); +} + +#[test] +fn native_spdx_rejects_missing_or_conflicting_verified_package_facts() { + for mutation in ["missing", "name", "version"] { + let mut fixture = Fixture::new("ring"); + let inventory = fixture.collect().expect("should collect native inputs"); + match mutation { + "missing" => fixture.observations.packages.clear(), + "name" => fixture.observations.packages[0].name = "other".to_owned(), + "version" => fixture.observations.packages[0].version = "99.0.0".to_owned(), + _ => unreachable!("should select known mutation"), + } + let origins = crate::composition::origin::VerifiedPackageOrigins::fixture_native_registry( + &fixture.observations, + ); + let (_, observed) = fixture_for_projection("ring", UnitContext::Target); + assert!( + crate::spdx::native::validate_for_tests(&inventory, &observed, &origins).is_err(), + "should reject verified facts with {mutation}" + ); + } +} diff --git a/crates/trusted-server-release/src/distribution.rs b/crates/trusted-server-release/src/distribution.rs new file mode 100644 index 0000000000..b66d248539 --- /dev/null +++ b/crates/trusted-server-release/src/distribution.rs @@ -0,0 +1,546 @@ +//! Unqualified candidate release index assembled from completed builds. +//! +//! A [`CandidateIndex`] joins exactly one canonical entry per fixed cohort +//! asset into release-index v1 bytes that pass the retained governing schema +//! and mirrored consumer semantic checks. It is a candidate only: +//! +//! - its tag is the fixed sentinel [`CANDIDATE_TAG`], never a release tag; +//! - its bytes are only ever named [`CANDIDATE_INDEX_NAME`], never +//! `release-index.json`; +//! - both signature bundle slots are [`BundleSlot::AwaitingSignature`] and +//! render the true empty-file record, because [`SignedCompanions`] is +//! uninhabited and no file is written for them; +//! - every unsigned companion carries `"release_qualification": false` and +//! its explicit gaps. +//! +//! No command, writer, signer or publisher consumes this module. Only the +//! test-only `transfer` and `ordinary` modules write candidate files, for the +//! ordinary-CI cross-job join. Nothing here grants signing, publication or +//! qualification authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{ + Role as DocumentRole, canonical_json, is_safe_flat_name, lower_hex, parse_json, + validate_release_index, validate_release_index_artifact, +}; +use crate::error::ReleaseError; +use crate::policy::{COHORT_ID, FIXED_ARTIFACTS, REPOSITORY}; +use crate::recipe::AssetId; + +mod entry; +mod joins; +#[cfg(test)] +mod ordinary; +#[cfg(test)] +mod summary; +#[cfg(test)] +pub(crate) mod transfer; + +#[cfg(test)] +mod tests; + +#[allow( + unused_imports, + reason = "the completed-build entry path has no production caller yet" +)] +pub(crate) use entry::{ + AcceptanceKind, CandidateAcceptance, candidate_entry_from_completed, check_candidate_owner, +}; + +/// Fixed tag of every candidate index; never an official release tag. +pub(crate) const CANDIDATE_TAG: &str = "v0.0.0-unqualified-candidate"; +/// The only name candidate index bytes are ever given. +pub(crate) const CANDIDATE_INDEX_NAME: &str = "candidate-release-index.json"; +/// Fixed name of the shared JS inventory asset. +const INVENTORY_NAME: &str = "tsjs-inventory.json"; +/// SHA-256 of zero bytes: the true digest of an empty bundle file record. +const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +/// Gaps every candidate index states in addition to its entries' gaps. +const INDEX_GAPS: [&str; 2] = [ + "candidate-tag-sentinel", + "signature-bundles-awaiting-signature", +]; +/// Signature bundle companion keys and their asset-name suffixes. +const BUNDLE_SUFFIXES: [(&str, &str); 2] = [ + ("provenance", ".provenance.sigstore.json"), + ("sbom_attestation", ".sbom.sigstore.json"), +]; + +/// Builds a distribution error carrying `message`. +fn distribution_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Distribution).attach(message.to_string()) +} + +/// Lowercase hexadecimal SHA-256 of the exact bytes. +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Signed companion bundles; uninhabited until isolated signing exists. +pub(crate) enum SignedCompanions {} + +/// One signature bundle slot of an index artifact entry. +pub(crate) enum BundleSlot { + /// No bundle exists yet; the slot renders the true empty-file record. + AwaitingSignature, + /// A signed bundle; unreachable because [`SignedCompanions`] is uninhabited. + Signed(SignedCompanions), +} + +impl BundleSlot { + /// Renders this slot's index file record under `name`. + fn record(&self, name: &str) -> Value { + match self { + Self::AwaitingSignature => json!({"name": name, "size": 0, "sha256": EMPTY_SHA256}), + Self::Signed(signed) => match *signed {}, + } + } +} + +/// The four present unsigned companion roles of one artifact entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CompanionRole { + Sbom, + BuildEvidence, + BuildRecipe, + RebuildCheck, +} + +impl CompanionRole { + const ALL: [Self; 4] = [ + Self::Sbom, + Self::BuildEvidence, + Self::BuildRecipe, + Self::RebuildCheck, + ]; + + /// The index companion field name. + fn key(self) -> &'static str { + match self { + Self::Sbom => "sbom", + Self::BuildEvidence => "build_evidence", + Self::BuildRecipe => "build_recipe", + Self::RebuildCheck => "rebuild_check", + } + } + + /// The required suffix after the asset name. + fn suffix(self) -> &'static str { + match self { + Self::Sbom => ".spdx.json", + Self::BuildEvidence => ".build-evidence.json", + Self::BuildRecipe => ".build-recipe.json", + Self::RebuildCheck => ".rebuild-check.json", + } + } + + /// The byte bound of this companion role. + fn limit(self) -> u64 { + match self { + Self::Sbom => DocumentRole::Spdx.limit(), + Self::BuildEvidence | Self::BuildRecipe | Self::RebuildCheck => { + DocumentRole::Companion.limit() + } + } + } +} + +/// One rendered unsigned companion and the identity recorded for it. +#[derive(Clone, Debug)] +struct CompanionFile { + role: CompanionRole, + name: String, + size: u64, + sha256: String, + bytes: Vec, +} + +impl CompanionFile { + /// Records `bytes` under the role-derived name for `asset`. + fn new(role: CompanionRole, asset: &str, bytes: Vec) -> Self { + Self { + role, + name: format!("{asset}{}", role.suffix()), + size: bytes.len() as u64, + sha256: sha256_hex(&bytes), + bytes, + } + } + + fn record(&self) -> Value { + json!({"name": self.name, "size": self.size, "sha256": self.sha256}) + } +} + +/// One canonical cohort asset's checked index entry and companion bytes. +/// +/// Only [`candidate_entry_from_completed`] constructs it in production; its +/// fields are private and production has no byte or JSON constructor. The +/// single exception is test-only: `distribution::transfer` rebuilds an entry +/// from an ordinary-CI job's transferred files and reruns every producer join +/// over those bytes, so the four real entries can be joined across CI jobs. +#[derive(Clone, Debug)] +pub(crate) struct CandidateArtifactEntry { + asset: AssetId, + source_commit: String, + policy_version: u64, + policy_sha256: String, + inventory: Vec, + artifact_size: u64, + artifact_sha256: String, + companions: Vec, + gaps: Vec, +} + +impl CandidateArtifactEntry { + /// The cohort asset name of this entry. + pub(crate) fn name(&self) -> &'static str { + self.asset.artifact().name + } + + /// Sorted, unique, non-empty gaps stated by this entry's companions. + pub(crate) fn gaps(&self) -> &[String] { + &self.gaps + } + + /// Rehashes every companion and checks its role-derived safe name. + fn check_companions(&self, names: &mut BTreeSet) -> Result<(), Report> { + let roles: Vec = self.companions.iter().map(|file| file.role).collect(); + if roles != CompanionRole::ALL { + return Err(distribution_error(format!( + "entry `{}` must carry exactly its four unsigned companions", + self.name() + ))); + } + for file in &self.companions { + if file.name != format!("{}{}", self.name(), file.role.suffix()) { + return Err(distribution_error(format!( + "companion `{}` is not named for its asset and role", + file.name.escape_debug() + ))); + } + insert_name(names, &file.name)?; + if file.size == 0 + || file.size > file.role.limit() + || file.size != file.bytes.len() as u64 + || file.sha256 != sha256_hex(&file.bytes) + { + return Err(distribution_error(format!( + "companion `{}` bytes differ from its recorded size or digest", + file.name + ))); + } + } + Ok(()) + } + + /// Renders this entry's index artifact object. + fn index_entry(&self, inventory_sha256: &str) -> Value { + let mut value = self.asset.artifact().index_identity(); + value["size"] = json!(self.artifact_size); + value["sha256"] = json!(self.artifact_sha256); + value["js_inventory_sha256"] = json!(inventory_sha256); + let mut companions = serde_json::Map::new(); + for file in &self.companions { + companions.insert(file.role.key().to_owned(), file.record()); + } + for (key, suffix) in BUNDLE_SUFFIXES { + let name = format!("{}{suffix}", self.name()); + companions.insert(key.to_owned(), BundleSlot::AwaitingSignature.record(&name)); + } + value["companions"] = Value::Object(companions); + value + } +} + +/// Inserts a safe flat name, rejecting case-insensitive duplicates. +fn insert_name(names: &mut BTreeSet, name: &str) -> Result<(), Report> { + if !is_safe_flat_name(name) { + return Err(distribution_error(format!( + "`{}` is not a safe flat release name", + name.escape_debug() + ))); + } + if !names.insert(name.to_ascii_lowercase()) { + return Err(distribution_error(format!( + "`{name}` collides with another release name" + ))); + } + Ok(()) +} + +/// Validated unsigned candidate index bytes and every file they name. +/// +/// It carries no signature, publication or qualification capability. +#[derive(Debug)] +pub(crate) struct CandidateIndex { + bytes: Vec, + sha256: String, + inventory: Vec, + companions: BTreeMap>, + gaps: Vec, +} + +impl CandidateIndex { + /// The fixed candidate index name; never `release-index.json`. + pub(crate) fn name(&self) -> &'static str { + CANDIDATE_INDEX_NAME + } + + /// The exact canonical index bytes. + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// SHA-256 of the exact index bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// The exact shared JS inventory bytes the index names. + pub(crate) fn inventory(&self) -> &[u8] { + &self.inventory + } + + /// The sixteen present unsigned companion files, by exact name. + pub(crate) fn companions(&self) -> &BTreeMap> { + &self.companions + } + + /// Sorted, unique gaps of the index and all of its entries. + pub(crate) fn gaps(&self) -> &[String] { + &self.gaps + } +} + +/// Assembles the candidate index from exactly one entry per cohort asset. +/// +/// Entries may arrive in any order; artifacts are emitted sorted by name, so +/// equal entries always render identical bytes. +/// +/// # Errors +/// +/// Rejects a missing, extra or duplicated asset, entries from different +/// source commits, producer policies or inventories, unsafe or colliding +/// names, companion bytes differing from their records, and any rendered +/// index that fails the retained schema or mirrored consumer checks. +pub(crate) fn assemble_candidate_index( + entries: &[CandidateArtifactEntry], +) -> Result> { + let ordered = cohort_order(entries)?; + let shared = check_shared_identity(&ordered)?; + let inventory_sha256 = sha256_hex(shared.inventory); + let mut names = BTreeSet::new(); + insert_name(&mut names, CANDIDATE_INDEX_NAME)?; + insert_name(&mut names, INVENTORY_NAME)?; + let mut artifacts = Vec::with_capacity(ordered.len()); + let mut companions = BTreeMap::new(); + let mut gaps: BTreeSet = INDEX_GAPS.iter().map(|gap| (*gap).to_owned()).collect(); + for entry in &ordered { + insert_name(&mut names, entry.name())?; + entry.check_companions(&mut names)?; + let artifact = entry.index_entry(&inventory_sha256); + validate_release_index_artifact(&artifact) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + artifacts.push(artifact); + for file in &entry.companions { + companions.insert(file.name.clone(), file.bytes.clone()); + } + gaps.extend(entry.gaps.iter().cloned()); + } + let index = json!({ + "schema_version": 1, + "repository": REPOSITORY, + "tag": CANDIDATE_TAG, + "source_commit": shared.source_commit, + "recipe_source_commit": shared.source_commit, + "cohort_source_commit": shared.source_commit, + "cohort": COHORT_ID, + "policy_version": shared.policy_version, + "policy_sha256": shared.policy_sha256, + "tsjs_inventory": { + "name": INVENTORY_NAME, + "size": shared.inventory.len(), + "sha256": inventory_sha256, + }, + "artifacts": artifacts, + }); + let bytes = canonical_json(&index, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + check_rendered_index(&bytes)?; + Ok(CandidateIndex { + sha256: sha256_hex(&bytes), + bytes, + inventory: shared.inventory.to_vec(), + companions, + gaps: gaps.into_iter().collect(), + }) +} + +/// Orders entries by asset name, requiring each fixed asset exactly once. +fn cohort_order( + entries: &[CandidateArtifactEntry], +) -> Result, Report> { + let mut ordered: BTreeMap<&str, &CandidateArtifactEntry> = BTreeMap::new(); + for entry in entries { + if ordered.insert(entry.name(), entry).is_some() { + return Err(distribution_error(format!( + "asset `{}` appears more than once", + entry.name() + ))); + } + } + if let Some(missing) = FIXED_ARTIFACTS + .iter() + .find(|fixed| !ordered.contains_key(fixed.name)) + { + return Err(distribution_error(format!( + "candidate index is missing asset `{}`", + missing.name + ))); + } + if ordered.len() != FIXED_ARTIFACTS.len() { + return Err(distribution_error( + "candidate index must list exactly the fixed cohort assets", + )); + } + Ok(ordered.into_values().collect()) +} + +/// The one source, policy and inventory every entry must share. +struct SharedIdentity<'a> { + source_commit: &'a str, + policy_version: u64, + policy_sha256: &'a str, + inventory: &'a [u8], +} + +fn check_shared_identity<'a>( + ordered: &[&'a CandidateArtifactEntry], +) -> Result, Report> { + let first = ordered + .first() + .ok_or_else(|| distribution_error("candidate index has no entries"))?; + let shared = SharedIdentity { + source_commit: &first.source_commit, + policy_version: first.policy_version, + policy_sha256: &first.policy_sha256, + inventory: &first.inventory, + }; + if !lower_hex(shared.source_commit, 40) + || !lower_hex(shared.policy_sha256, 64) + || shared.policy_version < 1 + || shared.inventory.is_empty() + { + return Err(distribution_error( + "candidate source or producer policy identity is malformed", + )); + } + for entry in ordered { + if entry.source_commit != shared.source_commit { + return Err(distribution_error( + "candidate entries come from different source commits", + )); + } + if entry.policy_version != shared.policy_version + || entry.policy_sha256 != shared.policy_sha256 + { + return Err(distribution_error( + "candidate entries name different producer policies", + )); + } + if entry.inventory.as_slice() != shared.inventory { + return Err(distribution_error( + "candidate entries name different JS inventories", + )); + } + } + Ok(shared) +} + +/// Re-parses rendered bytes and repeats the consumer's structural and +/// semantic checks, including the candidate-only sentinels. +fn check_rendered_index(bytes: &[u8]) -> Result<(), Report> { + let parsed = parse_json(bytes, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + validate_release_index(&parsed) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + let source = parsed["source_commit"].as_str().unwrap_or_default(); + if parsed["schema_version"] != 1 + || parsed["repository"] != REPOSITORY + || parsed["cohort"] != COHORT_ID + || parsed["tag"] != CANDIDATE_TAG + || parsed["recipe_source_commit"] != source + || parsed["cohort_source_commit"] != source + || parsed["tsjs_inventory"]["name"] != INVENTORY_NAME + { + return Err(distribution_error( + "rendered candidate index header differs from the candidate contract", + )); + } + let artifacts = parsed["artifacts"] + .as_array() + .ok_or_else(|| distribution_error("rendered candidate index has no artifacts"))?; + if artifacts.len() != FIXED_ARTIFACTS.len() { + return Err(distribution_error( + "rendered candidate index must list exactly the cohort artifacts", + )); + } + artifacts + .iter() + .try_for_each(|artifact| check_rendered_artifact(artifact, &parsed["tsjs_inventory"])) +} + +fn check_rendered_artifact( + artifact: &Value, + inventory: &Value, +) -> Result<(), Report> { + let name = artifact["name"].as_str().unwrap_or_default(); + let fixed = FIXED_ARTIFACTS + .iter() + .find(|fixed| fixed.name == name) + .ok_or_else(|| distribution_error(format!("unknown rendered asset `{name}`")))?; + let identity = fixed.index_identity(); + let identity_fields = identity + .as_object() + .ok_or_else(|| distribution_error("fixed identity must be an object"))?; + if identity_fields + .iter() + .any(|(key, value)| &artifact[key] != value) + { + return Err(distribution_error(format!( + "rendered asset `{name}` differs from its fixed identity" + ))); + } + if artifact["js_inventory_sha256"] != inventory["sha256"] { + return Err(distribution_error(format!( + "rendered asset `{name}` names a different JS inventory" + ))); + } + let companions = &artifact["companions"]; + for role in CompanionRole::ALL { + let record = &companions[role.key()]; + if record["name"] != format!("{name}{}", role.suffix()) + || record["size"].as_u64().is_none_or(|size| size == 0) + { + return Err(distribution_error(format!( + "rendered asset `{name}` has a missing or empty {} companion", + role.key() + ))); + } + } + for (key, suffix) in BUNDLE_SUFFIXES { + let expected = BundleSlot::AwaitingSignature.record(&format!("{name}{suffix}")); + if companions[key] != expected { + return Err(distribution_error(format!( + "rendered asset `{name}` {key} bundle is not the awaiting-signature sentinel" + ))); + } + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/distribution/entry.rs b/crates/trusted-server-release/src/distribution/entry.rs new file mode 100644 index 0000000000..28edc006a7 --- /dev/null +++ b/crates/trusted-server-release/src/distribution/entry.rs @@ -0,0 +1,534 @@ +//! One canonical candidate entry and its rendered unsigned companions. +//! +//! [`candidate_entry_from_completed`] is the only production constructor: it +//! binds a canonical [`RecipeCompleted`] to its approved source and its actual +//! runtime or startup acceptance, renders the candidate SPDX itself, and +//! renders build evidence, recipe and rebuild-check companions whose joins +//! are rechecked by the producer copy of the consumer rules. [`EntryParts`] +//! is a private plain struct so the pure renderer can be tested directly; +//! production has no byte or JSON constructor. The one exception is +//! test-only: `distribution::transfer` rebuilds an entry from the flat files +//! an ordinary-CI candidate job uploaded, reruns every join above over those +//! bytes and derives the policy identity from collector-read policy bytes. +//! It exists only under `cfg(test)` and grants no release authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::joins::{ + EvidenceJoin, ModuleIdentity, check_build_evidence, check_comparison, check_spdx_root, + inventory_modules, parse_informational, +}; +use super::{CandidateArtifactEntry, CompanionFile, CompanionRole, distribution_error, sha256_hex}; +use crate::documents::{Role as DocumentRole, canonical_json, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::policy::{CLI_POLICY_PATH, CLI_POLICY_VERSION, ReleaseTarget}; +use crate::recipe::{ + AssetId, FastlyEqualityChecked, FastlyRuntimeAccepted, NativeStartupAccepted, RecipeCompleted, + Role, +}; +use crate::source::ApprovedSource; +use crate::spdx::candidate::candidate_from_completed; + +/// Gaps every candidate entry states until signing and workflow binding exist. +pub(super) const ENTRY_GAPS: [&str; 4] = [ + "authenticated-bootstrap-not-established", + "signature-bundles-awaiting-signature", + "trusted-workflow-transfer-not-bound", + "workflow-run-identity-unavailable", +]; +/// Gap stated when no reproduction comparison exists for a native asset. +pub(super) const NATIVE_REPRODUCTION_GAP: &str = "native-reproduction-unavailable"; +/// Longest accepted gap text. +pub(super) const MAX_GAP_LENGTH: usize = 256; +/// Final-byte classification of a JS module retained in the executable. +const EMBEDDED_CLASSIFICATION: &str = "conservative_embedded_js"; +/// Final-byte classification of a JS module absent from the executable. +const MATERIAL_CLASSIFICATION: &str = "js_build_material"; + +/// The actual owning acceptance required for one canonical asset. +pub(crate) enum CandidateAcceptance<'a> { + /// Final-byte Viceroy acceptance plus canonical/reproduction equality. + Fastly { + runtime: &'a FastlyRuntimeAccepted, + equality: &'a FastlyEqualityChecked, + }, + /// Actual startup of the exact copied native CLI. + Native(&'a NativeStartupAccepted), +} + +impl CandidateAcceptance<'_> { + fn kind(&self) -> AcceptanceKind { + match self { + Self::Fastly { .. } => AcceptanceKind::FastlyRuntime, + Self::Native(_) => AcceptanceKind::NativeStartup, + } + } +} + +/// Which acceptance family an asset requires. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AcceptanceKind { + /// The Fastly Wasm asset. + FastlyRuntime, + /// A native CLI asset. + NativeStartup, +} + +/// Checks that `completed` may supply a candidate entry under `kind`. +/// +/// # Errors +/// +/// Rejects a reproduction build, an approved source at another commit, or an +/// acceptance family that does not match the completed asset. +pub(crate) fn check_candidate_owner( + completed: &RecipeCompleted, + source: &ApprovedSource, + kind: AcceptanceKind, +) -> Result<(), Report> { + if completed.role() != Role::Canonical { + return Err(distribution_error( + "candidate index entries come only from the canonical build", + )); + } + if source.commit() != completed.commit() { + return Err(distribution_error( + "approved source differs from the completed build commit", + )); + } + let expected = if completed.asset().artifact().target == ReleaseTarget::Wasm32Wasip1 { + AcceptanceKind::FastlyRuntime + } else { + AcceptanceKind::NativeStartup + }; + if kind != expected { + return Err(distribution_error(format!( + "asset `{}` requires {expected:?} acceptance, not {kind:?}", + completed.asset().artifact().name + ))); + } + Ok(()) +} + +/// Renders the candidate entry of one canonical completed build. +/// +/// The candidate SPDX is rendered here from the same completed owner, and the +/// owner is rehashed again after rendering. +/// +/// # Errors +/// +/// Rejects any [`check_candidate_owner`] failure, an acceptance bound to +/// another source, role, asset or final bytes, missing composition or JS +/// evidence, a Fastly executable omitting a prepared module, an untracked +/// consumer policy, and every rendering or join failure. +pub(crate) fn candidate_entry_from_completed( + completed: &RecipeCompleted, + source: &ApprovedSource, + acceptance: &CandidateAcceptance<'_>, +) -> Result> { + check_candidate_owner(completed, source, acceptance.kind())?; + let (comparison, compatibility_tests) = bind_acceptance(completed, acceptance)?; + let spdx = candidate_from_completed(completed, source)?; + let view = completed.composition_view()?; + let javascript = completed.javascript_contributions()?; + let artifact = completed.artifact(); + let membership = view.javascript(); + if membership.artifact_sha256() != artifact.sha256() + || membership.inventory_sha256() != javascript.inventory_sha256() + { + return Err(distribution_error( + "JS membership belongs to another artifact or inventory", + )); + } + let modules = membership + .modules() + .map(|(module, classification)| { + let embedded = match classification { + EMBEDDED_CLASSIFICATION => true, + MATERIAL_CLASSIFICATION => false, + other => { + return Err(distribution_error(format!( + "unknown JS module classification `{other}`" + ))); + } + }; + Ok(ModuleObservation { + id: module.id().to_owned(), + filename: module.filename().to_owned(), + size: module.size(), + sha256: module.sha256().to_owned(), + embedded, + }) + }) + .collect::, Report>>()?; + let policy_bytes = source + .read_tracked(CLI_POLICY_PATH, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + let (policy_version, policy_sha256) = policy_identity(&policy_bytes)?; + let definition = completed.recipe_definition()?; + let parts = EntryParts { + asset: completed.asset(), + source_commit: completed.commit().as_str().to_owned(), + policy_version, + policy_sha256, + artifact_sha256: artifact.sha256().to_owned(), + artifact_size: artifact.size(), + inventory: javascript.raw_inventory().to_vec(), + modules, + spdx: spdx.bytes().to_vec(), + spdx_gaps: spdx.gaps().to_vec(), + recipe: RecipeFacts { + definition_sha256: definition.sha256().to_owned(), + invocation_sha256: completed.plan_digest().to_owned(), + cargo_lock_sha256: completed.cargo_lock_sha256().to_owned(), + materials_digest: completed.materials_digest().to_owned(), + rust_tools_digest: completed.rust_tools_digest().to_owned(), + }, + comparison, + compatibility_tests, + }; + let entry = render_entry(parts)?; + completed.rehash_for_handoff()?; + Ok(entry) +} + +/// Derives the producer policy identity from the exact approved policy bytes. +/// +/// Mirrors the CLI's `producer_identity`: the version is the integer +/// `policy_version` (at least 1) of the strictly parsed bytes and the digest is +/// the SHA-256 of those same bytes. The version must also equal the compiled +/// [`CLI_POLICY_VERSION`]. +/// +/// # Errors +/// +/// Rejects malformed JSON, a missing, zero or non-integer version, or a +/// version other than the compiled one. +pub(super) fn policy_identity(bytes: &[u8]) -> Result<(u64, String), Report> { + let policy = parse_json(bytes, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + let version = policy["policy_version"] + .as_u64() + .filter(|version| *version >= 1) + .ok_or_else(|| distribution_error("approved consumer policy has no integer version"))?; + if version != CLI_POLICY_VERSION { + return Err(distribution_error(format!( + "approved consumer policy version {version} differs from compiled version {CLI_POLICY_VERSION}" + ))); + } + Ok((version, sha256_hex(bytes))) +} + +/// Binds the acceptance to this exact completed build and derives its facts. +fn bind_acceptance( + completed: &RecipeCompleted, + acceptance: &CandidateAcceptance<'_>, +) -> Result<(RebuildComparison, Vec), Report> { + let artifact = completed.artifact(); + match acceptance { + CandidateAcceptance::Fastly { runtime, equality } => { + let observed = runtime.observed(); + if equality.asset() != completed.asset() + || equality.sha256() != artifact.sha256() + || equality.size() != artifact.size() + || runtime.source_commit() != completed.commit() + || runtime.role() != completed.role() + || observed.artifact_sha256() != artifact.sha256() + || observed.artifact_size() != artifact.size() + { + return Err(distribution_error( + "Fastly acceptance belongs to another source, role or final output", + )); + } + let facts = BTreeMap::from([ + ("viceroy_version", observed.viceroy_version().to_owned()), + ("viceroy_sha256", observed.viceroy_sha256().to_owned()), + ("fixture_sha256", observed.fixture_sha256().to_owned()), + ("health_status", observed.health_status().to_string()), + ]); + Ok(( + RebuildComparison::Matched { + reproduction_sha256: equality.sha256().to_owned(), + reproduction_size: equality.size(), + }, + vec![CompatibilityTest { + name: "fastly-final-byte-publisher-request", + facts, + }], + )) + } + CandidateAcceptance::Native(accepted) => { + let diagnostic = accepted.diagnostic_for(completed)?; + let kind = diagnostic["startup"]["kind"] + .as_str() + .filter(|_| diagnostic["release_qualification"] == false) + .ok_or_else(|| distribution_error("native startup diagnostic is incomplete"))?; + Ok(( + RebuildComparison::Unavailable, + vec![CompatibilityTest { + name: "native-startup", + facts: BTreeMap::from([("startup_kind", kind.to_owned())]), + }], + )) + } + } +} + +/// One prepared JS module and its final-byte membership. +#[derive(Clone, Debug)] +pub(super) struct ModuleObservation { + pub(super) id: String, + pub(super) filename: String, + pub(super) size: u64, + pub(super) sha256: String, + pub(super) embedded: bool, +} + +impl ModuleObservation { + fn record(&self) -> Value { + json!({"id": self.id, "filename": self.filename, "size": self.size, "sha256": self.sha256}) + } +} + +/// Recipe identities recorded as authenticated informational data. +#[derive(Clone, Debug)] +pub(super) struct RecipeFacts { + pub(super) definition_sha256: String, + pub(super) invocation_sha256: String, + pub(super) cargo_lock_sha256: String, + pub(super) materials_digest: String, + pub(super) rust_tools_digest: String, +} + +/// The informational rebuild comparison of one entry. +#[derive(Clone, Debug)] +pub(super) enum RebuildComparison { + /// Canonical and reproduction final bytes were checked identical. + Matched { + reproduction_sha256: String, + reproduction_size: u64, + }, + /// No reproduction comparison exists for this asset. + Unavailable, +} + +/// One passed compatibility test and its stable facts (no timings). +#[derive(Clone, Debug)] +pub(super) struct CompatibilityTest { + pub(super) name: &'static str, + pub(super) facts: BTreeMap<&'static str, String>, +} + +/// Plain checked facts of one entry, before rendering. +#[derive(Clone, Debug)] +pub(super) struct EntryParts { + pub(super) asset: AssetId, + pub(super) source_commit: String, + pub(super) policy_version: u64, + pub(super) policy_sha256: String, + pub(super) artifact_sha256: String, + pub(super) artifact_size: u64, + pub(super) inventory: Vec, + pub(super) modules: Vec, + pub(super) spdx: Vec, + pub(super) spdx_gaps: Vec, + pub(super) recipe: RecipeFacts, + pub(super) comparison: RebuildComparison, + pub(super) compatibility_tests: Vec, +} + +impl EntryParts { + fn is_fastly(&self) -> bool { + self.asset.artifact().target == ReleaseTarget::Wasm32Wasip1 + } + + fn check_identity(&self) -> Result<(), Report> { + if !lower_hex(&self.source_commit, 40) + || !lower_hex(&self.policy_sha256, 64) + || self.policy_version < 1 + || !lower_hex(&self.artifact_sha256, 64) + || self.artifact_size == 0 + || self.artifact_size > DocumentRole::Executable.limit() + { + return Err(distribution_error( + "candidate entry source, policy or artifact identity is malformed", + )); + } + if self.compatibility_tests.is_empty() { + return Err(distribution_error( + "candidate entry needs its passed compatibility test", + )); + } + Ok(()) + } + + /// Returns the outcome fields after checking the asset's comparison rule. + fn comparison_fields(&self) -> Result<(&'static str, Value), Report> { + match (&self.comparison, self.is_fastly()) { + ( + RebuildComparison::Matched { + reproduction_sha256, + reproduction_size, + }, + true, + ) if *reproduction_sha256 == self.artifact_sha256 + && *reproduction_size == self.artifact_size => + { + Ok(("matched", json!(reproduction_sha256))) + } + (RebuildComparison::Unavailable, false) => Ok(("unavailable", Value::Null)), + _ => Err(distribution_error( + "rebuild comparison must be the checked Fastly equality or unavailable for a native asset", + )), + } + } + + /// Sorted unique gaps: fixed entry gaps, comparison gaps and SPDX gaps. + fn gaps(&self) -> Result, Report> { + if self.spdx_gaps.is_empty() { + return Err(distribution_error("candidate SPDX must state its gaps")); + } + let mut gaps: BTreeSet = ENTRY_GAPS.iter().map(|gap| (*gap).to_owned()).collect(); + if matches!(self.comparison, RebuildComparison::Unavailable) { + gaps.insert(NATIVE_REPRODUCTION_GAP.to_owned()); + } + for gap in &self.spdx_gaps { + if gap.is_empty() + || gap.len() > MAX_GAP_LENGTH + || gap + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(distribution_error("candidate gap text is malformed")); + } + gaps.insert(gap.clone()); + } + Ok(gaps.into_iter().collect()) + } + + /// Sorted prepared modules and the embedded subset. + fn module_records(&self) -> Result<(Vec, Vec), Report> { + let mut modules: Vec<&ModuleObservation> = self.modules.iter().collect(); + modules.sort_by(|left, right| left.id.cmp(&right.id)); + if modules.windows(2).any(|pair| pair[0].id == pair[1].id) { + return Err(distribution_error("prepared JS module ids must be unique")); + } + if self.is_fastly() && modules.iter().any(|module| !module.embedded) { + return Err(distribution_error( + "Fastly executable must embed every prepared JS module", + )); + } + let prepared = modules.iter().map(|module| module.record()).collect(); + let embedded = modules + .iter() + .filter(|module| module.embedded) + .map(|module| module.record()) + .collect(); + Ok((prepared, embedded)) + } +} + +fn render(value: &Value) -> Result, Report> { + canonical_json(value, DocumentRole::Companion) + .map_err(|error| error.change_context(ReleaseError::Distribution)) +} + +/// Renders and joins one entry's unsigned companions from plain facts. +pub(super) fn render_entry( + parts: EntryParts, +) -> Result> { + parts.check_identity()?; + let gaps = parts.gaps()?; + let (outcome, reproduction_sha256) = parts.comparison_fields()?; + let (prepared, embedded) = parts.module_records()?; + let artifact = parts.asset.artifact(); + let inventory_sha256 = sha256_hex(&parts.inventory); + let compatibility: Vec = parts + .compatibility_tests + .iter() + .map(|test| json!({"name": test.name, "outcome": "passed", "facts": test.facts})) + .collect(); + let build_evidence = render(&json!({ + "domain": "trusted-server.release.candidate-build-evidence", + "schema_version": 1, + "release_qualification": false, + "gaps": gaps, + "source_commit": parts.source_commit, + "asset": artifact.name, + "target": artifact.target.triple(), + "variant": "default", + "artifact_sha256": parts.artifact_sha256, + "artifact_size": parts.artifact_size, + "js_inventory_sha256": inventory_sha256, + "prepared_modules": prepared, + "embedded_modules": embedded, + "compatibility_tests": compatibility, + "workflow_run_id": null, + "transfer_artifact_id": null, + }))?; + let recipe = &parts.recipe; + let build_recipe = render(&json!({ + "domain": "trusted-server.release.candidate-build-recipe", + "version": 1, + "release_qualification": false, + "gaps": gaps, + "source_commit": parts.source_commit, + "asset": artifact.name, + "package": artifact.package, + "target": artifact.target.triple(), + "variant": "default", + "recipe_definition_sha256": recipe.definition_sha256, + "invocation_sha256": recipe.invocation_sha256, + "inputs": { + "cargo_lock_sha256": recipe.cargo_lock_sha256, + "materials_digest": recipe.materials_digest, + "rust_tools_digest": recipe.rust_tools_digest, + }, + "workflow_run_id": null, + }))?; + let rebuild_check = render(&json!({ + "domain": "trusted-server.release.candidate-rebuild-check", + "version": 1, + "release_qualification": false, + "gaps": gaps, + "source_commit": parts.source_commit, + "asset": artifact.name, + "outcome": outcome, + "canonical_sha256": parts.artifact_sha256, + "reproduction_sha256": reproduction_sha256, + "workflow_run_id": null, + "reproduction_transfer_artifact_id": null, + }))?; + let modules: Vec = inventory_modules(&parts.inventory, &parts.source_commit)?; + check_build_evidence( + &build_evidence, + &EvidenceJoin { + source_commit: &parts.source_commit, + target: artifact.target.triple(), + artifact_sha256: &parts.artifact_sha256, + inventory_sha256: &inventory_sha256, + modules: &modules, + }, + )?; + check_spdx_root(&parts.spdx, &parts.artifact_sha256)?; + parse_informational(&build_recipe, &parts.source_commit)?; + check_comparison(&rebuild_check, &parts.source_commit)?; + let companions = vec![ + CompanionFile::new(CompanionRole::Sbom, artifact.name, parts.spdx), + CompanionFile::new(CompanionRole::BuildEvidence, artifact.name, build_evidence), + CompanionFile::new(CompanionRole::BuildRecipe, artifact.name, build_recipe), + CompanionFile::new(CompanionRole::RebuildCheck, artifact.name, rebuild_check), + ]; + Ok(CandidateArtifactEntry { + asset: parts.asset, + source_commit: parts.source_commit, + policy_version: parts.policy_version, + policy_sha256: parts.policy_sha256, + inventory: parts.inventory, + artifact_size: parts.artifact_size, + artifact_sha256: parts.artifact_sha256, + companions, + gaps, + }) +} diff --git a/crates/trusted-server-release/src/distribution/joins.rs b/crates/trusted-server-release/src/distribution/joins.rs new file mode 100644 index 0000000000..ad4cde6b2c --- /dev/null +++ b/crates/trusted-server-release/src/distribution/joins.rs @@ -0,0 +1,248 @@ +//! Producer copy of the consumer's document joins over rendered companions. +//! +//! These mirror the CLI verifier's inventory, build-evidence, SPDX root and +//! informational-companion joins so the producer rejects a candidate the +//! consumer would reject. They parse strictly and read only joined fields. +//! +//! Drift guard: the CLI test +//! `crates/trusted-server-cli/src/release/evidence/candidate_fixture_tests.rs` +//! runs the consumer's own joins over the golden fixtures this producer +//! renders, so a divergence between these copies and the CLI fails there. + +use error_stack::Report; +use serde_json::Value; + +use super::distribution_error; +use crate::documents::{Role as DocumentRole, lower_hex, parse_json}; +use crate::error::ReleaseError; + +/// Producer role whose byte limit equals the CLI's `Role::Inventory` bound. +pub(super) const INVENTORY_ROLE: DocumentRole = DocumentRole::Metadata; +/// The CLI's `bounds::INVENTORY_BYTES` inventory limit (1 MiB). +#[cfg(test)] +pub(super) const CLI_INVENTORY_BYTES: u64 = 1_048_576; + +/// SPDX document element identifier. +const DOCUMENT_ID: &str = "SPDXRef-DOCUMENT"; + +/// The JS module identity joined between inventory and build evidence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ModuleIdentity { + pub(super) id: String, + pub(super) filename: String, + pub(super) size: u64, + pub(super) sha256: String, +} + +fn parse(bytes: &[u8], role: DocumentRole) -> Result> { + parse_json(bytes, role).map_err(|error| error.change_context(ReleaseError::Distribution)) +} + +fn module_identity(value: &Value) -> Result> { + let text = |key: &str| value[key].as_str().unwrap_or_default().to_owned(); + let module = ModuleIdentity { + id: text("id"), + filename: text("filename"), + size: value["size"].as_u64().unwrap_or(u64::MAX), + sha256: text("sha256"), + }; + if module.id.is_empty() + || module.filename.is_empty() + || module.size == u64::MAX + || !lower_hex(&module.sha256, 64) + { + return Err(distribution_error( + "JS module record needs id, filename, integer size and SHA-256", + )); + } + Ok(module) +} + +fn sorted_modules(value: &Value, what: &str) -> Result, Report> { + let modules = value + .as_array() + .ok_or_else(|| distribution_error(format!("{what} must be an array")))? + .iter() + .map(module_identity) + .collect::, _>>()?; + if modules.windows(2).any(|pair| pair[0].id >= pair[1].id) { + return Err(distribution_error(format!( + "{what} must be sorted by unique module id" + ))); + } + Ok(modules) +} + +fn require_version_and_source( + document: &Value, + version_field: &str, + commit: &str, + what: &str, +) -> Result<(), Report> { + if !document.is_object() || document[version_field].as_u64() != Some(1) { + return Err(distribution_error(format!("{what} version must be 1"))); + } + if document["source_commit"] != commit { + return Err(distribution_error(format!( + "{what} source commit is not the candidate source" + ))); + } + Ok(()) +} + +/// Parses the shared JS inventory and returns its module identities. +pub(super) fn inventory_modules( + bytes: &[u8], + commit: &str, +) -> Result, Report> { + let inventory = parse(bytes, INVENTORY_ROLE)?; + require_version_and_source(&inventory, "schema_version", commit, "JS inventory")?; + let modules = sorted_modules(&inventory["modules"], "JS inventory modules")?; + if modules.is_empty() { + return Err(distribution_error("JS inventory lists no modules")); + } + Ok(modules) +} + +/// The index facts one build-evidence companion must join. +pub(super) struct EvidenceJoin<'a> { + pub(super) source_commit: &'a str, + pub(super) target: &'a str, + pub(super) artifact_sha256: &'a str, + pub(super) inventory_sha256: &'a str, + pub(super) modules: &'a [ModuleIdentity], +} + +/// Joins rendered build evidence to the index facts and shared inventory. +pub(super) fn check_build_evidence( + bytes: &[u8], + join: &EvidenceJoin<'_>, +) -> Result<(), Report> { + let evidence = parse(bytes, DocumentRole::Companion)?; + require_version_and_source( + &evidence, + "schema_version", + join.source_commit, + "build evidence", + )?; + for (field, wanted) in [ + ("target", join.target), + ("variant", "default"), + ("artifact_sha256", join.artifact_sha256), + ("js_inventory_sha256", join.inventory_sha256), + ] { + if evidence[field] != wanted { + return Err(distribution_error(format!( + "build evidence `{field}` differs from the index entry" + ))); + } + } + let prepared = sorted_modules(&evidence["prepared_modules"], "prepared modules")?; + if prepared != join.modules { + return Err(distribution_error( + "prepared JS modules differ from the shared inventory", + )); + } + let embedded = sorted_modules(&evidence["embedded_modules"], "embedded modules")?; + if let Some(unknown) = embedded + .iter() + .find(|module| !join.modules.contains(module)) + { + return Err(distribution_error(format!( + "embedded JS module `{}` is not exactly an inventory module", + unknown.id + ))); + } + Ok(()) +} + +/// Requires an SPDX 2.3 document describing one root with the executable digest. +pub(super) fn check_spdx_root( + bytes: &[u8], + artifact_sha256: &str, +) -> Result<(), Report> { + let document = parse(bytes, DocumentRole::Spdx)?; + if document["spdxVersion"] != "SPDX-2.3" || document["SPDXID"] != DOCUMENT_ID { + return Err(distribution_error("SBOM is not an SPDX 2.3 document")); + } + let mut described: Vec<&str> = document["documentDescribes"] + .as_array() + .map(|ids| ids.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + described.extend( + document["relationships"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter(|relationship| { + relationship["spdxElementId"] == DOCUMENT_ID + && relationship["relationshipType"] == "DESCRIBES" + }) + .filter_map(|relationship| relationship["relatedSpdxElement"].as_str()), + ); + described.sort_unstable(); + described.dedup(); + let [root] = described.as_slice() else { + return Err(distribution_error( + "SPDX document must describe exactly one root", + )); + }; + let packages: Vec<&Value> = document["packages"] + .as_array() + .map(|packages| { + packages + .iter() + .filter(|package| package["SPDXID"] == *root) + .collect() + }) + .unwrap_or_default(); + let [package] = packages.as_slice() else { + return Err(distribution_error("SPDX root must be exactly one package")); + }; + let checksums: Vec<&str> = package["checksums"] + .as_array() + .map(|checksums| { + checksums + .iter() + .filter(|checksum| checksum["algorithm"] == "SHA256") + .filter_map(|checksum| checksum["checksumValue"].as_str()) + .collect() + }) + .unwrap_or_default(); + if checksums.as_slice() != [artifact_sha256] { + return Err(distribution_error( + "SPDX root package checksum differs from the executable digest", + )); + } + Ok(()) +} + +/// Strictly parses a version-1 informational companion for `source_commit`. +pub(super) fn parse_informational( + bytes: &[u8], + source_commit: &str, +) -> Result> { + let document = parse(bytes, DocumentRole::Companion)?; + require_version_and_source( + &document, + "version", + source_commit, + "informational companion", + )?; + Ok(document) +} + +/// Requires a rebuild-check outcome from the closed consumer vocabulary. +pub(super) fn check_comparison( + bytes: &[u8], + source_commit: &str, +) -> Result<(), Report> { + let document = parse_informational(bytes, source_commit)?; + match document["outcome"].as_str() { + Some("matched" | "mismatched" | "failed" | "unavailable") => Ok(()), + _ => Err(distribution_error( + "rebuild check outcome must be matched, mismatched, failed or unavailable", + )), + } +} diff --git a/crates/trusted-server-release/src/distribution/ordinary.rs b/crates/trusted-server-release/src/distribution/ordinary.rs new file mode 100644 index 0000000000..0699f3d1af --- /dev/null +++ b/crates/trusted-server-release/src/distribution/ordinary.rs @@ -0,0 +1,184 @@ +//! Actual ordinary-CI four-job candidate index join, without release authority. +//! +//! The collector downloads the four exact candidate-entry uploads through the +//! bounded real backend, rebuilds each entry from its transferred bytes, +//! derives the policy identity from its own authenticated checkout and +//! assembles the unqualified candidate index. Workflow transfer binding, +//! signing and qualification stay absent. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use super::transfer::{EntryDescriptor, ReceivedEntry, assemble_received, write_candidate_index}; +use super::{CANDIDATE_INDEX_NAME, EMPTY_SHA256, sha256_hex}; +use crate::collection::transfer::{OrdinaryArtifactSelection, retrieve_ordinary_ci}; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::policy::CLI_POLICY_PATH; +use crate::preflight::audit_environment; +use crate::process::Cancellation; +use crate::recipe::AssetId; +use crate::source::{CommitId, authenticate}; + +/// Each producing job's descriptor variable and the asset it must carry. +const DESCRIPTOR_SLOTS: [(&str, &str); 4] = [ + ( + "TS_RELEASE_CANDIDATE_ENTRY_FASTLY", + "trusted-server-fastly.wasm", + ), + ( + "TS_RELEASE_CANDIDATE_ENTRY_LINUX", + "ts-x86_64-unknown-linux-gnu", + ), + ( + "TS_RELEASE_CANDIDATE_ENTRY_MACOS_ARM", + "ts-aarch64-apple-darwin", + ), + ( + "TS_RELEASE_CANDIDATE_ENTRY_MACOS_INTEL", + "ts-x86_64-apple-darwin", + ), +]; + +fn required(name: &str) -> String { + let value = + std::env::var(name).expect("should provide the explicitly selected ordinary-CI input"); + assert!(!value.is_empty(), "should provide a non-empty {name}"); + value +} + +/// Downloads one exact upload and returns its descriptor and checked members. +fn receive( + descriptor: EntryDescriptor, + run_id: u64, + head: &str, + token: &str, + deadline: Instant, +) -> ReceivedEntry { + let observed = retrieve_ordinary_ci( + OrdinaryArtifactSelection { + run_id, + run_head_commit: head.to_owned(), + artifact_id: descriptor.artifact_id(), + artifact_name: descriptor.artifact_name().to_owned(), + archive_sha256: descriptor.archive_sha256().to_owned(), + }, + token, + deadline, + Cancellation::default(), + ) + .expect("should retrieve the exact candidate-entry upload through the bounded real backend"); + let metadata = parse_json(observed.metadata(), Role::Metadata) + .expect("should parse the exact original REST response bytes"); + assert_eq!( + metadata["id"].as_u64(), + Some(descriptor.artifact_id()), + "should corroborate the exact upload ID in raw REST metadata" + ); + let members = observed + .members(descriptor.members()) + .expect("should accept the upload under the unchanged flat archive contract"); + ReceivedEntry { + descriptor, + members, + } +} + +#[test] +#[ignore = "requires the four ordinary-CI candidate-entry uploads, their exact descriptors, a retained clean clone and a read-only Actions token"] +fn real_four_job_candidate_index() { + assert_eq!( + std::env::var("GITHUB_ACTIONS").as_deref(), + Ok("true"), + "should run only in the explicit hosted ordinary-CI collector" + ); + let run_id = required("TS_RELEASE_TRANSFER_RUN_ID") + .parse::() + .expect("should select an exact numeric workflow run ID"); + assert_eq!( + required("GITHUB_RUN_ID") + .parse::() + .expect("should observe the current workflow run ID"), + run_id, + "should select the current ordinary-CI workflow run" + ); + // REST identifies the PR head; the checkout and every fixture build the + // synthetic merge commit, which is the candidate source. + let head = required("TS_RELEASE_TRANSFER_RUN_HEAD_SHA"); + let source_commit = required("TS_RELEASE_CANDIDATE_SOURCE_COMMIT"); + assert!( + lower_hex(&head, 40) && lower_hex(&source_commit, 40), + "should provide exact run-head and candidate source identities" + ); + assert_eq!( + required("GITHUB_SHA"), + source_commit, + "should build the candidate from the checked-out workflow commit" + ); + let source_root = + std::fs::canonicalize(PathBuf::from(required("TS_RELEASE_CANDIDATE_SOURCE_ROOT"))) + .expect("should resolve the retained clean clone"); + let commit = CommitId::parse(&source_commit).expect("should parse the candidate commit"); + let audited = audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) + .expect("should audit the minimal environment"); + let source = authenticate(&source_root, &commit, &audited) + .expect("should authenticate the retained clean clone at the candidate commit"); + let policy = source + .read_tracked(CLI_POLICY_PATH, Role::Metadata) + .expect("should read the tracked consumer policy from the authenticated clone"); + let index_root = PathBuf::from(required("TS_RELEASE_CANDIDATE_INDEX_ROOT")); + let token = required("GH_TOKEN"); + let descriptors: Vec = DESCRIPTOR_SLOTS + .iter() + .map(|(variable, asset)| { + let asset = AssetId::parse(asset).expect("should resolve the fixed asset"); + EntryDescriptor::parse(required(variable).as_bytes(), asset, run_id).unwrap_or_else( + |error| panic!("should accept the {variable} descriptor: {error:?}"), + ) + }) + .collect(); + super::transfer::check_distinct_uploads(&descriptors.iter().collect::>()) + .expect("should select four distinct uploads"); + let received: Vec = descriptors + .into_iter() + .map(|descriptor| { + // Each download is its own bounded transport attempt, which the + // backend caps at 15 minutes; the largest upload is a ~285 MB CLI. + let deadline = Instant::now() + Duration::from_secs(10 * 60); + receive(descriptor, run_id, &head, &token, deadline) + }) + .collect(); + let index = assemble_received(&received, &source_commit, &policy) + .unwrap_or_else(|error| panic!("should assemble the four real entries: {error:?}")); + drop(received); + write_candidate_index(&index, &index_root).expect("should write the candidate index files"); + let written = std::fs::read(index_root.join(CANDIDATE_INDEX_NAME)) + .expect("should re-read the written candidate index"); + assert_eq!( + sha256_hex(&written), + index.sha256(), + "should write exactly the assembled index bytes" + ); + let parsed: Value = serde_json::from_slice(&written).expect("should parse the written index"); + for artifact in parsed["artifacts"] + .as_array() + .expect("should list artifacts") + { + for key in ["provenance", "sbom_attestation"] { + let record = &artifact["companions"][key]; + assert_eq!( + (record["size"].as_u64(), record["sha256"].as_str()), + (Some(0), Some(EMPTY_SHA256)), + "should keep the awaiting-signature sentinel for {key}" + ); + } + } + println!( + "{}", + String::from_utf8(written).expect("should render UTF-8 index bytes") + ); + println!("{} {CANDIDATE_INDEX_NAME}", index.sha256()); + println!("unqualified candidate gaps: {}", index.gaps().join(", ")); +} diff --git a/crates/trusted-server-release/src/distribution/summary.rs b/crates/trusted-server-release/src/distribution/summary.rs new file mode 100644 index 0000000000..aa074194bf --- /dev/null +++ b/crates/trusted-server-release/src/distribution/summary.rs @@ -0,0 +1,208 @@ +//! Test-only re-check and measurement summary of one candidate entry. +//! +//! The real CI builds use it to show that an entry built from an actual +//! completed build re-passes the producer joins and the release-index +//! `#/$defs/artifact` sub-schema from its retained bytes alone, and to print +//! what the entry carries. It grants no signing, publication or +//! qualification authority. + +use std::collections::BTreeSet; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::joins::{ + EvidenceJoin, check_build_evidence, check_comparison, check_spdx_root, inventory_modules, + parse_informational, +}; +use super::{CandidateArtifactEntry, CompanionRole, distribution_error, sha256_hex}; +use crate::documents::{ + Role as DocumentRole, canonical_json, parse_json, validate_release_index_artifact, +}; +use crate::error::ReleaseError; + +/// One companion's identity recomputed from its exact bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +struct CompanionSummary { + key: &'static str, + name: String, + size: u64, + sha256: String, +} + +/// What one rechecked candidate entry carries, for the printed measurement. +#[derive(Clone, Debug)] +pub(crate) struct EntrySummary { + name: &'static str, + artifact_sha256: String, + artifact_size: u64, + inventory_sha256: String, + entry_sha256: String, + entry_size: u64, + rebuild_outcome: String, + companions: Vec, + gaps: Vec, +} + +impl EntrySummary { + /// The rechecked rebuild-check outcome. + pub(crate) fn rebuild_outcome(&self) -> &str { + &self.rebuild_outcome + } + + /// SHA-256 of the exact JS inventory bytes the entry names. + pub(crate) fn inventory_sha256(&self) -> &str { + &self.inventory_sha256 + } + + /// SHA-256 of the canonical rendered index artifact entry. + pub(crate) fn entry_sha256(&self) -> &str { + &self.entry_sha256 + } + + /// The ordinary CI measurement of this entry. + pub(crate) fn measurement(&self) -> Value { + let companions: serde_json::Map = self + .companions + .iter() + .map(|companion| { + ( + companion.key.to_owned(), + json!({ + "name": companion.name, + "size": companion.size, + "sha256": companion.sha256, + }), + ) + }) + .collect(); + json!({ + "kind": "ordinary-ci-candidate-entry", + "asset": self.name, + "artifact": {"sha256": self.artifact_sha256, "size": self.artifact_size}, + "entry": {"sha256": self.entry_sha256, "size": self.entry_size}, + "js_inventory_sha256": self.inventory_sha256, + "rebuild_check_outcome": self.rebuild_outcome, + "companions": companions, + "gaps": self.gaps, + "artifact_schema": "#/$defs/artifact", + "release_qualification": false, + }) + } +} + +impl CandidateArtifactEntry { + /// Rechecks this entry from its retained bytes and summarizes it. + /// + /// Rehashes every companion, validates the rendered index artifact + /// against the retained `#/$defs/artifact` sub-schema, reruns every + /// producer join, and requires each informational companion to state + /// exactly the entry's non-empty gaps. + /// + /// # Errors + /// + /// Rejects companion bytes that differ from their records, a rendered + /// artifact the sub-schema rejects, any failed join, a missing rebuild + /// outcome, and empty or inconsistent gaps. + pub(crate) fn recheck_for_measurement(&self) -> Result> { + self.check_companions(&mut BTreeSet::new())?; + if self.gaps.is_empty() { + return Err(distribution_error("candidate entry states no gaps")); + } + let inventory_sha256 = sha256_hex(&self.inventory); + let artifact_entry = self.index_entry(&inventory_sha256); + validate_release_index_artifact(&artifact_entry) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + let companions = self.recomputed_companions(&artifact_entry)?; + let modules = inventory_modules(&self.inventory, &self.source_commit)?; + check_build_evidence( + self.companion_bytes(CompanionRole::BuildEvidence)?, + &EvidenceJoin { + source_commit: &self.source_commit, + target: self.asset.artifact().target.triple(), + artifact_sha256: &self.artifact_sha256, + inventory_sha256: &inventory_sha256, + modules: &modules, + }, + )?; + check_spdx_root( + self.companion_bytes(CompanionRole::Sbom)?, + &self.artifact_sha256, + )?; + parse_informational( + self.companion_bytes(CompanionRole::BuildRecipe)?, + &self.source_commit, + )?; + let rebuild_check = self.companion_bytes(CompanionRole::RebuildCheck)?; + check_comparison(rebuild_check, &self.source_commit)?; + for role in [ + CompanionRole::BuildEvidence, + CompanionRole::BuildRecipe, + CompanionRole::RebuildCheck, + ] { + let document = parse_json(self.companion_bytes(role)?, DocumentRole::Companion) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + if document["release_qualification"] != false || document["gaps"] != json!(self.gaps) { + return Err(distribution_error(format!( + "{} companion must be unqualified and state exactly the entry gaps", + role.key() + ))); + } + } + let rebuild_outcome = parse_informational(rebuild_check, &self.source_commit)?["outcome"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| distribution_error("rebuild check has no outcome"))?; + let entry_bytes = canonical_json(&artifact_entry, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + Ok(EntrySummary { + name: self.name(), + artifact_sha256: self.artifact_sha256.clone(), + artifact_size: self.artifact_size, + inventory_sha256, + entry_sha256: sha256_hex(&entry_bytes), + entry_size: entry_bytes.len() as u64, + rebuild_outcome, + companions, + gaps: self.gaps.clone(), + }) + } + + /// The exact bytes of the companion with `role`. + fn companion_bytes(&self, role: CompanionRole) -> Result<&[u8], Report> { + self.companions + .iter() + .find(|file| file.role == role) + .map(|file| file.bytes.as_slice()) + .ok_or_else(|| distribution_error(format!("entry has no {} companion", role.key()))) + } + + /// Recomputes each companion identity from bytes and requires the + /// rendered artifact to record exactly it. + fn recomputed_companions( + &self, + artifact_entry: &Value, + ) -> Result, Report> { + self.companions + .iter() + .map(|file| { + let summary = CompanionSummary { + key: file.role.key(), + name: file.name.clone(), + size: file.bytes.len() as u64, + sha256: sha256_hex(&file.bytes), + }; + let recorded = &artifact_entry["companions"][summary.key]; + if recorded + != &json!({"name": summary.name, "size": summary.size, "sha256": summary.sha256}) + { + return Err(distribution_error(format!( + "rendered {} record differs from its recomputed bytes", + summary.key + ))); + } + Ok(summary) + }) + .collect() + } +} diff --git a/crates/trusted-server-release/src/distribution/tests.rs b/crates/trusted-server-release/src/distribution/tests.rs new file mode 100644 index 0000000000..9f927fae62 --- /dev/null +++ b/crates/trusted-server-release/src/distribution/tests.rs @@ -0,0 +1,729 @@ +//! Pure candidate index tests over fictional entry facts. +//! +//! Entries come from [`EntryParts`] with fictional bytes and identities; a +//! real completed build cannot be produced without process execution, so the +//! completed-build wiring rejections live in the recipe tests. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use super::entry::{ + CompatibilityTest, EntryParts, ModuleObservation, RebuildComparison, RecipeFacts, + policy_identity, render_entry, +}; +use super::*; +use crate::documents::{validate_release_index_artifact, validate_spdx}; +use crate::policy::{CLI_POLICY_VERSION, FixedArtifact}; + +mod golden; +mod summary; +mod transfer; + +/// Fictional approved source commit of every entry. +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +/// Another fictional commit. +const OTHER_COMMIT: &str = "89abcdef0123456789abcdef0123456789abcdef"; +/// The actual tracked consumer policy bytes, so the CLI accepts them as current. +const POLICY: &[u8] = include_bytes!("../../../trusted-server-cli/release-policy.json"); +/// The CLI's structural positive index fixture. +const CLI_INDEX_FIXTURE: &[u8] = + include_bytes!("../../../trusted-server-cli/tests/fixtures/release/release-index.json"); +const FASTLY: &str = "trusted-server-fastly.wasm"; +const LINUX: &str = "ts-x86_64-unknown-linux-gnu"; + +fn digest(text: &str) -> String { + sha256_hex(text.as_bytes()) +} + +fn module(id: &str, embedded: bool) -> ModuleObservation { + let body = format!("fictional {id} module body"); + ModuleObservation { + id: id.to_owned(), + filename: format!("tsjs-{id}.js"), + size: body.len() as u64, + sha256: digest(&body), + embedded, + } +} + +fn inventory() -> Vec { + let modules: Vec = [module("core", true), module("prebid", true)] + .iter() + .map(|module| { + json!({"id": module.id, "filename": module.filename, "size": module.size, + "sha256": module.sha256, "contributions": [{"package": "fictional-js"}]}) + }) + .collect(); + canonical_json( + &json!({"schema_version": 1, "source_commit": COMMIT, "modules": modules, "tools": []}), + DocumentRole::Metadata, + ) + .expect("should render the fictional inventory") +} + +fn artifact_bytes(asset: &str) -> Vec { + format!("fictional {asset} executable bytes").into_bytes() +} + +fn spdx(asset: &str, artifact_sha256: &str) -> Vec { + let document = json!({ + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "name": asset, + "dataLicense": "CC0-1.0", + "documentNamespace": format!("https://example.com/spdx/candidate/{asset}"), + "creationInfo": { + "creators": ["Tool: fictional-candidate-fixture"], + "created": "2026-01-01T00:00:00Z", + "comment": "Unqualified candidate fixture; gaps: fictional-coverage-gap", + }, + "documentDescribes": ["SPDXRef-Artifact"], + "packages": [{ + "SPDXID": "SPDXRef-Artifact", + "name": asset, + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "checksums": [{"algorithm": "SHA256", "checksumValue": artifact_sha256}], + }], + "relationships": [{ + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Artifact", + }], + }); + validate_spdx(&document).expect("should keep the fictional SPDX schema-valid"); + canonical_json(&document, DocumentRole::Spdx).expect("should render the fictional SPDX") +} + +fn parts(asset: &'static str) -> EntryParts { + let asset_id = AssetId::parse(asset).expect("should resolve the fixed asset"); + let bytes = artifact_bytes(asset); + let artifact_sha256 = sha256_hex(&bytes); + let fastly = asset == FASTLY; + let (comparison, test) = if fastly { + ( + RebuildComparison::Matched { + reproduction_sha256: artifact_sha256.clone(), + reproduction_size: bytes.len() as u64, + }, + CompatibilityTest { + name: "fastly-final-byte-publisher-request", + facts: BTreeMap::from([ + ("viceroy_version", "0.0.0-fictional".to_owned()), + ("health_status", "200".to_owned()), + ]), + }, + ) + } else { + ( + RebuildComparison::Unavailable, + CompatibilityTest { + name: "native-startup", + facts: BTreeMap::from([("startup_kind", "fictional-startup".to_owned())]), + }, + ) + }; + EntryParts { + asset: asset_id, + source_commit: COMMIT.to_owned(), + policy_version: CLI_POLICY_VERSION, + policy_sha256: sha256_hex(POLICY), + spdx: spdx(asset, &artifact_sha256), + artifact_sha256, + artifact_size: bytes.len() as u64, + inventory: inventory(), + modules: vec![module("prebid", fastly), module("core", true)], + spdx_gaps: vec!["fictional-coverage-gap".to_owned()], + recipe: RecipeFacts { + definition_sha256: digest("fictional recipe definition"), + invocation_sha256: digest("fictional invocation"), + cargo_lock_sha256: digest("fictional Cargo.lock"), + materials_digest: digest("fictional materials"), + rust_tools_digest: digest("fictional Rust tools"), + }, + comparison, + compatibility_tests: vec![test], + } +} + +fn entry(asset: &'static str) -> CandidateArtifactEntry { + render_entry(parts(asset)).expect("should render the fictional entry") +} + +fn entries() -> Vec { + FIXED_ARTIFACTS + .iter() + .map(|fixed| entry(fixed.name)) + .collect() +} + +fn assembled() -> CandidateIndex { + assemble_candidate_index(&entries()).expect("should assemble the candidate index") +} + +fn rejection(result: Result>) -> String { + format!("{:?}", result.expect_err("should reject the candidate")) +} + +fn companion(entry: &CandidateArtifactEntry, role: CompanionRole) -> Value { + let file = entry + .companions + .iter() + .find(|file| file.role == role) + .expect("should carry the companion"); + serde_json::from_slice(&file.bytes).expect("should parse the companion") +} + +fn rendered_artifacts(index: &CandidateIndex) -> (Value, Vec) { + let value: Value = serde_json::from_slice(index.bytes()).expect("should parse the index"); + let artifacts = value["artifacts"] + .as_array() + .expect("should list artifacts") + .clone(); + (value, artifacts) +} + +// ---- Step 1: retained schema validators ---- + +#[test] +fn release_index_validator_accepts_the_existing_positive_fixture() { + let value = parse_json(CLI_INDEX_FIXTURE, DocumentRole::Metadata) + .expect("should parse the CLI index fixture"); + validate_release_index(&value).expect("should accept the structural positive fixture"); + validate_release_index_artifact(&value["artifacts"][0]) + .expect("should accept a valid fixture artifact entry"); +} + +#[test] +fn artifact_validator_rejects_a_fastly_entry_with_the_cli_capability() { + let value = parse_json(CLI_INDEX_FIXTURE, DocumentRole::Metadata) + .expect("should parse the CLI index fixture"); + let mut fastly = value["artifacts"] + .as_array() + .expect("should list artifacts") + .iter() + .find(|artifact| artifact["name"] == FASTLY) + .expect("should contain the Fastly entry") + .clone(); + fastly["capabilities"] = json!(["cli-execute"]); + assert!( + validate_release_index_artifact(&fastly).is_err(), + "should reject cli-execute on the Fastly asset" + ); + fastly["capabilities"] = json!(["fastly-production", "unknown-capability"]); + assert!( + validate_release_index_artifact(&fastly).is_err(), + "should reject an unknown capability" + ); +} + +// ---- Step 2: fixed identity accessor ---- + +fn schema_row(name: &str) -> Value { + let schema = parse_json( + include_bytes!("../../../../docs/superpowers/specs/schemas/release-index-v1.schema.json"), + DocumentRole::Metadata, + ) + .expect("should parse the retained schema"); + schema["$defs"]["artifact"]["oneOf"] + .as_array() + .expect("should list identity rows") + .iter() + .find(|row| row["properties"]["name"]["const"] == name) + .expect("should have a row per asset")["properties"] + .clone() +} + +fn minimum_version(runtime: &str) -> Value { + let schema = parse_json( + include_bytes!("../../../../docs/superpowers/specs/schemas/release-index-v1.schema.json"), + DocumentRole::Metadata, + ) + .expect("should parse the retained schema"); + schema["$defs"]["compatibility"]["oneOf"] + .as_array() + .expect("should list compatibility rows") + .iter() + .find(|row| row["properties"]["runtime"]["const"] == runtime) + .expect("should have a compatibility row")["properties"]["minimum_version"]["const"] + .clone() +} + +fn check_identity_row(fixed: &FixedArtifact) { + let identity = fixed.index_identity(); + let row = schema_row(fixed.name); + for key in [ + "name", + "role", + "kind", + "package", + "target", + "variant", + "assurance_profile", + ] { + assert_eq!( + identity[key], row[key]["const"], + "should render the schema {key} of {}", + fixed.name + ); + } + let capabilities = &row["capabilities"]; + let expected = if capabilities["const"].is_array() { + capabilities["const"].clone() + } else { + json!([capabilities["contains"]["const"]]) + }; + assert_eq!( + identity["capabilities"], expected, + "should render only the required capability of {}", + fixed.name + ); + let compatibility = &identity["compatibility"]; + for key in ["runtime", "architecture"] { + assert_eq!( + compatibility[key], row["compatibility"]["properties"][key]["const"], + "should render the schema compatibility {key} of {}", + fixed.name + ); + } + assert_eq!( + compatibility["minimum_version"], + minimum_version(compatibility["runtime"].as_str().unwrap_or_default()), + "should render the schema minimum version of {}", + fixed.name + ); + assert_eq!( + identity.as_object().map(serde_json::Map::len), + Some(9), + "should render exactly the nine identity fields" + ); +} + +#[test] +fn fixed_identity_accessor_equals_the_schema_row_for_every_asset() { + FIXED_ARTIFACTS.iter().for_each(check_identity_row); +} + +// ---- Step 3: valid four-entry join ---- + +#[test] +fn four_valid_entries_assemble_a_schema_valid_candidate_index() { + let index = assembled(); + let (value, artifacts) = rendered_artifacts(&index); + validate_release_index(&value).expect("should satisfy the retained schema"); + assert_eq!( + index.name(), + CANDIDATE_INDEX_NAME, + "should use the fixed name" + ); + assert_ne!( + index.name(), + "release-index.json", + "should never be named as a release" + ); + assert_eq!(value["tag"], CANDIDATE_TAG, "should carry the sentinel tag"); + assert_eq!(value["source_commit"], COMMIT, "should name the source"); + assert_eq!( + value["recipe_source_commit"], COMMIT, + "recipe commit should equal source" + ); + assert_eq!( + value["cohort_source_commit"], COMMIT, + "cohort commit should equal source" + ); + assert_eq!( + value["policy_sha256"], + sha256_hex(POLICY), + "should name the policy" + ); + assert_eq!( + index.sha256(), + sha256_hex(index.bytes()), + "should hash exact bytes" + ); + let names: Vec<&str> = artifacts + .iter() + .filter_map(|a| a["name"].as_str()) + .collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted, "should emit artifacts sorted by name"); + for artifact in &artifacts { + let name = artifact["name"].as_str().unwrap_or_default(); + for (key, suffix) in BUNDLE_SUFFIXES { + assert_eq!( + artifact["companions"][key], + json!({"name": format!("{name}{suffix}"), "size": 0, "sha256": sha256_hex(b"")}), + "should render the true empty-file record for {name} {key}" + ); + } + } + assert_eq!( + index.companions().len(), + 16, + "should carry sixteen companions" + ); + assert!( + index + .gaps() + .iter() + .any(|gap| gap == "candidate-tag-sentinel"), + "should state the sentinel gap" + ); +} + +// ---- Step 4: join rejections ---- + +#[test] +fn join_rejects_missing_extra_and_duplicate_assets() { + let mut three = entries(); + three.pop(); + assert!( + rejection(assemble_candidate_index(&three)).contains("missing asset"), + "should reject three entries" + ); + let mut five = entries(); + five.push(entry(LINUX)); + assert!( + rejection(assemble_candidate_index(&five)).contains("more than once"), + "should reject five entries" + ); + let mut duplicate = entries(); + duplicate[0] = entry(LINUX); + assert!( + assemble_candidate_index(&duplicate).is_err(), + "should reject four entries with one asset twice" + ); +} + +#[test] +fn join_rejects_mixed_source_policy_or_inventory() { + let mut source = entries(); + source[2].source_commit = OTHER_COMMIT.to_owned(); + assert!( + rejection(assemble_candidate_index(&source)).contains("different source commits"), + "should reject mixed source commits" + ); + let mut policy = entries(); + policy[1].policy_sha256 = "f".repeat(64); + assert!( + rejection(assemble_candidate_index(&policy)).contains("different producer policies"), + "should reject mixed policy identities" + ); + let mut inventory = entries(); + inventory[3].inventory.push(b' '); + assert!( + rejection(assemble_candidate_index(&inventory)).contains("different JS inventories"), + "should reject mixed inventory bytes" + ); +} + +#[test] +fn rendered_check_rejects_fastly_staging_and_non_sentinel_bundles() { + let index = assembled(); + let (value, artifacts) = rendered_artifacts(&index); + let mut fastly = artifacts + .iter() + .find(|artifact| artifact["name"] == FASTLY) + .expect("should render Fastly") + .clone(); + fastly["capabilities"] = json!(["fastly-production", "fastly-staging"]); + validate_release_index_artifact(&fastly) + .expect("should be structurally compatible with the schema"); + assert!( + rejection(check_rendered_artifact(&fastly, &value["tsjs_inventory"])) + .contains("fixed identity"), + "should reject the currently forbidden fastly-staging capability" + ); + let mut bundle = artifacts[1].clone(); + bundle["companions"]["provenance"]["size"] = json!(1); + assert!( + rejection(check_rendered_artifact(&bundle, &value["tsjs_inventory"])) + .contains("awaiting-signature sentinel"), + "should reject a non-sentinel bundle record" + ); +} + +#[test] +fn join_rejects_unsafe_colliding_or_misnamed_companions() { + for name in [ + "ts-x86_64-unknown-linux-gnu/../evil.spdx.json", + "ts-x86_64-unknown-linux-gnu\n.spdx.json", + "TS-X86_64-UNKNOWN-LINUX-GNU.spdx.json", + ] { + let mut altered = entries(); + let linux = altered + .iter_mut() + .find(|entry| entry.name() == LINUX) + .expect("should hold the Linux entry"); + linux.companions[0].name = name.to_owned(); + assert!( + assemble_candidate_index(&altered).is_err(), + "should reject companion name {name:?}" + ); + } + let mut names = BTreeSet::new(); + insert_name(&mut names, "tsjs-inventory.json").expect("should accept the inventory name"); + assert!( + insert_name(&mut names, "TSJS-Inventory.json").is_err(), + "should reject a case-colliding name" + ); + assert!( + insert_name(&mut names, "line\nbreak.json").is_err(), + "should reject a newline in a name" + ); +} + +#[test] +fn join_rejects_companion_size_or_digest_mismatch() { + let mut size = entries(); + size[0].companions[1].size += 1; + assert!( + rejection(assemble_candidate_index(&size)).contains("recorded size or digest"), + "should reject an altered companion length" + ); + let mut sha = entries(); + sha[0].companions[2].sha256 = "0".repeat(64); + assert!( + rejection(assemble_candidate_index(&sha)).contains("recorded size or digest"), + "should reject an altered companion digest" + ); + let mut bytes = entries(); + bytes[0].companions[3].bytes.push(b' '); + assert!( + assemble_candidate_index(&bytes).is_err(), + "should reject changed companion bytes" + ); +} + +// ---- Step 5: signature bundle slots ---- + +/// Compile-time proof that no signed bundle can be constructed. +fn signed_bundles_are_uninhabited(signed: &SignedCompanions) -> ! { + match *signed {} +} + +#[test] +fn awaiting_signature_renders_the_true_empty_file_record() { + let _proof: fn(&SignedCompanions) -> ! = signed_bundles_are_uninhabited; + assert_eq!( + BundleSlot::AwaitingSignature.record("example.sbom.sigstore.json"), + json!({"name": "example.sbom.sigstore.json", "size": 0, "sha256": EMPTY_SHA256}), + "should render the empty-file record" + ); + assert_eq!( + EMPTY_SHA256, + sha256_hex(b""), + "should be the SHA-256 of zero bytes" + ); +} + +// ---- Step 6: companion rendering rules ---- + +#[test] +fn build_evidence_lists_sorted_prepared_modules_and_the_embedded_subset() { + let linux = entry(LINUX); + let evidence = companion(&linux, CompanionRole::BuildEvidence); + let ids = |key: &str| -> Vec { + evidence[key] + .as_array() + .expect("should list modules") + .iter() + .map(|module| module["id"].clone()) + .collect() + }; + assert_eq!( + ids("prepared_modules"), + [json!("core"), json!("prebid")], + "should sort" + ); + assert_eq!( + ids("embedded_modules"), + [json!("core")], + "should list the embedded subset" + ); + let mut extra = parts(LINUX); + extra.modules.push(module("unlisted", true)); + assert!( + rejection(render_entry(extra)).contains("differ from the shared inventory"), + "should reject prepared modules outside the inventory" + ); +} + +#[test] +fn fastly_entry_rejects_a_js_build_material_module() { + let mut fastly = parts(FASTLY); + fastly.modules[0].embedded = false; + assert!( + rejection(render_entry(fastly)).contains("embed every prepared JS module"), + "should reject a Fastly module classified as build material" + ); +} + +#[test] +fn rebuild_check_is_matched_only_with_fastly_equality() { + let fastly = companion(&entry(FASTLY), CompanionRole::RebuildCheck); + assert_eq!( + fastly["outcome"], "matched", + "should record Fastly equality" + ); + let linux = companion(&entry(LINUX), CompanionRole::RebuildCheck); + assert_eq!( + linux["outcome"], "unavailable", + "should record native unavailable" + ); + assert_eq!( + linux["reproduction_sha256"], + Value::Null, + "should not invent a digest" + ); + let mut unavailable = parts(FASTLY); + unavailable.comparison = RebuildComparison::Unavailable; + assert!( + render_entry(unavailable).is_err(), + "Fastly should require equality" + ); + let mut matched = parts(LINUX); + matched.comparison = RebuildComparison::Matched { + reproduction_sha256: matched.artifact_sha256.clone(), + reproduction_size: matched.artifact_size, + }; + assert!( + render_entry(matched).is_err(), + "native should never claim matched" + ); + let mut different = parts(FASTLY); + different.comparison = RebuildComparison::Matched { + reproduction_sha256: "0".repeat(64), + reproduction_size: different.artifact_size, + }; + assert!( + render_entry(different).is_err(), + "should reject other equality bytes" + ); +} + +#[test] +fn every_informational_companion_is_unqualified_with_gaps_and_null_transfers() { + for entry in entries() { + assert!(!entry.gaps().is_empty(), "should state entry gaps"); + for role in [ + CompanionRole::BuildEvidence, + CompanionRole::BuildRecipe, + CompanionRole::RebuildCheck, + ] { + let document = companion(&entry, role); + assert_eq!( + document["release_qualification"], false, + "should never claim qualification" + ); + let gaps = document["gaps"].as_array().expect("should list gaps"); + let mut sorted = gaps.clone(); + sorted.sort_by(|left, right| left.as_str().cmp(&right.as_str())); + sorted.dedup(); + assert!( + !gaps.is_empty() && *gaps == sorted, + "gaps should be sorted unique" + ); + assert_eq!( + document["workflow_run_id"], + Value::Null, + "run ID is unavailable" + ); + } + } +} + +#[test] +fn entry_rejects_a_wrong_spdx_root_or_missing_gaps() { + let mut root = parts(FASTLY); + root.spdx = spdx(FASTLY, &"0".repeat(64)); + assert!( + rejection(render_entry(root)).contains("checksum differs"), + "should reject an SPDX root bound to other bytes" + ); + let mut gaps = parts(FASTLY); + gaps.spdx_gaps.clear(); + assert!( + render_entry(gaps).is_err(), + "should require candidate SPDX gaps" + ); + let mut tests = parts(LINUX); + tests.compatibility_tests.clear(); + assert!( + render_entry(tests).is_err(), + "should require a compatibility test" + ); +} + +// ---- Step 7: determinism ---- + +#[test] +fn assembly_is_deterministic_across_entry_order() { + let first = assembled(); + let second = assembled(); + assert_eq!( + first.bytes(), + second.bytes(), + "should render identical bytes" + ); + let mut permuted = entries(); + permuted.reverse(); + permuted.swap(0, 2); + let reordered = assemble_candidate_index(&permuted).expect("should accept any entry order"); + assert_eq!( + first.bytes(), + reordered.bytes(), + "should ignore entry order" + ); + assert_eq!( + first.companions(), + reordered.companions(), + "should match companions" + ); + assert_eq!(first.gaps(), reordered.gaps(), "should match gaps"); +} + +#[test] +fn inventory_join_uses_the_consumer_inventory_byte_limit() { + assert_eq!( + super::joins::INVENTORY_ROLE.limit(), + super::joins::CLI_INVENTORY_BYTES, + "should parse the inventory under the CLI inventory bound" + ); +} + +// ---- Producer policy identity from approved bytes ---- + +#[test] +fn policy_identity_derives_version_and_digest_from_the_same_bytes() { + assert_eq!( + policy_identity(POLICY).expect("should accept the tracked consumer policy"), + (CLI_POLICY_VERSION, sha256_hex(POLICY)), + "should read the version and hash the exact approved bytes" + ); +} + +#[test] +fn policy_identity_rejects_mismatched_missing_zero_or_non_integer_versions() { + for (case, bytes) in [ + ("mismatched version", br#"{"policy_version":2}"#.as_slice()), + ("missing version", br#"{"repository":"example"}"#.as_slice()), + ("version zero", br#"{"policy_version":0}"#.as_slice()), + ( + "fractional version", + br#"{"policy_version":1.0}"#.as_slice(), + ), + ("string version", br#"{"policy_version":"1"}"#.as_slice()), + ("malformed JSON", b"not JSON".as_slice()), + ] { + assert!( + policy_identity(bytes).is_err(), + "should reject the {case} policy" + ); + } +} diff --git a/crates/trusted-server-release/src/distribution/tests/golden.rs b/crates/trusted-server-release/src/distribution/tests/golden.rs new file mode 100644 index 0000000000..6c76654574 --- /dev/null +++ b/crates/trusted-server-release/src/distribution/tests/golden.rs @@ -0,0 +1,169 @@ +//! Golden byte equality between the producer's candidate rendering and the +//! committed fixtures the CLI consumer verifies. +//! +//! The committed files under `tests/fixtures/index/candidate/` are fictional +//! except for `policy_sha256`, which is the digest of the actual tracked +//! `crates/trusted-server-cli/release-policy.json`, so the consumer accepts +//! the producer policy as current. A policy change therefore requires +//! regenerating these fixtures in the same change. + +use super::*; + +/// Every committed candidate fixture file and its exact bytes. +const FIXTURES: [(&str, &[u8]); 18] = [ + ( + "candidate-release-index.json", + include_bytes!("../../../tests/fixtures/index/candidate/candidate-release-index.json"), + ), + ( + "tsjs-inventory.json", + include_bytes!("../../../tests/fixtures/index/candidate/tsjs-inventory.json"), + ), + ( + "trusted-server-fastly.wasm.spdx.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json" + ), + ), + ( + "trusted-server-fastly.wasm.build-evidence.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json" + ), + ), + ( + "trusted-server-fastly.wasm.build-recipe.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json" + ), + ), + ( + "trusted-server-fastly.wasm.rebuild-check.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json" + ), + ), + ( + "ts-aarch64-apple-darwin.spdx.json", + include_bytes!("../../../tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json"), + ), + ( + "ts-aarch64-apple-darwin.build-evidence.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json" + ), + ), + ( + "ts-aarch64-apple-darwin.build-recipe.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json" + ), + ), + ( + "ts-aarch64-apple-darwin.rebuild-check.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json" + ), + ), + ( + "ts-x86_64-apple-darwin.spdx.json", + include_bytes!("../../../tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json"), + ), + ( + "ts-x86_64-apple-darwin.build-evidence.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json" + ), + ), + ( + "ts-x86_64-apple-darwin.build-recipe.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json" + ), + ), + ( + "ts-x86_64-apple-darwin.rebuild-check.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json" + ), + ), + ( + "ts-x86_64-unknown-linux-gnu.spdx.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json" + ), + ), + ( + "ts-x86_64-unknown-linux-gnu.build-evidence.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json" + ), + ), + ( + "ts-x86_64-unknown-linux-gnu.build-recipe.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json" + ), + ), + ( + "ts-x86_64-unknown-linux-gnu.rebuild-check.json", + include_bytes!( + "../../../tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json" + ), + ), +]; + +fn fixture(name: &str) -> &'static [u8] { + FIXTURES + .iter() + .find(|(fixture, _)| *fixture == name) + .map(|(_, bytes)| *bytes) + .expect("should commit the named fixture") +} + +#[test] +fn producer_rendering_equals_the_committed_candidate_fixtures() { + let index = assembled(); + assert_eq!( + index.bytes(), + fixture(CANDIDATE_INDEX_NAME), + "should render the committed candidate index bytes" + ); + assert_eq!( + index.inventory(), + fixture(INVENTORY_NAME), + "should name the committed inventory bytes" + ); + assert_eq!( + index.companions().len() + 2, + FIXTURES.len(), + "should commit exactly the index, inventory and present companions" + ); + for (name, bytes) in index.companions() { + assert_eq!( + bytes.as_slice(), + fixture(name), + "should render the committed companion {name}" + ); + } +} + +#[test] +fn committed_fixture_names_the_actual_tracked_consumer_policy() { + let index: Value = serde_json::from_slice(fixture(CANDIDATE_INDEX_NAME)) + .expect("should parse the committed index"); + let policy = parse_json(POLICY, DocumentRole::Metadata).expect("should parse the policy"); + assert_eq!( + index["policy_sha256"], + sha256_hex(POLICY), + "should bind the exact tracked policy bytes" + ); + assert_eq!( + index["policy_version"], policy["policy_version"], + "should name the tracked policy version" + ); + assert_eq!( + policy["policy_version"], CLI_POLICY_VERSION, + "should match the compiled policy version" + ); +} diff --git a/crates/trusted-server-release/src/distribution/tests/summary.rs b/crates/trusted-server-release/src/distribution/tests/summary.rs new file mode 100644 index 0000000000..86c13a823c --- /dev/null +++ b/crates/trusted-server-release/src/distribution/tests/summary.rs @@ -0,0 +1,160 @@ +//! Pure tests of the test-only entry re-check and its measurement shape. + +use super::*; + +/// A fictional entry whose companion with `role` carries `bytes`, re-recorded +/// so only the joins, not the digest records, can reject it. +fn with_companion( + asset: &'static str, + role: CompanionRole, + bytes: Vec, +) -> CandidateArtifactEntry { + let mut tampered = entry(asset); + let file = tampered + .companions + .iter_mut() + .find(|file| file.role == role) + .expect("should carry the companion"); + *file = CompanionFile::new(role, asset, bytes); + tampered +} + +#[test] +fn fastly_entry_summary_records_matched_outcome_and_recomputed_companions() { + let fastly = entry(FASTLY); + + let summary = fastly + .recheck_for_measurement() + .expect("should recheck the fictional Fastly entry"); + let measurement = summary.measurement(); + + assert_eq!( + summary.rebuild_outcome(), + "matched", + "should record the checked equality" + ); + assert_eq!( + summary.inventory_sha256(), + sha256_hex(&inventory()), + "should name the exact inventory bytes" + ); + let keys: Vec<&String> = measurement + .as_object() + .expect("should render an object") + .keys() + .collect(); + assert_eq!( + keys, + [ + "artifact", + "artifact_schema", + "asset", + "companions", + "entry", + "gaps", + "js_inventory_sha256", + "kind", + "rebuild_check_outcome", + "release_qualification", + ], + "should render exactly the measurement fields" + ); + assert_eq!(measurement["asset"], FASTLY, "should name the asset"); + assert_eq!( + measurement["release_qualification"], + json!(false), + "should grant no qualification" + ); + assert_eq!( + measurement["gaps"], + json!(fastly.gaps()), + "should record the entry gaps" + ); + for file in &fastly.companions { + assert_eq!( + measurement["companions"][file.role.key()], + json!({"name": file.name, "size": file.bytes.len(), "sha256": sha256_hex(&file.bytes)}), + "should record the {} identity recomputed from bytes", + file.role.key() + ); + } + let rendered = canonical_json( + &fastly.index_entry(&sha256_hex(&inventory())), + DocumentRole::Metadata, + ) + .expect("should render the artifact entry"); + assert_eq!( + measurement["entry"], + json!({"sha256": sha256_hex(&rendered), "size": rendered.len()}), + "should record the rendered artifact entry identity" + ); +} + +#[test] +fn native_entry_summary_records_unavailable_outcome_and_reproduction_gap() { + let summary = entry(LINUX) + .recheck_for_measurement() + .expect("should recheck the fictional Linux entry"); + + assert_eq!( + summary.rebuild_outcome(), + "unavailable", + "should record no native comparison" + ); + assert!( + summary.measurement()["gaps"] + .as_array() + .expect("should list gaps") + .contains(&json!("native-reproduction-unavailable")), + "should state the native reproduction gap" + ); +} + +#[test] +fn recheck_rejects_companion_bytes_differing_from_their_record() { + let mut tampered = entry(LINUX); + tampered.companions[1].bytes.push(b' '); + + let error = rejection(tampered.recheck_for_measurement()); + + assert!( + error.contains("differ from its recorded size or digest"), + "should reject the stale record: {error}" + ); +} + +#[test] +fn recheck_reruns_joins_on_re_recorded_companions() { + let evidence = companion(&entry(FASTLY), CompanionRole::BuildEvidence); + let mut wrong_inventory = evidence.clone(); + wrong_inventory["js_inventory_sha256"] = json!(digest("another inventory")); + let bytes = canonical_json(&wrong_inventory, DocumentRole::Companion) + .expect("should render the tampered evidence"); + + let error = rejection( + with_companion(FASTLY, CompanionRole::BuildEvidence, bytes).recheck_for_measurement(), + ); + + assert!( + error.contains("`js_inventory_sha256` differs from the index entry"), + "should rerun the build evidence join: {error}" + ); +} + +#[test] +fn recheck_rejects_a_companion_stating_other_gaps() { + let mut recipe = companion(&entry(LINUX), CompanionRole::BuildRecipe); + recipe["gaps"] = json!(["fictional-other-gap"]); + let bytes = canonical_json(&recipe, DocumentRole::Companion).expect("should render the recipe"); + + let error = rejection( + with_companion(LINUX, CompanionRole::BuildRecipe, bytes).recheck_for_measurement(), + ); + + assert!( + error.contains( + "build_recipe companion must be unqualified and state exactly the entry gaps" + ), + "should reject inconsistent gaps: {error}" + ); +} diff --git a/crates/trusted-server-release/src/distribution/tests/transfer.rs b/crates/trusted-server-release/src/distribution/tests/transfer.rs new file mode 100644 index 0000000000..9328b4106b --- /dev/null +++ b/crates/trusted-server-release/src/distribution/tests/transfer.rs @@ -0,0 +1,811 @@ +//! Pure tests of the test-only ordinary-CI entry transfer and its collector. +//! +//! Fictional entries are exported to flat files, rebuilt from those bytes and +//! assembled, which must reproduce the committed golden candidate index. + +use std::path::Path; + +use crate::distribution::transfer::{ + ARTIFACT_MEMBER, EntryDescriptor, ReceivedEntry, assemble_received, check_distinct_uploads, + member_names, upload_name, write_candidate_index, +}; + +use super::*; + +/// Fictional producing workflow run of every descriptor. +const RUN_ID: u64 = 4242; +/// The committed golden candidate index bytes. +const GOLDEN_INDEX: &[u8] = + include_bytes!("../../../tests/fixtures/index/candidate/candidate-release-index.json"); +const MACOS_ARM: &str = "ts-aarch64-apple-darwin"; + +fn asset_id(asset: &str) -> AssetId { + AssetId::parse(asset).expect("should resolve the fixed asset") +} + +/// The six flat members of the fictional entry for `asset`. +fn members(asset: &'static str) -> BTreeMap> { + let artifact = artifact_bytes(asset); + entry(asset) + .transfer_members(&artifact) + .into_iter() + .map(|(name, bytes)| (name, bytes.to_vec())) + .collect() +} + +fn sizes(members: &BTreeMap>) -> BTreeMap { + members + .iter() + .map(|(name, bytes)| (name.clone(), bytes.len() as u64)) + .collect() +} + +/// The descriptor JSON the CI describe step would emit for `asset`. +fn descriptor_json(asset: &'static str, artifact_id: u64) -> Value { + let entry_sha256 = entry(asset) + .recheck_for_measurement() + .expect("should recheck the fictional entry") + .entry_sha256() + .to_owned(); + json!({ + "asset": asset, + "entry_sha256": entry_sha256, + "members": sizes(&members(asset)), + "artifact_id": artifact_id, + "artifact_name": format!("candidate-entry-{asset}-{RUN_ID}-1"), + "archive_sha256": format!("sha256:{}", digest(&format!("fictional archive {asset}"))), + "run_attempt": 1, + }) +} + +fn render_metadata(value: &Value) -> Vec { + canonical_json(value, DocumentRole::Metadata).expect("should render the fictional JSON") +} + +fn descriptor(asset: &'static str, artifact_id: u64) -> EntryDescriptor { + EntryDescriptor::parse( + &render_metadata(&descriptor_json(asset, artifact_id)), + asset_id(asset), + RUN_ID, + ) + .expect("should accept the fictional descriptor") +} + +fn received() -> Vec { + FIXED_ARTIFACTS + .iter() + .zip(1_u64..) + .map(|(fixed, artifact_id)| ReceivedEntry { + descriptor: descriptor(fixed.name, artifact_id), + members: members(fixed.name), + }) + .collect() +} + +fn rebuild( + asset: &'static str, + members: &BTreeMap>, +) -> Result> { + CandidateArtifactEntry::from_ordinary_ci_transfer(asset_id(asset), COMMIT, POLICY, members) +} + +/// Rewrites the companion with `suffix` of `asset` through `edit`. +fn edit_companion( + members: &mut BTreeMap>, + asset: &str, + suffix: &str, + edit: impl FnOnce(&mut Value), +) { + let bytes = members + .get_mut(&format!("{asset}{suffix}")) + .expect("should carry the companion"); + let mut value: Value = serde_json::from_slice(bytes).expect("should parse the companion"); + edit(&mut value); + let role = if suffix == ".spdx.json" { + DocumentRole::Spdx + } else { + DocumentRole::Companion + }; + *bytes = canonical_json(&value, role).expect("should render the edited companion"); +} + +/// Applies `edit` to all three informational companions of `asset`. +fn edit_informational( + members: &mut BTreeMap>, + asset: &str, + edit: impl Fn(&mut Value), +) { + for suffix in [ + ".build-evidence.json", + ".build-recipe.json", + ".rebuild-check.json", + ] { + edit_companion(members, asset, suffix, &edit); + } +} + +fn rebuild_rejection(asset: &'static str, members: &BTreeMap>) -> String { + rejection(rebuild(asset, members)) +} + +fn read_directory(directory: &Path) -> BTreeMap> { + std::fs::read_dir(directory) + .expect("should list the directory") + .map(|item| { + let item = item.expect("should read the directory entry"); + let name = item + .file_name() + .into_string() + .expect("should use UTF-8 names"); + let bytes = std::fs::read(item.path()).expect("should read the member"); + (name, bytes) + }) + .collect() +} + +// ---- Round trip ---- + +#[test] +fn exported_entries_rebuild_into_exactly_the_golden_candidate_index() { + let scratch = tempfile::tempdir().expect("should create a scratch directory"); + let received: Vec = entries() + .iter() + .zip(1_u64..) + .map(|(entry, artifact_id)| { + let asset = entry.name(); + let upload = scratch.path().join(format!("{asset}-upload")); + let transfer = scratch.path().join(format!("{asset}-transfer.json")); + entry + .export_flat_for_ordinary_ci(&artifact_bytes(asset), &upload, &transfer) + .expect("should export the fictional entry"); + let mut description: Value = serde_json::from_slice( + &std::fs::read(&transfer).expect("should read the transfer file"), + ) + .expect("should parse the transfer file"); + description["artifact_id"] = json!(artifact_id); + description["artifact_name"] = json!(format!("candidate-entry-{asset}-{RUN_ID}-1")); + description["archive_sha256"] = json!(digest(asset)); + description["run_attempt"] = json!(1); + ReceivedEntry { + descriptor: EntryDescriptor::parse( + &render_metadata(&description), + asset_id(asset), + RUN_ID, + ) + .expect("should accept the exported descriptor"), + members: read_directory(&upload), + } + }) + .collect(); + + let index = + assemble_received(&received, COMMIT, POLICY).expect("should assemble rebuilt entries"); + + assert_eq!( + index.bytes(), + GOLDEN_INDEX, + "should rebuild exactly the committed candidate index" + ); + assert_eq!( + index.bytes(), + assembled().bytes(), + "should equal the directly assembled index" + ); +} + +#[test] +fn rebuilt_entry_rechecks_to_the_original_entry_digest() { + for fixed in FIXED_ARTIFACTS { + let original = entry(fixed.name) + .recheck_for_measurement() + .expect("should recheck the original entry"); + let rebuilt = rebuild(fixed.name, &members(fixed.name)) + .expect("should rebuild the fictional entry") + .recheck_for_measurement() + .expect("should recheck the rebuilt entry"); + + assert_eq!( + rebuilt.entry_sha256(), + original.entry_sha256(), + "should rebuild the identical entry for {}", + fixed.name + ); + } +} + +// ---- Rebuild rejections ---- + +#[test] +fn rebuild_rejects_missing_extra_or_misnamed_members() { + let mut missing = members(FASTLY); + missing.remove(ARTIFACT_MEMBER); + assert!( + rebuild_rejection(FASTLY, &missing).contains("exactly its six flat members"), + "should reject a missing executable" + ); + + let mut extra = members(FASTLY); + extra.insert("candidate-build-record.json".to_owned(), b"{}".to_vec()); + assert!( + rebuild_rejection(FASTLY, &extra).contains("exactly its six flat members"), + "should reject an extra member" + ); + + let mut misnamed = members(FASTLY); + let spdx = misnamed + .remove(&format!("{FASTLY}.spdx.json")) + .expect("should carry the SPDX companion"); + misnamed.insert(format!("{LINUX}.spdx.json"), spdx); + assert!( + rebuild_rejection(FASTLY, &misnamed).contains("exactly its six flat members"), + "should reject a companion named for another asset" + ); +} + +#[test] +fn rebuild_rejects_evidence_for_another_asset_target_or_commit() { + let mut asset = members(FASTLY); + edit_companion(&mut asset, FASTLY, ".build-evidence.json", |evidence| { + evidence["asset"] = json!(LINUX); + }); + assert!( + rebuild_rejection(FASTLY, &asset).contains("unqualified candidate evidence"), + "should reject evidence naming another asset" + ); + + let mut target = members(LINUX); + edit_companion(&mut target, LINUX, ".build-evidence.json", |evidence| { + evidence["target"] = json!("x86_64-apple-darwin"); + }); + assert!( + rebuild_rejection(LINUX, &target).contains("unqualified candidate evidence"), + "should reject evidence naming another target" + ); + + let commit = CandidateArtifactEntry::from_ordinary_ci_transfer( + asset_id(LINUX), + OTHER_COMMIT, + POLICY, + &members(LINUX), + ); + assert!( + rejection(commit).contains("expected source"), + "should reject evidence from another source commit" + ); +} + +#[test] +fn rebuild_rejects_qualified_or_workflow_bound_companions() { + let mut evidence = members(LINUX); + edit_companion(&mut evidence, LINUX, ".build-evidence.json", |document| { + document["release_qualification"] = json!(true); + }); + assert!( + rebuild_rejection(LINUX, &evidence).contains("unqualified candidate evidence"), + "should reject qualified build evidence" + ); + + let mut recipe = members(LINUX); + edit_companion(&mut recipe, LINUX, ".build-recipe.json", |document| { + document["release_qualification"] = json!(true); + }); + assert!( + rebuild_rejection(LINUX, &recipe).contains("must be unqualified"), + "should reject a qualified build recipe" + ); + + let mut bound = members(LINUX); + edit_companion(&mut bound, LINUX, ".build-evidence.json", |document| { + document["transfer_artifact_id"] = json!(7); + }); + assert!( + rebuild_rejection(LINUX, &bound).contains("unqualified candidate evidence"), + "should reject a claimed transfer binding" + ); +} + +#[test] +fn rebuild_rejects_differing_or_incomplete_gaps() { + let mut differing = members(LINUX); + edit_companion(&mut differing, LINUX, ".build-recipe.json", |document| { + document["gaps"] = json!(["fictional-other-gap"]); + }); + assert!( + rebuild_rejection(LINUX, &differing).contains("exactly the entry gaps"), + "should reject gaps differing between companions" + ); + + let mut incomplete = members(LINUX); + edit_informational(&mut incomplete, LINUX, |document| { + let gaps: Vec = document["gaps"] + .as_array() + .expect("should list gaps") + .iter() + .filter(|gap| *gap != "trusted-workflow-transfer-not-bound") + .cloned() + .collect(); + document["gaps"] = json!(gaps); + }); + assert!( + rebuild_rejection(LINUX, &incomplete).contains("omit the fixed gap"), + "should reject gaps omitting a fixed entry gap" + ); + + let mut unsorted = members(LINUX); + edit_informational(&mut unsorted, LINUX, |document| { + let mut gaps = document["gaps"] + .as_array() + .expect("should list gaps") + .clone(); + gaps.reverse(); + document["gaps"] = json!(gaps); + }); + assert!( + rebuild_rejection(LINUX, &unsorted).contains("sorted and unique"), + "should reject unsorted gaps" + ); + + let mut fastly_native_gap = members(FASTLY); + edit_informational(&mut fastly_native_gap, FASTLY, |document| { + let mut gaps: Vec = + serde_json::from_value(document["gaps"].clone()).expect("should list gap strings"); + gaps.push("native-reproduction-unavailable".to_owned()); + gaps.sort(); + document["gaps"] = json!(gaps); + }); + assert!( + rebuild_rejection(FASTLY, &fastly_native_gap).contains("exactly for native assets"), + "should reject the native reproduction gap on the Fastly asset" + ); +} + +#[test] +fn rebuild_enforces_the_comparison_rule_per_asset() { + let mut fastly_unavailable = members(FASTLY); + edit_companion( + &mut fastly_unavailable, + FASTLY, + ".rebuild-check.json", + |document| { + document["outcome"] = json!("unavailable"); + document["reproduction_sha256"] = Value::Null; + }, + ); + assert!( + rebuild_rejection(FASTLY, &fastly_unavailable).contains("checked Fastly equality"), + "should reject an unavailable Fastly comparison" + ); + + let mut native_matched = members(LINUX); + let artifact_sha256 = sha256_hex(&artifact_bytes(LINUX)); + edit_companion( + &mut native_matched, + LINUX, + ".rebuild-check.json", + |document| { + document["outcome"] = json!("matched"); + document["reproduction_sha256"] = json!(artifact_sha256); + }, + ); + assert!( + rebuild_rejection(LINUX, &native_matched).contains("checked Fastly equality"), + "should reject a matched native comparison" + ); + + let mut fastly_other = members(FASTLY); + edit_companion( + &mut fastly_other, + FASTLY, + ".rebuild-check.json", + |document| { + document["reproduction_sha256"] = json!(digest("fictional other reproduction")); + }, + ); + assert!( + rebuild_rejection(FASTLY, &fastly_other).contains("checked Fastly equality"), + "should reject a Fastly reproduction digest other than canonical" + ); +} + +#[test] +fn rebuild_rejects_changed_executable_inventory_or_spdx_root() { + let mut executable = members(MACOS_ARM); + executable.insert( + ARTIFACT_MEMBER.to_owned(), + b"fictional tampered executable".to_vec(), + ); + assert!( + rebuild_rejection(MACOS_ARM, &executable).contains("differs from its build evidence"), + "should reject executable bytes other than the recorded artifact" + ); + + let mut inventory_change = members(MACOS_ARM); + let mut value: Value = serde_json::from_slice(&inventory_change[INVENTORY_NAME]) + .expect("should parse the inventory"); + value["tools"] = json!([{"name": "fictional-tool"}]); + inventory_change.insert(INVENTORY_NAME.to_owned(), render_metadata(&value)); + assert!( + rebuild_rejection(MACOS_ARM, &inventory_change).contains("js_inventory_sha256"), + "should reject an inventory other than the evidence digest names" + ); + + let mut spdx_root = members(MACOS_ARM); + edit_companion(&mut spdx_root, MACOS_ARM, ".spdx.json", |document| { + document["packages"][0]["checksums"][0]["checksumValue"] = + json!(digest("fictional other executable")); + }); + assert!( + rebuild_rejection(MACOS_ARM, &spdx_root).contains("SPDX root package checksum"), + "should reject an SPDX root naming another executable" + ); + + let mut embedded = members(FASTLY); + edit_companion(&mut embedded, FASTLY, ".build-evidence.json", |document| { + let first = document["embedded_modules"][0].clone(); + document["embedded_modules"] = json!([first]); + }); + assert!( + rebuild_rejection(FASTLY, &embedded).contains("embed every prepared JS module"), + "should reject a Fastly executable omitting a prepared module" + ); +} + +#[test] +fn rebuild_rejects_another_policy_version_or_an_oversized_companion() { + let mut policy = parse_json(POLICY, DocumentRole::Metadata).expect("should parse the policy"); + policy["policy_version"] = json!(CLI_POLICY_VERSION + 1); + let other_policy = render_metadata(&policy); + let result = CandidateArtifactEntry::from_ordinary_ci_transfer( + asset_id(LINUX), + COMMIT, + &other_policy, + &members(LINUX), + ); + assert!( + rejection(result).contains("differs from compiled version"), + "should reject a policy version other than the compiled one" + ); + + let mut oversized = members(LINUX); + let limit = usize::try_from(DocumentRole::Spdx.limit()).expect("should fit the SPDX limit"); + oversized.insert(format!("{LINUX}.spdx.json"), vec![b' '; limit + 1]); + assert!( + rebuild_rejection(LINUX, &oversized).contains("role byte limit"), + "should reject a companion over its role limit" + ); +} + +// ---- Descriptor parser ---- + +fn descriptor_rejection(value: &Value, asset: &str) -> String { + rejection(EntryDescriptor::parse( + &render_metadata(value), + asset_id(asset), + RUN_ID, + )) +} + +#[test] +fn descriptor_accepts_the_exact_upload_outputs() { + let parsed = descriptor(FASTLY, 9); + + assert_eq!(parsed.artifact_id(), 9, "should keep the exact upload ID"); + assert_eq!( + parsed.artifact_name(), + upload_name(asset_id(FASTLY), RUN_ID, 1), + "should keep the exact upload name" + ); + assert_eq!( + parsed.archive_sha256(), + digest(&format!("fictional archive {FASTLY}")), + "should strip the optional sha256 prefix" + ); + assert_eq!( + parsed.members().keys().cloned().collect::>(), + member_names(asset_id(FASTLY)), + "should declare exactly the six flat members" + ); +} + +#[test] +fn descriptor_rejects_unknown_or_missing_keys_and_another_asset() { + let mut unknown = descriptor_json(FASTLY, 1); + unknown["workflow_run_id"] = json!(1); + assert!( + descriptor_rejection(&unknown, FASTLY).contains("exactly its transfer and upload keys"), + "should reject an unknown key" + ); + + let mut missing = descriptor_json(FASTLY, 1); + missing + .as_object_mut() + .expect("should be an object") + .remove("run_attempt"); + assert!( + descriptor_rejection(&missing, FASTLY).contains("exactly its transfer and upload keys"), + "should reject a missing key" + ); + + assert!( + descriptor_rejection(&descriptor_json(FASTLY, 1), LINUX).contains("another asset"), + "should reject a descriptor in another asset's slot" + ); +} + +#[test] +fn descriptor_rejects_bad_identity_digest_or_name() { + let cases: [(&str, Value, &str); 6] = [ + ("artifact_id", json!(0), "upload ID must be positive"), + ("run_attempt", json!(0), "run attempt must be positive"), + ("archive_sha256", json!("sha256:ABC"), "archive digest"), + ( + "entry_sha256", + json!(digest("x").to_uppercase()), + "entry digest", + ), + ( + "artifact_name", + json!(format!("candidate-entry-{FASTLY}-1-1")), + "upload name must be", + ), + ("run_attempt", json!(2), "upload name must be"), + ]; + for (key, replacement, expected) in cases { + let mut value = descriptor_json(FASTLY, 1); + value[key] = replacement; + let message = descriptor_rejection(&value, FASTLY); + assert!( + message.contains(expected), + "should reject a bad {key}: {message}" + ); + } +} + +#[test] +fn descriptor_rejects_wrong_member_names_or_sizes() { + let mut renamed = descriptor_json(FASTLY, 1); + let members = renamed["members"] + .as_object_mut() + .expect("should list members"); + let size = members + .remove(ARTIFACT_MEMBER) + .expect("should declare the executable"); + members.insert("executable".to_owned(), size); + assert!( + descriptor_rejection(&renamed, FASTLY).contains("exactly the six flat members"), + "should reject a renamed member" + ); + + let mut empty = descriptor_json(FASTLY, 1); + empty["members"][INVENTORY_NAME] = json!(0); + assert!( + descriptor_rejection(&empty, FASTLY).contains("empty or exceeds its bound"), + "should reject an empty member" + ); + + let mut oversized = descriptor_json(FASTLY, 1); + oversized["members"][format!("{FASTLY}.build-recipe.json")] = + json!(DocumentRole::Companion.limit() + 1); + assert!( + descriptor_rejection(&oversized, FASTLY).contains("empty or exceeds its bound"), + "should reject a member over its role bound" + ); + + let mut executable = descriptor_json(FASTLY, 1); + executable["members"][ARTIFACT_MEMBER] = json!(DocumentRole::Executable.limit() + 1); + assert!( + descriptor_rejection(&executable, FASTLY).contains("empty or exceeds its bound"), + "should reject an executable over its bound" + ); +} + +#[test] +fn collector_rejects_repeated_upload_ids() { + let first = descriptor(FASTLY, 5); + let second = descriptor(LINUX, 5); + + assert!( + rejection(check_distinct_uploads(&[&first, &second])).contains("distinct IDs"), + "should reject two slots naming the same upload" + ); +} + +// ---- Collector assembly ---- + +#[test] +fn collector_rejects_members_or_entry_digest_differing_from_the_descriptor() { + let mut changed_member = received(); + changed_member[0] + .members + .insert(ARTIFACT_MEMBER.to_owned(), b"short".to_vec()); + assert!( + rejection(assemble_received(&changed_member, COMMIT, POLICY)) + .contains("differ from their descriptor"), + "should reject members differing from the declared sizes" + ); + + let mut other_entry = received(); + let asset = FIXED_ARTIFACTS[1].name; + assert_eq!( + other_entry[1].descriptor.asset(), + asset_id(asset), + "should receive entries in cohort order" + ); + let mut value = descriptor_json(asset, 2); + value["entry_sha256"] = json!(digest("fictional other entry")); + other_entry[1].descriptor = + EntryDescriptor::parse(&render_metadata(&value), asset_id(asset), RUN_ID) + .expect("should accept the descriptor shape"); + assert!( + rejection(assemble_received(&other_entry, COMMIT, POLICY)) + .contains("differs from the producing job's entry"), + "should reject an entry other than the producing job checked" + ); + + let mut three = received(); + three.pop(); + assert!( + rejection(assemble_received(&three, COMMIT, POLICY)).contains("missing asset"), + "should never assemble from fewer than four entries" + ); +} + +/// Inventory bytes like the fictional one, from `commit` with `tools`. +fn inventory_from(commit: &str, tools: Value) -> Vec { + let mut value: Value = serde_json::from_slice(&inventory()).expect("should parse inventory"); + value["source_commit"] = json!(commit); + value["tools"] = tools; + render_metadata(&value) +} + +/// Rebuilds the fictional entry of `asset` rendered from `parts`. +fn rebuilt_from(parts: EntryParts, commit: &str) -> CandidateArtifactEntry { + let asset = parts.asset; + let artifact = artifact_bytes(asset.artifact().name); + let entry = render_entry(parts).expect("should render the varied entry"); + let members: BTreeMap> = entry + .transfer_members(&artifact) + .into_iter() + .map(|(name, bytes)| (name, bytes.to_vec())) + .collect(); + CandidateArtifactEntry::from_ordinary_ci_transfer(asset, commit, POLICY, &members) + .expect("should rebuild the varied entry") +} + +/// Rebuilt entries for every asset except Linux, plus `linux`. +fn rebuilt_with_linux(linux: CandidateArtifactEntry) -> Vec { + FIXED_ARTIFACTS + .iter() + .filter(|fixed| fixed.name != LINUX) + .map(|fixed| rebuilt_from(parts(fixed.name), COMMIT)) + .chain([linux]) + .collect() +} + +#[test] +fn rebuilt_entries_still_reject_mixed_commits_or_inventories() { + let mut other = parts(LINUX); + other.source_commit = OTHER_COMMIT.to_owned(); + other.inventory = inventory_from(OTHER_COMMIT, json!([])); + let mixed_commit = rebuilt_with_linux(rebuilt_from(other, OTHER_COMMIT)); + assert!( + rejection(assemble_candidate_index(&mixed_commit)).contains("different source commits"), + "should reject rebuilt entries from different commits" + ); + + let mut varied = parts(LINUX); + varied.inventory = inventory_from(COMMIT, json!([{"name": "fictional-tool"}])); + let mixed_inventory = rebuilt_with_linux(rebuilt_from(varied, COMMIT)); + assert!( + rejection(assemble_candidate_index(&mixed_inventory)).contains("different JS inventories"), + "should reject rebuilt entries naming different inventories" + ); +} + +// ---- Export and index writers ---- + +#[test] +fn export_writes_exactly_the_six_declared_members() { + let scratch = tempfile::tempdir().expect("should create a scratch directory"); + let upload = scratch.path().join("candidate-entry"); + let transfer = scratch.path().join("candidate-entry-transfer.json"); + + entry(MACOS_ARM) + .export_flat_for_ordinary_ci(&artifact_bytes(MACOS_ARM), &upload, &transfer) + .expect("should export the fictional entry"); + + let written = read_directory(&upload); + let description: Value = + serde_json::from_slice(&std::fs::read(&transfer).expect("should read the transfer file")) + .expect("should parse the transfer file"); + assert_eq!( + written.keys().cloned().collect::>(), + member_names(asset_id(MACOS_ARM)), + "should write exactly the six flat members" + ); + assert_eq!( + description["members"], + json!(sizes(&written)), + "should declare the written member sizes" + ); + assert_eq!( + written, + members(MACOS_ARM), + "should write the exact member bytes" + ); + assert_eq!( + description["asset"], MACOS_ARM, + "should name the exported asset" + ); +} + +#[test] +fn export_refuses_existing_paths_nested_transfer_or_other_executable_bytes() { + let scratch = tempfile::tempdir().expect("should create a scratch directory"); + let existing = scratch.path().join("existing"); + std::fs::create_dir(&existing).expect("should create the existing directory"); + let transfer = scratch.path().join("transfer.json"); + let linux = entry(LINUX); + let artifact = artifact_bytes(LINUX); + + assert!( + rejection(linux.export_flat_for_ordinary_ci(&artifact, &existing, &transfer)) + .contains("cannot create new transfer directory"), + "should refuse an existing upload directory" + ); + + let fresh = scratch.path().join("fresh"); + assert!( + rejection(linux.export_flat_for_ordinary_ci( + &artifact, + &fresh, + &fresh.join("transfer.json") + )) + .contains("outside the uploaded directory"), + "should refuse a transfer file inside the upload" + ); + + assert!( + rejection(linux.export_flat_for_ordinary_ci(b"fictional other bytes", &fresh, &transfer)) + .contains("differ from the entry's recorded artifact"), + "should refuse executable bytes other than the recorded artifact" + ); + assert!( + !fresh.exists(), + "should write nothing for a rejected export" + ); +} + +#[test] +fn index_writer_writes_the_index_inventory_and_sixteen_companions_once() { + let scratch = tempfile::tempdir().expect("should create a scratch directory"); + let root = scratch.path().join("candidate-index"); + let index = assembled(); + + write_candidate_index(&index, &root).expect("should write the candidate index"); + + let written = read_directory(&root); + assert_eq!(written.len(), 18, "should write exactly eighteen files"); + assert_eq!( + written.get(CANDIDATE_INDEX_NAME).map(Vec::as_slice), + Some(index.bytes()), + "should write the exact index bytes" + ); + assert_eq!( + written.get(INVENTORY_NAME).map(Vec::as_slice), + Some(index.inventory()), + "should write the exact inventory bytes" + ); + for (name, bytes) in index.companions() { + assert_eq!( + written.get(name), + Some(bytes), + "should write the exact companion {name}" + ); + } + assert!( + rejection(write_candidate_index(&index, &root)) + .contains("cannot create new transfer directory"), + "should refuse an existing index directory" + ); +} diff --git a/crates/trusted-server-release/src/distribution/transfer.rs b/crates/trusted-server-release/src/distribution/transfer.rs new file mode 100644 index 0000000000..57d34169da --- /dev/null +++ b/crates/trusted-server-release/src/distribution/transfer.rs @@ -0,0 +1,652 @@ +//! Test-only ordinary-CI transfer of candidate entries between CI jobs. +//! +//! Each real candidate job exports its rechecked [`CandidateArtifactEntry`] +//! as six flat files (the final executable, the shared JS inventory and the +//! four unsigned companions) plus a separate CI-internal descriptor. A +//! collector job downloads the four exact uploads, rebuilds each entry with +//! [`CandidateArtifactEntry::from_ordinary_ci_transfer`], which reruns every +//! producer join over the transferred bytes, and assembles the unqualified +//! candidate index with the unchanged [`assemble_candidate_index`]. +//! +//! The descriptor is ordinary-CI plumbing, not a release schema. Nothing here +//! binds a trusted workflow transfer: companions keep `null` workflow and +//! transfer identities and the `trusted-workflow-transfer-not-bound` gap, and +//! nothing grants signing, publication or qualification authority. + +use std::collections::{BTreeMap, BTreeSet}; +use std::os::unix::fs::DirBuilderExt as _; +use std::path::Path; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::entry::{ENTRY_GAPS, MAX_GAP_LENGTH, NATIVE_REPRODUCTION_GAP, policy_identity}; +use super::joins::{ + EvidenceJoin, INVENTORY_ROLE, check_build_evidence, check_comparison, check_spdx_root, + inventory_modules, parse_informational, +}; +use super::{ + CandidateArtifactEntry, CandidateIndex, CompanionFile, CompanionRole, INVENTORY_NAME, + assemble_candidate_index, distribution_error, sha256_hex, +}; +use crate::collection::producer::write_new; +use crate::documents::{Role as DocumentRole, canonical_json, lower_hex, parse_json}; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::recipe::AssetId; + +/// Flat member name of the transferred final executable. +pub(crate) const ARTIFACT_MEMBER: &str = "artifact"; +/// Prefix of every candidate-entry upload name. +const UPLOAD_PREFIX: &str = "candidate-entry-"; +/// Keys the producing job's transfer file carries. +const TRANSFER_KEYS: [&str; 3] = ["asset", "entry_sha256", "members"]; +/// Keys the CI describe step adds from the exact upload-step outputs. +const UPLOAD_KEYS: [&str; 4] = [ + "archive_sha256", + "artifact_id", + "artifact_name", + "run_attempt", +]; +/// Domain of the rendered build-evidence companion. +const EVIDENCE_DOMAIN: &str = "trusted-server.release.candidate-build-evidence"; +/// Domain of the rendered build-recipe companion. +const RECIPE_DOMAIN: &str = "trusted-server.release.candidate-build-recipe"; +/// Domain of the rendered rebuild-check companion. +const REBUILD_DOMAIN: &str = "trusted-server.release.candidate-rebuild-check"; + +type Result = core::result::Result>; + +fn parse(bytes: &[u8], role: DocumentRole) -> Result { + parse_json(bytes, role).map_err(|error| error.change_context(ReleaseError::Distribution)) +} + +fn write_member(path: &Path, bytes: &[u8]) -> Result<()> { + write_new(path, bytes).map_err(|error| error.change_context(ReleaseError::Distribution)) +} + +/// Creates a new private directory, refusing an existing path. +fn create_private_directory(directory: &Path) -> Result<()> { + std::fs::DirBuilder::new() + .mode(0o700) + .create(directory) + .map_err(|error| { + distribution_error(format!( + "cannot create new transfer directory {}: {error}", + directory.display() + )) + }) +} + +/// The byte bound of one transferred member of `asset`. +fn member_limit(asset: AssetId, name: &str) -> Option { + if name == ARTIFACT_MEMBER { + return Some(DocumentRole::Executable.limit()); + } + if name == INVENTORY_NAME { + return Some(INVENTORY_ROLE.limit()); + } + CompanionRole::ALL + .into_iter() + .find(|role| name == format!("{}{}", asset.artifact().name, role.suffix())) + .map(CompanionRole::limit) +} + +/// The exact six flat member names transferred for `asset`. +pub(crate) fn member_names(asset: AssetId) -> BTreeSet { + let mut names: BTreeSet = CompanionRole::ALL + .into_iter() + .map(|role| format!("{}{}", asset.artifact().name, role.suffix())) + .collect(); + names.insert(ARTIFACT_MEMBER.to_owned()); + names.insert(INVENTORY_NAME.to_owned()); + names +} + +/// The exact upload name of `asset` from producing `run_id` and `run_attempt`. +pub(crate) fn upload_name(asset: AssetId, run_id: u64, run_attempt: u64) -> String { + format!( + "{UPLOAD_PREFIX}{}-{run_id}-{run_attempt}", + asset.artifact().name + ) +} + +impl CandidateArtifactEntry { + /// The exact flat member bytes of this entry plus its final executable. + pub(super) fn transfer_members<'a>(&'a self, artifact: &'a [u8]) -> BTreeMap { + let mut members: BTreeMap = self + .companions + .iter() + .map(|file| (file.name.clone(), file.bytes.as_slice())) + .collect(); + members.insert(ARTIFACT_MEMBER.to_owned(), artifact); + members.insert(INVENTORY_NAME.to_owned(), &self.inventory); + members + } + + /// Exports this rechecked entry for the ordinary-CI index collector. + /// + /// Writes exactly the six flat members into the new directory + /// `upload_directory` and the transfer file (asset, member sizes and + /// the rechecked entry digest) to the new file `transfer_file`, which + /// must lie outside the upload directory. + /// + /// # Errors + /// + /// Rejects executable bytes other than the entry's recorded size and + /// digest, a failed recheck, an existing directory or file, a transfer + /// file inside the upload directory and any write failure. + pub(crate) fn export_flat_for_ordinary_ci( + &self, + artifact: &[u8], + upload_directory: &Path, + transfer_file: &Path, + ) -> Result<()> { + if artifact.len() as u64 != self.artifact_size + || sha256_hex(artifact) != self.artifact_sha256 + { + return Err(distribution_error( + "exported executable bytes differ from the entry's recorded artifact", + )); + } + if transfer_file.starts_with(upload_directory) { + return Err(distribution_error( + "transfer file must lie outside the uploaded directory", + )); + } + let summary = self.recheck_for_measurement()?; + let members = self.transfer_members(artifact); + let sizes: BTreeMap<&str, u64> = members + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.len() as u64)) + .collect(); + let transfer = canonical_json( + &json!({ + "asset": self.name(), + "entry_sha256": summary.entry_sha256(), + "members": sizes, + }), + DocumentRole::Metadata, + ) + .map_err(|error| error.change_context(ReleaseError::Distribution))?; + create_private_directory(upload_directory)?; + for (name, bytes) in &members { + write_member(&upload_directory.join(name), bytes)?; + } + write_member(transfer_file, &transfer) + } + + /// Rebuilds one entry from an ordinary-CI job's transferred bytes. + /// + /// Test-only. `policy` must be the consumer policy bytes the collector + /// read from its own authenticated checkout; the policy identity is never + /// taken from the producing job. Every producer join reruns over the + /// exact bytes: the shared inventory, build evidence (including the + /// executable's size and digest, which the transferred executable must + /// match), the SPDX root, the informational recipe and the rebuild check. + /// Fastly must record the checked `matched` equality and native assets + /// `unavailable`; every companion must be unqualified, carry `null` + /// workflow and transfer identities and state the same sorted gaps. + /// + /// # Errors + /// + /// Rejects a missing, extra or misnamed member, any companion naming + /// another asset, target, source or executable, a qualified companion, + /// differing or incomplete gaps, a comparison outcome other than the + /// asset's rule, a policy version other than the compiled one and any + /// failed join or oversized companion. + pub(crate) fn from_ordinary_ci_transfer( + asset: AssetId, + source_commit: &str, + policy: &[u8], + members: &BTreeMap>, + ) -> Result { + let expected = member_names(asset); + if !members.keys().eq(expected.iter()) { + return Err(distribution_error(format!( + "transfer for `{}` must carry exactly its six flat members", + asset.artifact().name + ))); + } + if !lower_hex(source_commit, 40) { + return Err(distribution_error("expected source commit is malformed")); + } + let (policy_version, policy_sha256) = policy_identity(policy)?; + let member = |name: &str| -> &[u8] { members.get(name).map_or(&[], Vec::as_slice) }; + let companion = |role: CompanionRole| { + member(&format!("{}{}", asset.artifact().name, role.suffix())).to_vec() + }; + let evidence_bytes = companion(CompanionRole::BuildEvidence); + let evidence = parse(&evidence_bytes, DocumentRole::Companion)?; + let identity = check_evidence_identity(asset, source_commit, &evidence)?; + let artifact = member(ARTIFACT_MEMBER); + if artifact.len() as u64 != identity.artifact_size + || sha256_hex(artifact) != identity.artifact_sha256 + { + return Err(distribution_error( + "transferred executable differs from its build evidence", + )); + } + let inventory = member(INVENTORY_NAME).to_vec(); + let inventory_sha256 = sha256_hex(&inventory); + let modules = inventory_modules(&inventory, source_commit)?; + check_build_evidence( + &evidence_bytes, + &EvidenceJoin { + source_commit, + target: asset.artifact().target.triple(), + artifact_sha256: &identity.artifact_sha256, + inventory_sha256: &inventory_sha256, + modules: &modules, + }, + )?; + if is_fastly(asset) && evidence["embedded_modules"] != evidence["prepared_modules"] { + return Err(distribution_error( + "Fastly executable must embed every prepared JS module", + )); + } + let spdx = companion(CompanionRole::Sbom); + check_spdx_root(&spdx, &identity.artifact_sha256)?; + let recipe_bytes = companion(CompanionRole::BuildRecipe); + let recipe = parse_informational(&recipe_bytes, source_commit)?; + check_recipe(asset, &recipe)?; + let rebuild_bytes = companion(CompanionRole::RebuildCheck); + check_comparison(&rebuild_bytes, source_commit)?; + let rebuild = parse_informational(&rebuild_bytes, source_commit)?; + check_rebuild(asset, &identity.artifact_sha256, &rebuild)?; + let gaps = check_gaps(asset, &evidence)?; + for (role, document) in [ + (CompanionRole::BuildRecipe, &recipe), + (CompanionRole::RebuildCheck, &rebuild), + ] { + if document["release_qualification"] != false || document["gaps"] != json!(gaps) { + return Err(distribution_error(format!( + "{} companion must be unqualified and state exactly the entry gaps", + role.key() + ))); + } + } + let name = asset.artifact().name; + let entry = Self { + asset, + source_commit: source_commit.to_owned(), + policy_version, + policy_sha256, + inventory, + artifact_size: identity.artifact_size, + artifact_sha256: identity.artifact_sha256, + companions: vec![ + CompanionFile::new(CompanionRole::Sbom, name, spdx), + CompanionFile::new(CompanionRole::BuildEvidence, name, evidence_bytes), + CompanionFile::new(CompanionRole::BuildRecipe, name, recipe_bytes), + CompanionFile::new(CompanionRole::RebuildCheck, name, rebuild_bytes), + ], + gaps, + }; + entry.check_companions(&mut BTreeSet::new())?; + Ok(entry) + } +} + +fn is_fastly(asset: AssetId) -> bool { + asset.artifact().target == ReleaseTarget::Wasm32Wasip1 +} + +/// The executable identity build evidence records. +struct EvidenceIdentity { + artifact_sha256: String, + artifact_size: u64, +} + +fn check_evidence_identity( + asset: AssetId, + source_commit: &str, + evidence: &Value, +) -> Result { + let artifact = asset.artifact(); + if evidence["domain"] != EVIDENCE_DOMAIN + || evidence["schema_version"] != 1 + || evidence["release_qualification"] != false + || evidence["asset"] != artifact.name + || evidence["target"] != artifact.target.triple() + || evidence["source_commit"] != source_commit + || !evidence["workflow_run_id"].is_null() + || !evidence["transfer_artifact_id"].is_null() + { + return Err(distribution_error(format!( + "build evidence is not the unqualified candidate evidence of `{}` at the expected source", + artifact.name + ))); + } + let artifact_sha256 = evidence["artifact_sha256"] + .as_str() + .filter(|digest| lower_hex(digest, 64)) + .map(str::to_owned); + let artifact_size = evidence["artifact_size"] + .as_u64() + .filter(|size| (1..=DocumentRole::Executable.limit()).contains(size)); + let (Some(artifact_sha256), Some(artifact_size)) = (artifact_sha256, artifact_size) else { + return Err(distribution_error( + "build evidence executable identity is malformed", + )); + }; + Ok(EvidenceIdentity { + artifact_sha256, + artifact_size, + }) +} + +fn check_recipe(asset: AssetId, recipe: &Value) -> Result<()> { + let artifact = asset.artifact(); + if recipe["domain"] != RECIPE_DOMAIN + || recipe["asset"] != artifact.name + || recipe["package"] != artifact.package + || recipe["target"] != artifact.target.triple() + || !recipe["workflow_run_id"].is_null() + { + return Err(distribution_error(format!( + "build recipe is not the candidate recipe of `{}`", + artifact.name + ))); + } + Ok(()) +} + +fn check_rebuild(asset: AssetId, artifact_sha256: &str, rebuild: &Value) -> Result<()> { + let name = asset.artifact().name; + if rebuild["domain"] != REBUILD_DOMAIN + || rebuild["asset"] != name + || rebuild["canonical_sha256"] != artifact_sha256 + || !rebuild["workflow_run_id"].is_null() + || !rebuild["reproduction_transfer_artifact_id"].is_null() + { + return Err(distribution_error(format!( + "rebuild check is not the candidate check of `{name}`" + ))); + } + let rule_holds = if is_fastly(asset) { + rebuild["outcome"] == "matched" && rebuild["reproduction_sha256"] == artifact_sha256 + } else { + rebuild["outcome"] == "unavailable" && rebuild["reproduction_sha256"].is_null() + }; + if !rule_holds { + return Err(distribution_error(format!( + "rebuild check of `{name}` must be the checked Fastly equality or unavailable for a native asset" + ))); + } + Ok(()) +} + +/// Requires sorted, unique, well-formed gaps covering the fixed entry gaps, +/// the native reproduction gap exactly for native assets and at least one +/// SPDX gap. +fn check_gaps(asset: AssetId, evidence: &Value) -> Result> { + let gaps = evidence["gaps"] + .as_array() + .ok_or_else(|| distribution_error("build evidence states no gap list"))? + .iter() + .map(|gap| { + gap.as_str() + .filter(|text| { + !text.is_empty() + && text.len() <= MAX_GAP_LENGTH + && !text + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + }) + .map(str::to_owned) + .ok_or_else(|| distribution_error("candidate gap text is malformed")) + }) + .collect::>>()?; + if gaps.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(distribution_error( + "candidate gaps must be sorted and unique", + )); + } + let has = |wanted: &str| gaps.iter().any(|gap| gap == wanted); + if let Some(missing) = ENTRY_GAPS.iter().find(|gap| !has(gap)) { + return Err(distribution_error(format!( + "candidate gaps omit the fixed gap `{missing}`" + ))); + } + if has(NATIVE_REPRODUCTION_GAP) == is_fastly(asset) { + return Err(distribution_error( + "native reproduction gap must be stated exactly for native assets", + )); + } + let fixed = ENTRY_GAPS.len() + usize::from(!is_fastly(asset)); + if gaps.len() <= fixed { + return Err(distribution_error("candidate SPDX must state its gaps")); + } + Ok(gaps) +} + +/// One producing job's checked upload descriptor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EntryDescriptor { + asset: AssetId, + entry_sha256: String, + artifact_id: u64, + artifact_name: String, + archive_sha256: String, + members: BTreeMap, +} + +impl EntryDescriptor { + /// Strictly parses the descriptor the producing job emitted for `asset`. + /// + /// The descriptor's own `asset` must equal the slot's asset, its upload + /// name must be exactly that asset's name for `run_id` and the producing + /// job's own `run_attempt`, and its members must be exactly the six flat + /// names, each non-empty and within its role bound. + /// + /// Attempts may differ between descriptors: re-running only a failed job + /// gives it a new attempt while the others keep their earlier uploads. + /// REST metadata has no attempt field, so the exact recomputed name, with + /// the corroborated run, ID, digest, head and repository, carries the + /// binding. + /// + /// # Errors + /// + /// Rejects malformed JSON, missing or unknown keys, a zero ID or attempt, + /// a malformed digest, another asset or upload name, and wrong member + /// names or sizes. + pub(crate) fn parse(bytes: &[u8], asset: AssetId, run_id: u64) -> Result { + let value = parse(bytes, DocumentRole::Metadata)?; + let keys: BTreeSet<&str> = value + .as_object() + .ok_or_else(|| distribution_error("candidate entry descriptor must be an object"))? + .keys() + .map(String::as_str) + .collect(); + let wanted: BTreeSet<&str> = TRANSFER_KEYS.into_iter().chain(UPLOAD_KEYS).collect(); + if keys != wanted { + return Err(distribution_error( + "candidate entry descriptor must carry exactly its transfer and upload keys", + )); + } + let name = asset.artifact().name; + if value["asset"] != name { + return Err(distribution_error(format!( + "descriptor names another asset than `{name}`" + ))); + } + let entry_sha256 = value["entry_sha256"] + .as_str() + .filter(|digest| lower_hex(digest, 64)) + .ok_or_else(|| distribution_error("descriptor entry digest is malformed"))?; + let archive = value["archive_sha256"].as_str().unwrap_or_default(); + let archive_sha256 = archive.strip_prefix("sha256:").unwrap_or(archive); + if !lower_hex(archive_sha256, 64) { + return Err(distribution_error("descriptor archive digest is malformed")); + } + let artifact_id = value["artifact_id"] + .as_u64() + .filter(|id| *id > 0) + .ok_or_else(|| distribution_error("descriptor upload ID must be positive"))?; + let run_attempt = value["run_attempt"] + .as_u64() + .filter(|attempt| *attempt > 0) + .ok_or_else(|| distribution_error("descriptor run attempt must be positive"))?; + let artifact_name = upload_name(asset, run_id, run_attempt); + if value["artifact_name"] != artifact_name.as_str() { + return Err(distribution_error(format!( + "descriptor upload name must be `{artifact_name}`" + ))); + } + let members = parse_members(asset, &value["members"])?; + Ok(Self { + asset, + entry_sha256: entry_sha256.to_owned(), + artifact_id, + artifact_name, + archive_sha256: archive_sha256.to_owned(), + members, + }) + } + + /// The asset this descriptor's slot names. + pub(crate) fn asset(&self) -> AssetId { + self.asset + } + + /// The exact upload ID returned by the producing upload step. + pub(crate) fn artifact_id(&self) -> u64 { + self.artifact_id + } + + /// The exact upload name the producing job used. + pub(crate) fn artifact_name(&self) -> &str { + &self.artifact_name + } + + /// The original upload ZIP digest, without a `sha256:` prefix. + pub(crate) fn archive_sha256(&self) -> &str { + &self.archive_sha256 + } + + /// The exact flat member names and sizes declared by the producing job. + pub(crate) fn members(&self) -> &BTreeMap { + &self.members + } +} + +fn parse_members(asset: AssetId, value: &Value) -> Result> { + let object = value + .as_object() + .ok_or_else(|| distribution_error("descriptor members must be an object"))?; + let names: BTreeSet = object.keys().cloned().collect(); + if names != member_names(asset) { + return Err(distribution_error(format!( + "descriptor must declare exactly the six flat members of `{}`", + asset.artifact().name + ))); + } + object + .iter() + .map(|(name, size)| { + let limit = member_limit(asset, name).unwrap_or_default(); + size.as_u64() + .filter(|size| (1..=limit).contains(size)) + .map(|size| (name.clone(), size)) + .ok_or_else(|| { + distribution_error(format!( + "descriptor member `{name}` size is empty or exceeds its bound" + )) + }) + }) + .collect() +} + +/// Requires four descriptors with distinct upload IDs, one per cohort asset. +/// +/// # Errors +/// +/// Rejects a repeated upload ID or asset. +pub(crate) fn check_distinct_uploads(descriptors: &[&EntryDescriptor]) -> Result<()> { + let ids: BTreeSet = descriptors.iter().map(|item| item.artifact_id).collect(); + let assets: BTreeSet<&str> = descriptors + .iter() + .map(|item| item.asset.artifact().name) + .collect(); + if ids.len() != descriptors.len() || assets.len() != descriptors.len() { + return Err(distribution_error( + "candidate entry uploads must have distinct IDs and assets", + )); + } + Ok(()) +} + +/// One downloaded upload: its descriptor and its exact extracted members. +pub(crate) struct ReceivedEntry { + /// The checked descriptor the producing job emitted. + pub(crate) descriptor: EntryDescriptor, + /// The flat members read through the unchanged ZIP reader. + pub(crate) members: BTreeMap>, +} + +/// Rebuilds each received entry and assembles the unqualified candidate index. +/// +/// Each rebuilt entry must recheck to exactly the entry digest its producing +/// job reported, and its members must be exactly the declared names and sizes. +/// +/// # Errors +/// +/// Rejects repeated uploads, members differing from their descriptor, any +/// rebuild failure, an entry digest other than the producing job's, and every +/// [`assemble_candidate_index`] rejection. +pub(crate) fn assemble_received( + received: &[ReceivedEntry], + source_commit: &str, + policy: &[u8], +) -> Result { + let descriptors: Vec<&EntryDescriptor> = received.iter().map(|item| &item.descriptor).collect(); + check_distinct_uploads(&descriptors)?; + let entries = received + .iter() + .map(|item| { + let declared = &item.descriptor.members; + let matches = item.members.len() == declared.len() + && item + .members + .iter() + .all(|(name, bytes)| declared.get(name) == Some(&(bytes.len() as u64))); + if !matches { + return Err(distribution_error(format!( + "received members of `{}` differ from their descriptor", + item.descriptor.asset.artifact().name + ))); + } + let entry = CandidateArtifactEntry::from_ordinary_ci_transfer( + item.descriptor.asset, + source_commit, + policy, + &item.members, + )?; + if entry.recheck_for_measurement()?.entry_sha256() != item.descriptor.entry_sha256 { + return Err(distribution_error(format!( + "rebuilt entry of `{}` differs from the producing job's entry", + entry.name() + ))); + } + Ok(entry) + }) + .collect::>>()?; + assemble_candidate_index(&entries) +} + +/// Writes the index, shared inventory and sixteen companions into the new +/// directory `root`. +/// +/// # Errors +/// +/// Rejects an existing directory and any write failure. +pub(crate) fn write_candidate_index(index: &CandidateIndex, root: &Path) -> Result<()> { + create_private_directory(root)?; + write_member(&root.join(index.name()), index.bytes())?; + write_member(&root.join(INVENTORY_NAME), index.inventory())?; + for (name, bytes) in index.companions() { + write_member(&root.join(name), bytes)?; + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 1a443c679e..963199c933 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -34,6 +34,12 @@ pub(crate) enum ReleaseError { Runtime, #[display("Artifact composition evidence is rejected")] Composition, + #[display("Release transfer collection is rejected")] + Collection, + #[display("SPDX generation inputs or document are rejected")] + Spdx, + #[display("Candidate release distribution evidence is rejected")] + Distribution, } impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 73bb316736..f1efc96778 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -1,3 +1,9 @@ +#[allow(dead_code, reason = "candidate source port has no production caller")] +mod collection; +#[allow(dead_code, reason = "candidate source port has no production caller")] +mod distribution; +#[allow(dead_code, reason = "candidate source port has no production caller")] +mod spdx; // consumed by Task 7 preparation and Task 9 recipes #[allow(dead_code)] mod boundary; diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index 270a9f772e..b89f4db190 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -65,7 +65,9 @@ mod sources; mod startup; mod tools; -pub(crate) use ambient::{AmbientProfile, CheckedAmbient}; +pub(crate) use ambient::{ + AmbientClaim, AmbientProfile, CheckedAmbient, Dimension, ObservedAmbient, +}; #[allow( unused_imports, reason = "the qualified Apple selection has no production caller yet" @@ -73,11 +75,15 @@ pub(crate) use ambient::{AmbientProfile, CheckedAmbient}; pub(crate) use apple::{ AppleDeveloperTools, AppleToolEvidence, AppleToolFacts, QualifiedAppleTools, }; +pub(crate) use artifact::copy_artifact; pub(crate) use cargo_output::successful_messages as observed_cargo_messages; +pub(crate) use compare::FastlyEqualityChecked; pub(crate) use completed::RecipeCompleted; pub(crate) use identity::{ExactInvocationDigest, RecipeDefinitionIdentity}; pub(crate) use paths::ExecutionPaths; pub(crate) use plan::CargoPlan; +pub(crate) use runtime::FastlyRuntimeAccepted; +pub(crate) use startup::NativeStartupAccepted; #[allow( unused_imports, reason = "the qualified selection has no production caller yet" diff --git a/crates/trusted-server-release/src/recipe/startup.rs b/crates/trusted-server-release/src/recipe/startup.rs index dd385a638b..ca0fab62f6 100644 --- a/crates/trusted-server-release/src/recipe/startup.rs +++ b/crates/trusted-server-release/src/recipe/startup.rs @@ -14,7 +14,6 @@ mod accepted; mod lifecycle; -#[cfg(test)] pub(crate) use accepted::NativeStartupAccepted; pub(crate) mod native; diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs index 9250e199cd..e258eb55d9 100644 --- a/crates/trusted-server-release/src/recipe/tests.rs +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -336,6 +336,9 @@ fn checked_reproduction() -> CheckedAmbient { mod ambient; mod bind; mod candidate; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod candidate_entry; +mod candidate_index; #[cfg(target_os = "linux")] mod cli; mod cli_candidate; @@ -359,6 +362,7 @@ mod real_fixture; mod remap; mod runtime; mod sources; +mod spdx_candidate; mod tools; #[test] diff --git a/crates/trusted-server-release/src/recipe/tests/candidate.rs b/crates/trusted-server-release/src/recipe/tests/candidate.rs index bf429cb3a4..28917944be 100644 --- a/crates/trusted-server-release/src/recipe/tests/candidate.rs +++ b/crates/trusted-server-release/src/recipe/tests/candidate.rs @@ -36,6 +36,205 @@ pub(super) enum FixtureHost { /// How the fixture inputs were acquired. const ACQUISITION: &str = "unauthenticated-ordinary-ci"; +/// Checks that a real candidate export outlives all owned build storage. +pub(super) fn check_export_after_cleanup(build: T, completed: fn(&T) -> &RecipeCompleted) { + check_owned_export_after_cleanup(build, completed, None); +} + +/// Checks an export carrying the actual final-byte Fastly runtime acceptance. +pub(super) fn check_fastly_export_after_cleanup( + build: T, + completed: fn(&T) -> &RecipeCompleted, + accepted: &super::super::runtime::FastlyRuntimeAccepted, +) { + check_owned_export_after_cleanup(build, completed, Some(RuntimeAcceptance::Fastly(accepted))); +} + +/// Checks retained canonical native startup evidence after every build root is removed. +pub(super) fn check_native_export_after_cleanup( + build: T, + completed: fn(&T) -> &RecipeCompleted, + accepted: &super::super::NativeStartupAccepted, +) { + check_owned_export_after_cleanup(build, completed, Some(RuntimeAcceptance::Native(accepted))); +} + +#[derive(Clone, Copy)] +enum RuntimeAcceptance<'a> { + Fastly(&'a super::super::FastlyRuntimeAccepted), + Native(&'a super::super::NativeStartupAccepted), +} + +impl RuntimeAcceptance<'_> { + fn diagnostic_for(&self, completed: &RecipeCompleted) -> Value { + match self { + Self::Fastly(accepted) => accepted.diagnostic_for(completed), + Self::Native(accepted) => accepted.diagnostic_for(completed), + } + .expect("should retain exact owning runtime observations") + } +} + +fn check_owned_export_after_cleanup( + build: T, + completed: fn(&T) -> &RecipeCompleted, + accepted: Option>, +) { + let completed = completed(&build); + let artifact = completed.artifact().path().to_owned(); + let view = completed + .composition_view() + .expect("should expose sealed typed receipts after working-root cleanup"); + assert_eq!( + view.receipt_sha256(), + completed + .composition_export() + .expect("should export the same sealed receipt") + .sha256(), + "should bind the typed view to the exact original retained receipt bytes" + ); + assert!( + !view.observations().units().is_empty(), + "should retain actual compiler units through the typed view" + ); + for package in view.observations().packages() { + let origin = view + .package_origins() + .package(package.id()) + .expect("should retain an exact verified origin for every observed package"); + assert_eq!( + origin.name(), + package.name(), + "should retain the joined name" + ); + assert_eq!( + origin.version(), + package.version(), + "should retain the joined version" + ); + } + let projected = crate::spdx::cargo::CargoProjection::from_completed(completed) + .expect("should project verified Rust packages after working-root cleanup"); + assert_eq!( + projected.component_count(), + view.observations().packages().len(), + "should project precisely the observed packages rather than the potential graph" + ); + assert_eq!( + projected.observations().units().len(), + view.observations().units().len(), + "should retain the original per-unit feature and context evidence" + ); + let javascript = completed + .javascript_contributions() + .expect("should retain checked original JS evidence without its preparation lease"); + let inventory = javascript.raw_inventory().to_vec(); + let javascript_projection = + crate::spdx::javascript::JavascriptProjection::from_completed(completed) + .expect("should project original JS evidence joined to final-byte membership"); + assert!( + javascript_projection.component_count() > 0, + "should project actual checked JS material even when CLI discards complete bundles" + ); + let native = crate::spdx::native::NativeProjection::from_completed(completed).expect( + "should project exact observed native contexts through verified original materials", + ); + assert_eq!( + native.component_count(), + view.native().contributors().len(), + "should preserve measured native membership rather than every prepared dependency" + ); + let platform = crate::spdx::platform::PlatformProjection::from_completed(completed) + .expect("should project artifact-bound installed tools and logical runtime requirements"); + assert!( + platform.component_count() > 0, + "should retain measured platform facts" + ); + assert_eq!( + platform.receipt_sha256(), + view.receipt_sha256(), + "should project the exact retained composition receipt" + ); + let composition = completed + .composition_export() + .expect("should export the sealed composition receipt"); + crate::collection::check_composition_receipt( + composition.bytes(), + &crate::collection::ReceiptFacts { + artifact_sha256: completed.artifact().sha256(), + source_commit: completed.commit().as_str(), + inventory_sha256: javascript.inventory_sha256(), + materials_sha256: completed.materials_digest(), + asset: completed.artifact().name(), + }, + ) + .unwrap_or_else(|error| { + panic!("should accept the real producer composition receipt: {error:?}") + }); + let expected_runtime = accepted.map(|accepted| accepted.diagnostic_for(completed)); + let export = match accepted { + Some(RuntimeAcceptance::Fastly(runtime)) => { + crate::collection::producer::CandidateBuildExport::with_fastly_runtime( + completed, runtime, + ) + } + Some(RuntimeAcceptance::Native(startup)) => { + crate::collection::producer::CandidateBuildExport::with_native_startup( + completed, startup, + ) + } + None => crate::collection::producer::CandidateBuildExport::from_completed(completed), + } + .expect("should export actual candidate receipts before closing their owners"); + drop(build); + assert!( + !artifact.exists(), + "should remove the original artifact with its owning build storage" + ); + let root = export + .rehash_for_handoff() + .expect("should retain exact exported bytes after all original build storage is removed"); + assert_eq!( + std::fs::read(root.join("tsjs-inventory.json")) + .expect("should retain original JS inventory"), + inventory, + "should preserve original observer bytes after source, target and JS owners disappear" + ); + let record: Value = serde_json::from_slice( + &std::fs::read(root.join("candidate-build-record.json")) + .expect("should retain build record"), + ) + .expect("should parse exported diagnostic record"); + let gaps = record["gaps"] + .as_array() + .expect("should list remaining gaps"); + match expected_runtime { + Some(runtime) => { + assert_eq!( + record["runtime_acceptance"], runtime, + "should retain exact final-byte runtime observations after working-root cleanup" + ); + assert!( + !gaps + .iter() + .any(|gap| gap == "runtime-acceptance-not-exported"), + "should remove only a gap satisfied by actual owning acceptance" + ); + } + None => { + assert!( + record["runtime_acceptance"].is_null(), + "should not invent unobserved runtime acceptance" + ); + assert!( + gaps.iter() + .any(|gap| gap == "runtime-acceptance-not-exported"), + "should preserve the missing runtime gap" + ); + } + } +} + /// Every field `fixture.json` must hold, and nothing else. const FIXTURE_FIELDS: [&str; 10] = [ "acquisition", diff --git a/crates/trusted-server-release/src/recipe/tests/candidate_entry.rs b/crates/trusted-server-release/src/recipe/tests/candidate_entry.rs new file mode 100644 index 0000000000..c3b35119c2 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/candidate_entry.rs @@ -0,0 +1,121 @@ +//! Checks of the candidate index entry each real CI build constructs from +//! its canonical completed build and actual acceptance, and the summary it +//! adds to that job's retained measurement. +//! +//! The four entries come from different CI jobs, so they are never joined +//! here. Each job records its own entry and JS inventory digest and exports +//! the rechecked entry, with its final executable, as flat files that the +//! ordinary-CI collector (`distribution::ordinary`) downloads, rebuilds and +//! joins into the unqualified candidate index. + +use std::path::Path; + +use serde_json::Value; + +use super::candidate::js_inventory_sha256; +use super::spdx_candidate::check_platform_gaps; +use super::*; +use crate::distribution::{CandidateAcceptance, candidate_entry_from_completed}; +use crate::policy::{FIXED_ARTIFACTS, ReleaseTarget}; + +/// Gap every native entry states while no reproduction comparison exists. +const NATIVE_REPRODUCTION_GAP: &str = "native-reproduction-unavailable"; + +/// The rebuild-check outcome a canonical entry for `target` must record. +fn expected_rebuild_outcome(target: ReleaseTarget) -> &'static str { + match target { + ReleaseTarget::Wasm32Wasip1 => "matched", + ReleaseTarget::X86_64UnknownLinuxGnu + | ReleaseTarget::Aarch64AppleDarwin + | ReleaseTarget::X86_64AppleDarwin => "unavailable", + } +} + +/// Where one real candidate job exports its entry for the index collector. +pub(super) struct EntryExport<'a> { + /// New directory whose flat files the job uploads. + pub(super) upload_directory: &'a Path, + /// New transfer file, outside the upload, the job's describe step reads. + pub(super) transfer_file: &'a Path, +} + +/// Builds and rechecks the candidate entry of one real canonical build, +/// exports it for the ordinary-CI index collector and returns its +/// measurement summary. +/// +/// # Panics +/// +/// Panics when the entry cannot be constructed, its rendered artifact fails +/// the `#/$defs/artifact` sub-schema, a producer join fails, a companion's +/// recomputed digest or size differs, the gaps miss the target family's +/// platform gaps, the rebuild-check outcome is not the expected one, or the +/// final executable or export cannot be read or written. +pub(super) fn check_real_entry( + completed: &RecipeCompleted, + source: &ApprovedSource, + acceptance: &CandidateAcceptance<'_>, + export: &EntryExport<'_>, +) -> Value { + let entry = + candidate_entry_from_completed(completed, source, acceptance).unwrap_or_else(|error| { + panic!("should build the candidate entry of the real canonical build: {error:?}") + }); + let artifact = completed.asset().artifact(); + assert_eq!( + entry.name(), + artifact.name, + "should name the completed asset" + ); + let summary = entry.recheck_for_measurement().unwrap_or_else(|error| { + panic!("should recheck the real entry's schema, joins and companions: {error:?}") + }); + assert_eq!( + summary.rebuild_outcome(), + expected_rebuild_outcome(artifact.target), + "should record the {} rebuild-check outcome", + artifact.target.triple() + ); + assert_eq!( + summary.inventory_sha256(), + js_inventory_sha256(completed), + "should name the exact JS inventory this build consumed" + ); + assert!(!entry.gaps().is_empty(), "should state its gaps"); + check_platform_gaps(completed, entry.gaps()); + assert_eq!( + entry + .gaps() + .iter() + .any(|gap| gap == NATIVE_REPRODUCTION_GAP), + artifact.target != ReleaseTarget::Wasm32Wasip1, + "should state the native reproduction gap exactly for native assets" + ); + let final_bytes = completed + .read_final_bytes() + .expect("should read the rehashed final executable for export"); + entry + .export_flat_for_ordinary_ci(&final_bytes, export.upload_directory, export.transfer_file) + .unwrap_or_else(|error| { + panic!("should export the real entry for the index collector: {error:?}") + }); + summary.measurement() +} + +#[test] +fn only_the_fastly_asset_expects_a_matched_rebuild_check() { + let outcomes: Vec<(&str, &str)> = FIXED_ARTIFACTS + .iter() + .map(|fixed| (fixed.name, expected_rebuild_outcome(fixed.target))) + .collect(); + + assert_eq!( + outcomes, + [ + ("trusted-server-fastly.wasm", "matched"), + ("ts-aarch64-apple-darwin", "unavailable"), + ("ts-x86_64-apple-darwin", "unavailable"), + ("ts-x86_64-unknown-linux-gnu", "unavailable"), + ], + "should expect a matched comparison only for the Fastly asset" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/candidate_index.rs b/crates/trusted-server-release/src/recipe/tests/candidate_index.rs new file mode 100644 index 0000000000..c53739c33f --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/candidate_index.rs @@ -0,0 +1,107 @@ +//! Completed-build wiring rejections of a candidate index entry. +//! +//! A pure completion fixture has no composition receipts and no actual +//! runtime or startup acceptance, so these tests reach only the owner checks +//! that run before acceptance binding. Successful entries require the real +//! completed candidate builds. + +use super::complete::{built, wasm_with}; +use super::fixture::OTHER_COMMIT; +use super::*; +use crate::distribution::{AcceptanceKind, check_candidate_owner}; +use crate::inspect::tests::builders::{ElfSpec, elf}; + +const FASTLY: &str = "trusted-server-fastly.wasm"; +const LINUX: &str = "ts-x86_64-unknown-linux-gnu"; + +fn completed(asset: &'static str, role: Role) -> RecipeCompleted { + let bytes = if asset == FASTLY { + wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]) + } else { + elf(&ElfSpec { + payload: b"/trusted-server/src/main.rs", + ..ElfSpec::conforming() + }) + }; + let mut build = built(asset, role, &bytes); + let stdout = build.stdout(|_| {}); + build + .finish(stdout) + .expect("should complete the accepted fixture artifact") +} + +fn source(commit: &str) -> ApprovedSource { + let commit = CommitId::parse(commit).expect("should parse the fixture commit"); + ApprovedSource::in_memory_for_tests(&commit, &[]) +} + +fn rejection(result: Result<(), Report>) -> String { + format!( + "{:?}", + result.expect_err("should reject the candidate owner") + ) +} + +#[test] +fn candidate_owner_accepts_the_canonical_build_with_its_acceptance_family() { + check_candidate_owner( + &completed(FASTLY, Role::Canonical), + &source(COMMIT), + AcceptanceKind::FastlyRuntime, + ) + .expect("should accept the canonical Fastly owner"); + check_candidate_owner( + &completed(LINUX, Role::Canonical), + &source(COMMIT), + AcceptanceKind::NativeStartup, + ) + .expect("should accept the canonical native owner"); +} + +#[test] +fn candidate_owner_rejects_a_reproduction_build() { + let error = rejection(check_candidate_owner( + &completed(FASTLY, Role::Reproduction), + &source(COMMIT), + AcceptanceKind::FastlyRuntime, + )); + assert!( + error.contains("only from the canonical build"), + "should reject the reproduction role: {error}" + ); +} + +#[test] +fn candidate_owner_rejects_the_wrong_acceptance_family() { + let fastly = rejection(check_candidate_owner( + &completed(FASTLY, Role::Canonical), + &source(COMMIT), + AcceptanceKind::NativeStartup, + )); + assert!( + fastly.contains("requires FastlyRuntime acceptance"), + "should reject native acceptance for Fastly: {fastly}" + ); + let native = rejection(check_candidate_owner( + &completed(LINUX, Role::Canonical), + &source(COMMIT), + AcceptanceKind::FastlyRuntime, + )); + assert!( + native.contains("requires NativeStartup acceptance"), + "should reject Fastly acceptance for a native CLI: {native}" + ); +} + +#[test] +fn candidate_owner_rejects_a_source_at_another_commit() { + let error = rejection(check_candidate_owner( + &completed(FASTLY, Role::Canonical), + &source(OTHER_COMMIT), + AcceptanceKind::FastlyRuntime, + )); + assert!( + error.contains("approved source differs from the completed build commit"), + "should reject the mismatched source: {error}" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/cli.rs b/crates/trusted-server-release/src/recipe/tests/cli.rs index 9ffa3a84bf..6a1d7c03eb 100644 --- a/crates/trusted-server-release/src/recipe/tests/cli.rs +++ b/crates/trusted-server-release/src/recipe/tests/cli.rs @@ -22,6 +22,7 @@ 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::candidate_entry::{EntryExport, check_real_entry}; use super::cli_candidate::{ CliCandidateFacts, CliFixture, NativeReproduction, OPERATOR_SENTINELS, ReproductionOutcome, check_build_budget, cli_candidate_measurement, finish_cleanup, native_reproduction_from_env, @@ -32,8 +33,10 @@ use super::real_fixture::{ cargo_fixture, environment_with_operator_values, path_text, try_ordinary_ci_ambient, try_prepare_vendor, }; +use super::spdx_candidate::{candidate_spdx_measurement, check_real_candidate}; use super::*; use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, ImageSelection, ProbeProgram}; +use crate::distribution::CandidateAcceptance; use crate::documents::{parse_json, read_bounded}; use crate::inspect::{ElfPolicy, inspect_elf}; use crate::preflight::AuditedEnvironment; @@ -240,6 +243,8 @@ fn start_everywhere(completed: &RecipeCompleted, cli: &CliFixture) -> NativeStar observed } +// The reviewed esbuild lifecycle hook remains at 0.27.3 while the current +// JS lock selects 0.28.2; this source port does not qualify the new hook. #[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() { @@ -299,10 +304,15 @@ fn linux_cli_candidate_builds_inspects_and_starts_cleanly() { .expect("should encode the canonical measurement"), ) .expect("should retain accepted canonical evidence before optional reproduction"); + let canonical_candidate = check_real_candidate(&canonical.completed, &js.source); let reproduction_build = match native_reproduction { NativeReproduction::Measure => Some(build_cli(&inputs, Role::Reproduction)), NativeReproduction::Defer => None, }; + let reproduction_candidate = reproduction_build + .as_ref() + .and_then(|build| build.as_ref().ok()) + .map(|reproduction| check_real_candidate(&reproduction.completed, &js.source)); let paired = reproduction_build.as_ref().map(|build| { reproduction_result( @@ -331,12 +341,34 @@ fn linux_cli_candidate_builds_inspects_and_starts_cleanly() { .expect("should retain actual Linux startup runs"), reproduction, }); + measurement["candidate_spdx"] = + candidate_spdx_measurement(&canonical_candidate, reproduction_candidate.as_ref()); 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:?}"); } + let upload_directory = cli_root.join("candidate-entry"); + let transfer_file = cli_root.join("candidate-entry-transfer.json"); + measurement["candidate_entry"] = check_real_entry( + &canonical.completed, + &js.source, + &CandidateAcceptance::Native(&startup), + &EntryExport { + upload_directory: &upload_directory, + transfer_file: &transfer_file, + }, + ); + retain_cli_measurement(&cli_root, &measurement); + super::candidate::check_native_export_after_cleanup( + canonical, + |build| &build.completed, + &startup, + ); + if let Some(Ok(reproduction)) = reproduction_build { + super::candidate::check_export_after_cleanup(reproduction, |build| &build.completed); + } } /// Retains `measurement` as the Linux CLI candidate measurement. diff --git a/crates/trusted-server-release/src/recipe/tests/complete.rs b/crates/trusted-server-release/src/recipe/tests/complete.rs index 3e98d1e927..c8e507a3ea 100644 --- a/crates/trusted-server-release/src/recipe/tests/complete.rs +++ b/crates/trusted-server-release/src/recipe/tests/complete.rs @@ -14,6 +14,25 @@ use crate::inspect::tests::builders::{ElfSpec, MachoSpec, elf, macho}; const FINGERPRINT: [u8; 32] = [7; 32]; +#[test] +fn a_completed_candidate_without_complete_composition_cannot_generate_spdx() { + 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 candidate artifact"); + build + .fixture + .completed + .rehash_for_handoff() + .expect("should retain an unchanged owned JS handoff"); + assert!( + crate::spdx::generate_from_completed(&completed, &build.fixture.completed).is_err(), + "should reject SPDX generation without complete owning composition inputs" + ); +} + #[test] fn pure_completion_fixtures_cannot_export_measured_socket_receipts() { let bytes = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); @@ -48,6 +67,10 @@ fn pure_completion_fixtures_cannot_export_candidate_transfers() { completed.javascript_contributions().is_err(), "should refuse original JS evidence absent from the owning completion" ); + assert!( + crate::collection::producer::CandidateBuildExport::from_completed(&completed).is_err(), + "should not manufacture a measured candidate transfer from a pure fixture" + ); } // Inject only a checked JS snapshot into an explicitly unmeasured completion diff --git a/crates/trusted-server-release/src/recipe/tests/full.rs b/crates/trusted-server-release/src/recipe/tests/full.rs index 2a928bd974..432f33836f 100644 --- a/crates/trusted-server-release/src/recipe/tests/full.rs +++ b/crates/trusted-server-release/src/recipe/tests/full.rs @@ -21,12 +21,15 @@ use super::candidate::{ CargoFixture, SCHEMA_VERSION, cargo_docker_settings, cargo_measurement, ci_build_provenance, denial_measurement, equality_measurement, reported_errno, }; +use super::candidate_entry::{EntryExport, check_real_entry}; use super::real_fixture::{ ATTEMPTS, DenialCase, DenialSelection, bind_denial_case, cargo_fixture, ordinary_ci_ambient, path_text, try_prepare_vendor, }; +use super::spdx_candidate::{candidate_spdx_measurement, check_real_candidate}; use super::*; use crate::boundary::{Boundary, DenialProof, ImageSelection, ProbeProgram}; +use crate::distribution::CandidateAcceptance; use crate::materials::{LockedMaterials, resolution_differences}; use crate::preparation::tests::full::{JsFixtureRun, run_two_fresh_js_builds}; use crate::process::{Cancellation, ProcessLimits}; @@ -384,6 +387,8 @@ fn scan_final_bytes(fixture: &CargoFixture, builds: [&FastlyBuild; 2]) -> RemapS .unwrap_or_else(|error| panic!("should prove remapping in the final bytes: {error:?}")) } +// The reviewed esbuild lifecycle hook remains at 0.27.3 while the current +// JS lock selects 0.28.2; this source port does not qualify the new hook. #[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() { @@ -431,6 +436,10 @@ fn fastly_canonical_and_reproduction_builds_are_byte_identical() { "forbidden": ["/boundary/", "host build roots", "fixture root", "RUNNER_TEMP"], "found": scan.found(), }); + let canonical_candidate = check_real_candidate(&canonical.completed, &js.source); + let reproduction_candidate = check_real_candidate(&reproduction.completed, &js.source); + measurement["candidate_spdx"] = + candidate_spdx_measurement(&canonical_candidate, Some(&reproduction_candidate)); measurement["js_inventory_sha256"] = serde_json::json!(js_inventory_sha256(&canonical.completed)); measurement["ci_build_provenance"] = ci_build_provenance(&fixture); @@ -454,6 +463,31 @@ fn fastly_canonical_and_reproduction_builds_are_byte_identical() { .is_err(), "should reject attaching reproduction acceptance to the equal-byte canonical role" ); + let upload_directory = fixture.root.join("candidate-entry"); + let transfer_file = fixture.root.join("candidate-entry-transfer.json"); + measurement["candidate_entry"] = check_real_entry( + &canonical.completed, + &js.source, + &CandidateAcceptance::Fastly { + runtime: &canonical_runtime, + equality: &checked, + }, + &EntryExport { + upload_directory: &upload_directory, + transfer_file: &transfer_file, + }, + ); + retain_equality_measurement(&fixture, &measurement); + super::candidate::check_fastly_export_after_cleanup( + canonical, + |build| &build.completed, + &canonical_runtime, + ); + super::candidate::check_fastly_export_after_cleanup( + reproduction, + |build| &build.completed, + &reproduction_runtime, + ); } /// Prints and retains `measurement` as the canonical/reproduction equality diff --git a/crates/trusted-server-release/src/recipe/tests/macos_cli.rs b/crates/trusted-server-release/src/recipe/tests/macos_cli.rs index 90084d52ee..6cc2d750ec 100644 --- a/crates/trusted-server-release/src/recipe/tests/macos_cli.rs +++ b/crates/trusted-server-release/src/recipe/tests/macos_cli.rs @@ -22,6 +22,7 @@ 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::candidate_entry::{EntryExport, check_real_entry}; use super::cli_candidate::{ NativeReproduction, OPERATOR_SENTINELS, ReproductionOutcome, check_build_budget, finish_cleanup, native_reproduction_from_env, reproduction_result, @@ -36,8 +37,10 @@ use super::real_fixture::{ environment_with_operator_values, minimal_environment, path_text, try_ordinary_ci_ambient, try_prepare_vendor, }; +use super::spdx_candidate::{candidate_spdx_measurement, check_real_candidate}; use super::*; use crate::boundary::DescendantCleanup; +use crate::distribution::CandidateAcceptance; use crate::inspect::{MachoCpu, MachoPolicy, inspect_macho}; use crate::materials::LockedMaterials; use crate::preflight::AuditedEnvironment; @@ -227,13 +230,16 @@ fn start_natively(build: &MacosBuild) -> NativeStartupAccepted { } /// Encodes, prints and retains the measurement beside the Cargo fixture. +/// Writes and prints the measurement; `candidate_spdx` is null until rendered. /// Returns the retained measurement so a later check can extend it. fn retain_measurement( fixture: &CargoFixture, facts: &MacosCliCandidateFacts<'_>, + candidate_spdx: serde_json::Value, provenance: &serde_json::Value, ) -> serde_json::Value { let mut measurement = macos_cli_candidate_measurement(facts); + measurement["candidate_spdx"] = candidate_spdx; measurement["js_inventory_sha256"] = serde_json::json!(js_inventory_sha256(facts.canonical)); measurement["ci_build_provenance"] = provenance.clone(); write_measurement(fixture, &measurement); @@ -248,6 +254,8 @@ fn write_measurement(fixture: &CargoFixture, measurement: &serde_json::Value) { .expect("should retain the macOS CLI candidate measurement"); } +// The reviewed esbuild lifecycle hook remains at 0.27.3 while the current +// JS lock selects 0.28.2; this source port does not qualify the new hook. #[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() { @@ -340,13 +348,19 @@ fn macos_cli_candidate_builds_inspects_and_starts_cleanly() { retain_measurement( &fixture, &facts(ReproductionOutcome::NotAttempted), + serde_json::Value::Null, &provenance, ); + let canonical_candidate = check_real_candidate(&canonical.completed, &source); let reproduction_build = match native_reproduction { NativeReproduction::Measure => Some(build_macos_cli(&inputs, Role::Reproduction)), NativeReproduction::Defer => None, }; + let reproduction_candidate = reproduction_build + .as_ref() + .and_then(|build| build.as_ref().ok()) + .map(|reproduction| check_real_candidate(&reproduction.completed, &source)); let paired = reproduction_build.as_ref().map(|build| { reproduction_result( build @@ -363,8 +377,33 @@ fn macos_cli_candidate_builds_inspects_and_starts_cleanly() { Some((Err(error), Some(_))) => ReproductionOutcome::InvariantFailed(error.clone()), Some((Err(error), None)) => ReproductionOutcome::BuildFailed(error.clone()), }; - retain_measurement(&fixture, &facts(reproduction), &provenance); + let mut measurement = retain_measurement( + &fixture, + &facts(reproduction), + candidate_spdx_measurement(&canonical_candidate, reproduction_candidate.as_ref()), + &provenance, + ); if let Some((_, Some(error))) = &paired { panic!("should pair builds over identical inputs: {error:?}"); } + let upload_directory = fixture.root.join("candidate-entry"); + let transfer_file = fixture.root.join("candidate-entry-transfer.json"); + measurement["candidate_entry"] = check_real_entry( + &canonical.completed, + &source, + &CandidateAcceptance::Native(&startup), + &EntryExport { + upload_directory: &upload_directory, + transfer_file: &transfer_file, + }, + ); + write_measurement(&fixture, &measurement); + super::candidate::check_native_export_after_cleanup( + canonical, + |build| &build.completed, + &startup, + ); + if let Some(Ok(reproduction)) = reproduction_build { + super::candidate::check_export_after_cleanup(reproduction, |build| &build.completed); + } } diff --git a/crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs b/crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs new file mode 100644 index 0000000000..0522ed9167 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs @@ -0,0 +1,406 @@ +//! Checks of the unqualified candidate whole-document SPDX: the assertions the +//! real CI builds run on every completed artifact, the measurement they print, +//! and the wiring rejections a pure completion fixture can reach. +//! +//! Byte identity of the canonical and reproduction candidates is recorded as a +//! measurement only; it is not a gate. + +use serde_json::{Value, json}; +use sha2::Sha256; + +use super::complete::{Built, built, wasm_with}; +use super::fixture::OTHER_COMMIT; +use super::*; +use crate::policy::ReleaseTarget; +use crate::recipe::AppleToolEvidence; +use crate::spdx::candidate::{CandidateSpdx, candidate_from_completed}; + +/// SPDX identifier of the executable root package. +const ROOT_SPDX_ID: &str = "SPDXRef-Artifact"; +/// Gap stated when GNU privileged helper contents stay unmeasured. +const GNU_PRIVILEGED_HELPER_GAP: &str = "gnu-privileged-helper-content-unmeasured"; +/// Gap stated when the Apple tool closure is not fully measured. +const APPLE_TOOL_CLOSURE_GAP: &str = "apple-tool-closure-unmeasured"; + +/// Renders and checks the candidate SPDX of one real completed build. +/// +/// # Panics +/// +/// Panics when the candidate cannot be rendered, does not validate, binds +/// another root digest, omits or invents a projected package, or misses a +/// coverage gap its target family must state. +pub(super) fn check_real_candidate( + completed: &RecipeCompleted, + source: &ApprovedSource, +) -> CandidateSpdx { + let candidate = candidate_from_completed(completed, source).unwrap_or_else(|error| { + panic!("should render a candidate SPDX for the real completed build: {error:?}") + }); + assert!( + !candidate.gaps().is_empty(), + "should state at least one coverage gap" + ); + assert_eq!( + candidate.sha256(), + hex::encode(Sha256::digest(candidate.bytes())), + "should hash the exact candidate document bytes" + ); + let document: Value = + serde_json::from_slice(candidate.bytes()).expect("should parse the candidate document"); + crate::documents::validate_spdx(&document).expect("should satisfy the pinned SPDX schema"); + let packages = document["packages"] + .as_array() + .expect("should list candidate packages"); + let root = packages + .iter() + .find(|package| package["SPDXID"] == ROOT_SPDX_ID) + .expect("should contain the executable root package"); + assert_eq!( + root["checksums"], + json!([{"algorithm": "SHA256", "checksumValue": completed.artifact().sha256()}]), + "should bind the root package to the completed artifact SHA-256" + ); + assert_eq!( + packages.len(), + 1 + projected_component_count(completed), + "should contain the root plus exactly the four projections' components" + ); + let comment = document["creationInfo"]["comment"] + .as_str() + .expect("should carry a creation comment"); + assert!( + comment.contains("candidate"), + "should name the document a candidate" + ); + for gap in candidate.gaps() { + assert!( + comment.contains(gap.as_str()), + "should state gap {gap} in the creation comment" + ); + } + check_platform_gaps(completed, candidate.gaps()); + candidate +} + +/// Sums the component counts of the four projections of one completed build. +fn projected_component_count(completed: &RecipeCompleted) -> usize { + let rust = crate::spdx::cargo::CargoProjection::from_completed(completed) + .expect("should project the real Rust packages"); + let native = crate::spdx::native::NativeProjection::from_completed(completed) + .expect("should project the real native contributors"); + let javascript = crate::spdx::javascript::JavascriptProjection::from_completed(completed) + .expect("should project the real JavaScript modules"); + let platform = crate::spdx::platform::PlatformProjection::from_completed(completed) + .expect("should project the real platform facts"); + rust.component_count() + + native.component_count() + + javascript.component_count() + + platform.component_count() +} + +/// Requires the target family's platform gaps and no Apple gap elsewhere. +pub(super) fn check_platform_gaps(completed: &RecipeCompleted, gaps: &[String]) { + let view = completed + .composition_view() + .expect("should expose the sealed composition view"); + let platform = view.platform(); + let target = completed.asset().artifact().target; + let facts = PlatformGapFacts { + unresolved_tool_roles: platform.unresolved_native_tool_roles(), + apple_exclusions: platform.apple().map(AppleToolEvidence::coverage_exclusions), + apple_closure_unmeasured: platform.apple_tool_closure_unmeasured(), + }; + let required = required_platform_gaps(target, &facts).unwrap_or_else(|reason| { + panic!("should fit {} platform evidence: {reason}", target.triple()) + }); + for gap in &required { + assert!( + gaps.contains(gap), + "should state the {} platform gap {gap}", + target.triple() + ); + } + if !target.is_darwin() { + assert!( + !gaps.iter().any(|gap| gap.starts_with("apple-")), + "should state no Apple gap for {}", + target.triple() + ); + } +} + +/// The platform facts a real candidate's gaps must restate. +struct PlatformGapFacts<'a> { + unresolved_tool_roles: &'a [&'static str], + apple_exclusions: Option<&'a [&'static str]>, + apple_closure_unmeasured: bool, +} + +/// Names the gaps one target family's candidate must state. +/// +/// Fastly Wasm must state every unresolved native tool host path, the Linux +/// GNU CLI its unmeasured privileged helper contents, and Apple targets every +/// reviewed exclusion plus an unmeasured tool closure. +/// +/// # Errors +/// +/// Returns the reason when the facts lack what the target family records: +/// Fastly with no unresolved tool role, or Apple with no Apple evidence or no +/// Apple gap at all. +fn required_platform_gaps( + target: ReleaseTarget, + facts: &PlatformGapFacts<'_>, +) -> Result, String> { + match target { + ReleaseTarget::Wasm32Wasip1 => { + if facts.unresolved_tool_roles.is_empty() { + return Err("Fastly evidence records no unresolved native tool role".to_owned()); + } + Ok(facts + .unresolved_tool_roles + .iter() + .map(|role| format!("native-tool-host-path-unresolved:{role}")) + .collect()) + } + ReleaseTarget::X86_64UnknownLinuxGnu => Ok(vec![GNU_PRIVILEGED_HELPER_GAP.to_owned()]), + ReleaseTarget::Aarch64AppleDarwin | ReleaseTarget::X86_64AppleDarwin => { + let exclusions = facts + .apple_exclusions + .ok_or_else(|| "Apple target records no Apple tool evidence".to_owned())?; + let mut required = exclusions + .iter() + .map(|exclusion| format!("apple-coverage-excluded:{exclusion}")) + .collect::>(); + if facts.apple_closure_unmeasured { + required.push(APPLE_TOOL_CLOSURE_GAP.to_owned()); + } + if required.is_empty() { + return Err( + "Apple evidence records neither exclusions nor an unmeasured closure" + .to_owned(), + ); + } + Ok(required) + } + } +} + +/// The measured identity of one candidate document. +struct CandidateDocument<'a> { + sha256: &'a str, + bytes: &'a [u8], + gaps: &'a [String], +} + +impl<'a> From<&'a CandidateSpdx> for CandidateDocument<'a> { + fn from(candidate: &'a CandidateSpdx) -> Self { + Self { + sha256: candidate.sha256(), + bytes: candidate.bytes(), + gaps: candidate.gaps(), + } + } +} + +/// The printed measurement of a build's candidate SPDX documents. +pub(super) fn candidate_spdx_measurement( + canonical: &CandidateSpdx, + reproduction: Option<&CandidateSpdx>, +) -> Value { + candidate_pair_measurement( + &CandidateDocument::from(canonical), + reproduction.map(CandidateDocument::from).as_ref(), + ) +} + +/// Records both documents and whether their bytes match, without gating on it. +fn candidate_pair_measurement( + canonical: &CandidateDocument<'_>, + reproduction: Option<&CandidateDocument<'_>>, +) -> Value { + let document = |candidate: &CandidateDocument<'_>| { + json!({ + "sha256": candidate.sha256, + "size": candidate.bytes.len(), + "gaps": candidate.gaps, + }) + }; + json!({ + "kind": "ordinary-ci-candidate-spdx", + "canonical": document(canonical), + "reproduction": reproduction.map(document), + "byte_identical": reproduction.map(|reproduction| reproduction.bytes == canonical.bytes), + "byte_identity_gate": false, + "release_qualification": false, + }) +} + +/// Completes the pure Fastly fixture build at the fixture commit. +fn completed_fixture() -> (Built, RecipeCompleted) { + 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"); + (build, completed) +} + +fn rejection(result: Result>) -> String { + match result { + Ok(_) => panic!("should reject the candidate"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn candidate_rejects_an_approved_source_at_another_commit() { + let (_build, completed) = completed_fixture(); + let other = CommitId::parse(OTHER_COMMIT).expect("should parse the other commit"); + let source = ApprovedSource::in_memory_for_tests(&other, &[]); + let error = rejection(candidate_from_completed(&completed, &source)); + assert!( + error.contains("approved source differs from the completed build commit"), + "should reject the source commit mismatch: {error}" + ); +} + +#[test] +fn candidate_rejects_a_completion_without_retained_composition() { + let (_build, completed) = completed_fixture(); + let commit = CommitId::parse(COMMIT).expect("should parse the fixture commit"); + let source = ApprovedSource::in_memory_for_tests(&commit, &[]); + let error = rejection(candidate_from_completed(&completed, &source)); + assert!( + !error.contains("approved source differs") + && error.contains("completed build has no retained composition"), + "should pass the commit check before rejecting the missing receipts: {error}" + ); +} + +fn facts<'a>( + unresolved_tool_roles: &'a [&'static str], + apple_exclusions: Option<&'a [&'static str]>, + apple_closure_unmeasured: bool, +) -> PlatformGapFacts<'a> { + PlatformGapFacts { + unresolved_tool_roles, + apple_exclusions, + apple_closure_unmeasured, + } +} + +#[test] +fn fastly_requires_every_unresolved_tool_role() { + let required = required_platform_gaps( + ReleaseTarget::Wasm32Wasip1, + &facts(&["compiler", "linker"], None, false), + ) + .expect("should accept unresolved Fastly tool roles"); + assert_eq!( + required, + [ + "native-tool-host-path-unresolved:compiler", + "native-tool-host-path-unresolved:linker", + ], + "should require one gap per unresolved role" + ); + assert!( + required_platform_gaps(ReleaseTarget::Wasm32Wasip1, &facts(&[], None, false)).is_err(), + "should reject Fastly evidence with no unresolved role" + ); +} + +#[test] +fn linux_requires_the_gnu_privileged_helper_gap() { + let required = required_platform_gaps( + ReleaseTarget::X86_64UnknownLinuxGnu, + &facts(&[], None, false), + ) + .expect("should name the GNU gap"); + assert_eq!( + required, + [GNU_PRIVILEGED_HELPER_GAP], + "should require the privileged helper gap" + ); +} + +#[test] +fn apple_requires_exclusions_and_the_unmeasured_closure() { + let required = required_platform_gaps( + ReleaseTarget::Aarch64AppleDarwin, + &facts(&[], Some(&["example-exclusion"]), true), + ) + .expect("should accept Apple evidence"); + assert_eq!( + required, + [ + "apple-coverage-excluded:example-exclusion", + APPLE_TOOL_CLOSURE_GAP + ], + "should require every exclusion and the closure gap" + ); + assert!( + required_platform_gaps(ReleaseTarget::X86_64AppleDarwin, &facts(&[], None, true)).is_err(), + "should reject an Apple target without Apple evidence" + ); + assert!( + required_platform_gaps( + ReleaseTarget::X86_64AppleDarwin, + &facts(&[], Some(&[]), false) + ) + .is_err(), + "should reject Apple evidence that states no gap" + ); +} + +#[test] +fn candidate_measurement_records_identity_without_gating() { + let gaps = vec!["example-gap".to_owned()]; + let canonical = CandidateDocument { + sha256: "a", + bytes: b"{}", + gaps: &gaps, + }; + let differing = CandidateDocument { + sha256: "b", + bytes: b"{ }", + gaps: &gaps, + }; + let identical = candidate_pair_measurement(&canonical, Some(&canonical)); + assert_eq!( + identical["byte_identical"], + json!(true), + "should record identical bytes" + ); + assert_eq!( + identical["canonical"], + json!({"sha256": "a", "size": 2, "gaps": ["example-gap"]}), + "should record digest, size and gaps" + ); + let differs = candidate_pair_measurement(&canonical, Some(&differing)); + assert_eq!( + differs["byte_identical"], + json!(false), + "should record differing bytes" + ); + assert_eq!( + differs["byte_identity_gate"], + json!(false), + "should not gate on byte identity" + ); + let alone = candidate_pair_measurement(&canonical, None); + assert_eq!( + ( + alone["reproduction"].clone(), + alone["byte_identical"].clone() + ), + (Value::Null, Value::Null), + "should record no comparison without a reproduction" + ); + assert_eq!( + alone["release_qualification"], + json!(false), + "should grant no qualification" + ); +} diff --git a/crates/trusted-server-release/src/spdx.rs b/crates/trusted-server-release/src/spdx.rs new file mode 100644 index 0000000000..8e123a8e6d --- /dev/null +++ b/crates/trusted-server-release/src/spdx.rs @@ -0,0 +1,560 @@ +//! Pure SPDX document rendering, with actual-build generation deliberately closed. +//! +//! Schema and graph validity do not establish complete composition coverage or +//! grant signing, publication or qualification authority. Complete owning-input +//! integration and full real-artifact inspection remain outstanding. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component as PathComponent, Path, PathBuf}; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{Role, canonical_json, lower_hex, validate_spdx}; +use crate::error::ReleaseError; +use crate::javascript::JsBuildCompleted; +use crate::materials::CargoOrigin; +use crate::policy::ReleaseTarget; +use crate::recipe::RecipeCompleted; + +pub(crate) mod candidate; +pub(crate) mod cargo; +pub(crate) mod javascript; +pub(crate) mod native; +pub(crate) mod platform; + +/// Validated bounded SPDX bytes, without a release-authorization capability. +#[derive(Debug)] +pub(crate) struct GeneratedSpdx { + bytes: Vec, + sha256: String, +} + +impl GeneratedSpdx { + /// The exact serialized SPDX document bytes. + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// SHA-256 of the exact document bytes, distinct from its executable root. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } +} + +/// Rejects actual builds until complete owning composition inputs are available. +/// +/// No current candidate receipt establishes complete native/tool coverage. This +/// closed adapter performs no complete-input validation and emits no document. +/// +/// # Errors +/// +/// Always returns an error for the missing complete owning-input integration. +pub(crate) fn generate_from_completed( + _completed: &RecipeCompleted, + _javascript: &JsBuildCompleted, +) -> Result> { + Err(spdx_error( + "complete owning composition input integration is unavailable; candidate native/tool coverage remains incomplete", + )) +} + +fn spdx_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Spdx).attach(detail.to_string()) +} + +// Only this module's tests construct positive inputs until the owning adapter +// can validate all required composition classes. No JSON constructor exists. +struct SpdxInputs<'a> { + executable: &'a [u8], + expected_artifact_sha256: String, + name: String, + version: String, + source_commit: String, + target: ReleaseTarget, + created: String, + // Rendered as `creationInfo.comment`; candidate documents state their gaps here. + document_comment: Option, + components: Vec, + dependencies: Vec, +} + +struct Component { + // The future owning adapter must derive this logical key from verified + // original source/package identities, excluding temporary checkout/vendor + // paths that can occur inside opaque Cargo IDs. + identity: String, + name: String, + version: Option, + roles: Vec, + origin: Option, + declared_license: Option, +} + +#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)] +enum ComponentRole { + ObservedRust, + RetainedJavascript, + ConservativeJavascript, + ObservedNative, + BuildMaterial, + BuildTool, + ExternalRuntime, +} + +enum Origin { + NativeVerified { + original: native::NativeOriginal, + }, + PlatformMeasuredFile { + role: String, + sha256: String, + }, + JavascriptVerified { + source_commit: String, + original: javascript::JavascriptOriginal, + producer: Option, + }, + CargoMaterial { + source: String, + original: CargoOrigin, + prepared_manifest_sha256: String, + }, + + Source { + commit: String, + path: String, + }, + Distribution { + location: String, + sha256: String, + }, + StructuredTree { + sha256: String, + }, + Generated { + producer: String, + sha256: String, + }, + RuntimeObservation { + artifact_sha256: String, + }, +} + +struct Dependency { + from: String, + to: String, + build: bool, +} + +const ROOT_ID: &str = "SPDXRef-Artifact"; +const DOCUMENT_ID: &str = "SPDXRef-DOCUMENT"; + +fn plain(value: &str) -> Result<(), Report> { + if value.is_empty() || value.len() > 8192 || value.chars().any(char::is_control) { + return Err(spdx_error("SPDX input is not bounded plain text")); + } + Ok(()) +} + +fn relative_source(path: &str) -> Result<(), Report> { + plain(path)?; + if path.contains('\\') + || Path::new(path) + .components() + .collect::() + .as_os_str() + != Path::new(path).as_os_str() + || Path::new(path) + .components() + .any(|component| !matches!(component, PathComponent::Normal(_))) + { + return Err(spdx_error("source origin path is not relative and normal")); + } + Ok(()) +} + +fn valid_created(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 20 + || [4, 7, 10, 13, 16, 19] + .into_iter() + .zip(b"--T::Z") + .any(|(index, expected)| bytes[index] != *expected) + || bytes + .iter() + .enumerate() + .any(|(index, byte)| ![4, 7, 10, 13, 16, 19].contains(&index) && !byte.is_ascii_digit()) + { + return false; + } + let number = |range| { + value + .get(range) + .and_then(|part: &str| part.parse::().ok()) + }; + let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = ( + number(0..4), + number(5..7), + number(8..10), + number(11..13), + number(14..16), + number(17..19), + ) else { + return false; + }; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + }; + year != 0 && day != 0 && day <= days && hour < 24 && minute < 60 && second < 60 +} + +fn component_id(identity: &str) -> String { + format!( + "SPDXRef-Component-{}", + hex::encode(Sha256::digest(identity.as_bytes())) + ) +} + +fn license(declaration: Option<&str>) -> Result<(&'static str, String), Report> { + let Some(declaration) = declaration else { + return Ok(( + "NOASSERTION", + "No declared license information or license conclusion is asserted.".to_owned(), + )); + }; + plain(declaration)?; + let known = match declaration { + "MIT" => "MIT", + "Apache-2.0" => "Apache-2.0", + "MIT OR Apache-2.0" => "MIT OR Apache-2.0", + "Apache-2.0 OR MIT" => "Apache-2.0 OR MIT", + _ => { + return Ok(( + "NOASSERTION", + format!( + "Unrecognized observed declaration: {declaration}. No license conclusion is asserted." + ), + )); + } + }; + Ok(( + known, + "Exact reviewed declaration retained; no license conclusion is asserted.".to_owned(), + )) +} + +type OriginFields = (String, String, Option<(&'static str, String)>); + +fn origin_fields( + component: &Component, + inputs: &SpdxInputs<'_>, +) -> Result> { + let origin = component + .origin + .as_ref() + .ok_or_else(|| spdx_error("component has no contributing origin"))?; + if component.roles.contains(&ComponentRole::ExternalRuntime) + != matches!(origin, Origin::RuntimeObservation { .. }) + || component.roles.contains(&ComponentRole::ExternalRuntime) && component.roles.len() != 1 + { + return Err(spdx_error( + "runtime requirements and contributing origins have incompatible roles", + )); + } + let unknown_location = "NOASSERTION".to_owned(); + match origin { + Origin::NativeVerified { original } => native::origin_fields(component, original), + Origin::PlatformMeasuredFile { role, sha256 } => { + platform::file_origin_fields(component, role, sha256) + } + Origin::JavascriptVerified { + source_commit, + original, + producer, + } => javascript::origin_fields( + component, + inputs, + source_commit, + original, + producer.as_deref(), + ), + Origin::CargoMaterial { + source, + original, + prepared_manifest_sha256, + } => { + cargo::validate_material(source, original)?; + if !lower_hex(prepared_manifest_sha256, 64) { + return Err(spdx_error("prepared Cargo manifest SHA-256 is invalid")); + } + let facts = serde_json::to_string(&json!({"locked_source":source,"original":original,"prepared_manifest_sha256":prepared_manifest_sha256})).map_err(spdx_error)?; + plain(&facts)?; + let checksum = match original { + CargoOrigin::Registry { archive_sha256 } => { + Some(("SHA256", archive_sha256.clone())) + } + CargoOrigin::Git { .. } => None, + }; + let source_info = format!( + "Verified original Cargo facts {facts}; prepared manifest and Git object identities are provenance evidence, not raw package checksums." + ); + plain(&source_info)?; + Ok((unknown_location, source_info, checksum)) + } + + Origin::Source { commit, path } => { + if !lower_hex(commit, 40) || commit != &inputs.source_commit { + return Err(spdx_error( + "workspace source origin differs from the selected source", + )); + } + relative_source(path)?; + Ok(( + unknown_location, + format!("Approved source commit {commit}; source-relative path {path}."), + None, + )) + } + Origin::Distribution { location, sha256 } => { + plain(location)?; + if !location.starts_with("https://") || !lower_hex(sha256, 64) { + return Err(spdx_error( + "distribution has no bounded origin and raw archive digest", + )); + } + Ok((location.clone(),"Checksum identifies the original distribution archive, not the final executable or individual surviving functions.".to_owned(),Some(("SHA256", sha256.clone())))) + } + Origin::StructuredTree { sha256 } => { + if !lower_hex(sha256, 64) + || component.roles.iter().any(|role| { + !matches!( + role, + ComponentRole::BuildMaterial | ComponentRole::BuildTool + ) + }) + { + return Err(spdx_error( + "structured tree evidence cannot replace a contributing original", + )); + } + Ok(( + unknown_location, + format!( + "Structured installed-tree evidence SHA-256 {sha256}; this is not a raw-file checksum or original acquisition provenance." + ), + None, + )) + } + Origin::Generated { producer, sha256 } => { + if !lower_hex(sha256, 64) + || !inputs.components.iter().any(|candidate| { + candidate.identity == *producer + && candidate.identity != component.identity + && candidate.roles.contains(&ComponentRole::BuildTool) + }) + { + return Err(spdx_error( + "generated contributor has no known build-tool producer or byte digest", + )); + } + Ok(( + unknown_location, + format!( + "Observed generated module bytes before later transformations; producer identity {producer}." + ), + Some(("SHA256", sha256.clone())), + )) + } + Origin::RuntimeObservation { artifact_sha256 } => { + if artifact_sha256 != &inputs.expected_artifact_sha256 { + return Err(spdx_error( + "runtime requirement belongs to another executable", + )); + } + Ok((unknown_location,"Logical requirement observed in accepted executable bytes; installed runtime identity and bytes are not asserted.".to_owned(),None)) + } + } +} + +fn component_package( + component: &Component, + inputs: &SpdxInputs<'_>, +) -> Result> { + plain(&component.name)?; + let (declared, license_comment) = license(component.declared_license.as_deref())?; + let (download, source_info, checksum) = origin_fields(component, inputs)?; + let mut value = json!({"SPDXID":component_id(&component.identity),"name":component.name,"downloadLocation":download,"filesAnalyzed":false,"licenseDeclared":declared,"licenseConcluded":"NOASSERTION","licenseComments":license_comment,"copyrightText":"NOASSERTION","sourceInfo":source_info}); + if let Some(version) = &component.version { + plain(version)?; + value["versionInfo"] = json!(version); + } + if let Some((algorithm, digest)) = checksum { + value["checksums"] = json!([{"algorithm":algorithm,"checksumValue":digest}]); + } + Ok(value) +} + +fn render(inputs: &SpdxInputs<'_>) -> Result> { + plain(&inputs.name)?; + plain(&inputs.version)?; + if inputs.executable.is_empty() + || inputs.executable.len() as u64 > Role::Executable.limit() + || !lower_hex(&inputs.expected_artifact_sha256, 64) + || hex::encode(Sha256::digest(inputs.executable)) != inputs.expected_artifact_sha256 + || !lower_hex(&inputs.source_commit, 40) + || !valid_created(&inputs.created) + || inputs.components.len() > 16_384 + || inputs.dependencies.len() > 65_536 + { + return Err(spdx_error( + "SPDX root identity, timestamp or input bounds are invalid", + )); + } + let mut components = BTreeMap::new(); + let mut identifiers = BTreeSet::from([ROOT_ID.to_owned(), DOCUMENT_ID.to_owned()]); + for component in &inputs.components { + plain(&component.identity)?; + if component.roles.is_empty() + || component.roles.iter().collect::>().len() != component.roles.len() + { + return Err(spdx_error("component roles must be nonempty and unique")); + } + if components + .insert(component.identity.as_str(), component) + .is_some() + || !identifiers.insert(component_id(&component.identity)) + { + return Err(spdx_error( + "duplicate component identity or SPDX identifier", + )); + } + } + let mut packages = vec![ + json!({"SPDXID":ROOT_ID,"name":inputs.name,"versionInfo":inputs.version,"downloadLocation":"NOASSERTION","filesAnalyzed":false,"licenseDeclared":"NOASSERTION","licenseConcluded":"NOASSERTION","copyrightText":"NOASSERTION","checksums":[{"algorithm":"SHA256","checksumValue":inputs.expected_artifact_sha256}],"sourceInfo":format!("Final executable for {}; approved source commit {}.",inputs.target.triple(),inputs.source_commit)}), + ]; + let mut relationships = BTreeSet::new(); + relationships.insert(( + DOCUMENT_ID.to_owned(), + "DESCRIBES", + ROOT_ID.to_owned(), + "Selected final executable.", + )); + for component in components.values() { + packages.push(component_package(component, inputs)?); + let id = component_id(&component.identity); + for role in &component.roles { + let (from, kind, to, comment) = match role { + ComponentRole::ObservedRust | ComponentRole::ObservedNative => ( + ROOT_ID.to_owned(), + "GENERATED_FROM", + id.clone(), + "Conservative observed compilation input; final function reachability is not asserted.", + ), + ComponentRole::RetainedJavascript => ( + ROOT_ID.to_owned(), + "CONTAINS", + id.clone(), + "Complete module bytes observed in final executable; function reachability is not asserted.", + ), + ComponentRole::ConservativeJavascript => ( + ROOT_ID.to_owned(), + "GENERATED_FROM", + id.clone(), + "Conservative post-Rollup rendered input before esbuild minification; final function reachability is not asserted.", + ), + ComponentRole::BuildMaterial => ( + id.clone(), + "BUILD_DEPENDENCY_OF", + ROOT_ID.to_owned(), + "Prepared build input; final executable retention is not asserted.", + ), + ComponentRole::BuildTool => ( + id.clone(), + "BUILD_TOOL_OF", + ROOT_ID.to_owned(), + "Build tool input; executable embedding and original acquisition are not asserted.", + ), + ComponentRole::ExternalRuntime => ( + id.clone(), + "RUNTIME_DEPENDENCY_OF", + ROOT_ID.to_owned(), + "Logical external runtime requirement; runtime bytes are not embedded component evidence.", + ), + }; + relationships.insert((from, kind, to, comment)); + } + if let Some( + Origin::Generated { producer, .. } + | Origin::JavascriptVerified { + producer: Some(producer), + .. + }, + ) = &component.origin + { + relationships.insert(( + component_id(producer), + "GENERATES", + component_id(&component.identity), + "Observed producer of generated module bytes before later transformations.", + )); + } + } + let mut dependency_keys = BTreeSet::new(); + for dependency in &inputs.dependencies { + if !components.contains_key(dependency.from.as_str()) + || !components.contains_key(dependency.to.as_str()) + || dependency.from == dependency.to + || !dependency_keys.insert((&dependency.from, &dependency.to, dependency.build)) + { + return Err(spdx_error( + "dependency is duplicated, self-referential or dangling", + )); + } + let (from, kind, to) = if dependency.build { + ( + component_id(&dependency.to), + "BUILD_DEPENDENCY_OF", + component_id(&dependency.from), + ) + } else { + ( + component_id(&dependency.from), + "DEPENDS_ON", + component_id(&dependency.to), + ) + }; + relationships.insert(( + from, + kind, + to, + "Potential Cargo package relation between observed endpoints; exact compiler unit dependency DAG is not asserted.", + )); + } + let relationships:Vec<_> = relationships.into_iter().map(|(from,kind,to,comment)| json!({"spdxElementId":from,"relationshipType":kind,"relatedSpdxElement":to,"comment":comment})).collect(); + let mut document = json!({"SPDXID":DOCUMENT_ID,"spdxVersion":"SPDX-2.3","dataLicense":"CC0-1.0","name":format!("{} {} composition",inputs.name,inputs.version),"creationInfo":{"created":inputs.created,"creators":[format!("Tool: trusted-server-release-{}",env!("CARGO_PKG_VERSION"))]},"documentDescribes":[ROOT_ID],"packages":packages,"relationships":relationships}); + if let Some(comment) = &inputs.document_comment { + plain(comment)?; + document["creationInfo"]["comment"] = json!(comment); + } + let semantic_bytes = canonical_json(&document, Role::Spdx) + .map_err(|error| error.change_context(ReleaseError::Spdx))?; + document["documentNamespace"] = json!(format!( + "https://spdx.org/spdxdocs/trusted-server-{}", + hex::encode(Sha256::digest(&semantic_bytes)) + )); + validate_spdx(&document).map_err(|error| error.change_context(ReleaseError::Spdx))?; + let bytes = canonical_json(&document, Role::Spdx) + .map_err(|error| error.change_context(ReleaseError::Spdx))?; + let sha256 = hex::encode(Sha256::digest(&bytes)); + Ok(GeneratedSpdx { bytes, sha256 }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/spdx/candidate.rs b/crates/trusted-server-release/src/spdx/candidate.rs new file mode 100644 index 0000000000..f8c195c51e --- /dev/null +++ b/crates/trusted-server-release/src/spdx/candidate.rs @@ -0,0 +1,331 @@ +//! Unqualified candidate whole-document SPDX that states its coverage gaps. +//! +//! The closed [`super::generate_from_completed`] path stays closed: a +//! [`CandidateSpdx`] is never converted into a [`super::GeneratedSpdx`]. Its +//! creation comment names the document a candidate and lists every recorded +//! coverage gap, so it grants no signing, publication or qualification +//! authority. + +use std::collections::BTreeSet; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::cargo::CargoProjection; +use super::javascript::JavascriptProjection; +use super::native::NativeProjection; +use super::platform::PlatformProjection; +use super::{Component, Dependency, SpdxInputs, lower_hex, plain, render, spdx_error}; +use crate::composition::CompositionView; +use crate::composition::origin::Original; +use crate::documents::Role; +use crate::error::ReleaseError; +use crate::recipe::RecipeCompleted; +use crate::source::ApprovedSource; + +#[cfg(test)] +mod tests; + +const SECONDS_PER_DAY: u64 = 86_400; +const LAST_FOUR_DIGIT_YEAR: u64 = 9_999; +const MAX_ROOT_VERSION_LENGTH: usize = 128; + +/// Validated bounded candidate SPDX bytes with their explicit coverage gaps. +/// +/// This type has no conversion into [`super::GeneratedSpdx`] and carries no +/// release-authorization capability. +#[derive(Debug)] +pub(crate) struct CandidateSpdx { + bytes: Vec, + sha256: String, + gaps: Vec, +} + +impl CandidateSpdx { + /// The exact serialized candidate SPDX document bytes. + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// SHA-256 of the exact document bytes, distinct from its executable root. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Sorted, unique coverage gaps stated in the document's creation comment. + pub(crate) fn gaps(&self) -> &[String] { + &self.gaps + } +} + +/// Renders a gap-bearing candidate document for one completed build. +/// +/// All four projections must come from the same sealed composition receipt, +/// and the root package must be the policy asset's workspace package at the +/// approved commit. +/// +/// # Errors +/// +/// Rejects another approved source, missing or changed composition receipts, +/// a platform projection over another receipt, an unverified root package, +/// overlapping or dangling projected parts, an empty required part, a +/// gap-free candidate, an unrepresentable creation time, changed final bytes +/// or a document beyond the SPDX size limit. +pub(crate) fn candidate_from_completed( + completed: &RecipeCompleted, + source: &ApprovedSource, +) -> Result> { + if source.commit() != completed.commit() { + return Err(spdx_error( + "approved source differs from the completed build commit", + )); + } + let created = spdx_created(source.epoch())?; + let view = completed.composition_view()?; + let rust = CargoProjection::from_completed(completed)?; + let native = NativeProjection::from_completed(completed)?; + let javascript = JavascriptProjection::from_completed(completed)?; + let platform = PlatformProjection::from_completed(completed)?; + if platform.receipt_sha256() != view.receipt_sha256() { + return Err(spdx_error( + "platform projection belongs to another composition receipt", + )); + } + let version = root_version(completed, &view)?; + let gaps = platform.coverage_gaps(); + let (components, dependencies) = merge_parts(CandidateParts { + rust: rust.into_parts(), + native: native.into_parts(), + javascript: javascript.into_parts(), + platform: platform.into_parts(), + })?; + let executable = completed.read_final_bytes()?; + let artifact = completed.asset().artifact(); + let inputs = SpdxInputs { + executable: &executable, + expected_artifact_sha256: completed.artifact().sha256().to_owned(), + name: artifact.name.to_owned(), + version, + source_commit: completed.commit().as_str().to_owned(), + target: artifact.target, + created, + document_comment: None, + components, + dependencies, + }; + render_candidate(inputs, gaps) +} + +/// Formats `SOURCE_DATE_EPOCH` as an SPDX `created` UTC timestamp. +/// +/// Uses proleptic Gregorian civil-date arithmetic for days since 1970-01-01. +fn spdx_created(epoch: u64) -> Result> { + let days = epoch / SECONDS_PER_DAY; + let seconds_of_day = epoch % SECONDS_PER_DAY; + let shifted = days + .checked_add(719_468) + .ok_or_else(|| spdx_error("source epoch exceeds the SPDX timestamp range"))?; + let era = shifted / 146_097; + let day_of_era = shifted % 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + }; + let year = era * 400 + year_of_era + u64::from(month <= 2); + if year > LAST_FOUR_DIGIT_YEAR { + return Err(spdx_error( + "source epoch is beyond the four-digit SPDX year range", + )); + } + Ok(format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + seconds_of_day / 3_600, + seconds_of_day % 3_600 / 60, + seconds_of_day % 60 + )) +} + +/// Pure facts joining the policy asset package to its verified original. +struct RootIdentityInputs<'a> { + asset_package: &'a str, + observed_name: &'a str, + observed_version: &'a str, + origin_name: &'a str, + origin_version: &'a str, + original: &'a Original, + source_commit: &'a str, +} + +/// Returns the root version when the asset package is the approved workspace package. +fn checked_root_version(inputs: &RootIdentityInputs<'_>) -> Result> { + plain(inputs.asset_package)?; + if inputs.asset_package.contains(['/', '\\']) + || !lower_hex(inputs.source_commit, 40) + || inputs.observed_name != inputs.asset_package + || inputs.origin_name != inputs.asset_package + || inputs.origin_version != inputs.observed_version + || inputs.observed_version.is_empty() + || inputs.observed_version.len() > MAX_ROOT_VERSION_LENGTH + || inputs + .observed_version + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(spdx_error( + "candidate root is not the verified policy asset package version", + )); + } + let expected_manifest = format!("crates/{}/Cargo.toml", inputs.asset_package); + match inputs.original { + Original::Workspace { + source_commit, + path, + } if source_commit == inputs.source_commit + && path.to_str() == Some(expected_manifest.as_str()) => + { + Ok(inputs.observed_version.to_owned()) + } + _ => Err(spdx_error( + "candidate root has no matching approved workspace origin", + )), + } +} + +/// Joins the selected Cargo package to the policy asset through its verified original. +fn root_version( + completed: &RecipeCompleted, + view: &CompositionView<'_>, +) -> Result> { + 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(|| spdx_error("candidate has no selected root package"))?; + if packages.next().is_some() { + return Err(spdx_error("candidate selected root package is ambiguous")); + } + let origin = view + .package_origins() + .package(id) + .ok_or_else(|| spdx_error("candidate selected root package has no verified origin"))?; + checked_root_version(&RootIdentityInputs { + asset_package: completed.asset().artifact().package, + observed_name: package.name(), + observed_version: package.version(), + origin_name: origin.name(), + origin_version: origin.version(), + original: origin.origin(), + source_commit: completed.commit().as_str(), + }) +} + +type ProjectedPart = (Vec, Vec); + +/// The four adapter projections of one sealed composition receipt. +struct CandidateParts { + rust: ProjectedPart, + native: ProjectedPart, + javascript: ProjectedPart, + platform: ProjectedPart, +} + +/// Concatenates disjoint parts whose edges stay inside their own part. +/// +/// Native may be empty (an artifact can have no native contributors); Rust, +/// JavaScript and platform parts may not. +fn merge_parts(parts: CandidateParts) -> Result> { + let CandidateParts { + rust, + native, + javascript, + platform, + } = parts; + let mut identities = BTreeSet::new(); + let mut components = Vec::new(); + let mut dependencies = Vec::new(); + for (label, required, (part_components, part_dependencies)) in [ + ("Rust", true, rust), + ("native", false, native), + ("JavaScript", true, javascript), + ("platform", true, platform), + ] { + if required && part_components.is_empty() { + return Err(spdx_error(format!("candidate {label} projection is empty"))); + } + let local = part_components + .iter() + .map(|component| component.identity.as_str()) + .collect::>(); + if local.len() != part_components.len() + || part_dependencies.iter().any(|dependency| { + !local.contains(dependency.from.as_str()) || !local.contains(dependency.to.as_str()) + }) + { + return Err(spdx_error(format!( + "candidate {label} projection repeats an identity or has a dangling edge" + ))); + } + for component in &part_components { + if !identities.insert(component.identity.clone()) { + return Err(spdx_error(format!( + "candidate {label} projection repeats another adapter's identity" + ))); + } + } + components.extend(part_components); + dependencies.extend(part_dependencies); + } + Ok((components, dependencies)) +} + +/// Renders validated inputs with a creation comment naming every gap. +fn render_candidate( + mut inputs: SpdxInputs<'_>, + gaps: Vec, +) -> Result> { + let gaps = gaps + .into_iter() + .collect::>() + .into_iter() + .collect::>(); + if gaps.is_empty() { + return Err(spdx_error( + "candidate SPDX requires at least one stated coverage gap", + )); + } + for gap in &gaps { + plain(gap)?; + } + inputs.document_comment = Some(format!( + "Unqualified candidate composition document; not a complete SBOM and grants no signing, publication or qualification authority. Known coverage gaps: {}.", + gaps.join(", ") + )); + let generated = render(&inputs)?; + bounded_candidate(generated.bytes, gaps) +} + +/// Wraps rendered bytes after enforcing the SPDX document size limit. +fn bounded_candidate( + bytes: Vec, + gaps: Vec, +) -> Result> { + if bytes.is_empty() || bytes.len() as u64 > Role::Spdx.limit() { + return Err(spdx_error("candidate SPDX bytes exceed the document limit")); + } + let sha256 = hex::encode(Sha256::digest(&bytes)); + Ok(CandidateSpdx { + bytes, + sha256, + gaps, + }) +} diff --git a/crates/trusted-server-release/src/spdx/candidate/tests.rs b/crates/trusted-server-release/src/spdx/candidate/tests.rs new file mode 100644 index 0000000000..53aff3cd90 --- /dev/null +++ b/crates/trusted-server-release/src/spdx/candidate/tests.rs @@ -0,0 +1,403 @@ +use std::path::PathBuf; + +use serde_json::{Value, json}; +use sha2::Sha256; + +use super::*; +use crate::spdx::{ComponentRole, Origin, valid_created}; + +const EXECUTABLE: &[u8] = b"fictional candidate executable bytes"; +const COMMIT: &str = "1111111111111111111111111111111111111111"; +const ROOT_PACKAGE: &str = "example-adapter"; + +// ---- Step 1: SPDX creation timestamps from SOURCE_DATE_EPOCH ---- + +#[test] +fn spdx_created_renders_the_unix_epoch_start() { + let created = spdx_created(0).expect("should render the Unix epoch start"); + assert_eq!( + created, "1970-01-01T00:00:00Z", + "should render epoch zero as midnight on 1970-01-01" + ); + assert!( + valid_created(&created), + "should satisfy the renderer timestamp check" + ); +} + +#[test] +fn spdx_created_skips_february_29_in_a_non_leap_century() { + assert_eq!( + spdx_created(4_107_456_000).expect("should render the last February day of 2100"), + "2100-02-28T00:00:00Z", + "should keep 2100-02-28" + ); + assert_eq!( + spdx_created(4_107_542_400).expect("should render the following day"), + "2100-03-01T00:00:00Z", + "should honour the 100-year non-leap rule" + ); +} + +#[test] +fn spdx_created_renders_a_leap_day() { + let created = spdx_created(951_782_400).expect("should render a leap day"); + assert_eq!( + created, "2000-02-29T00:00:00Z", + "should honour the 400-year leap rule" + ); + assert!( + valid_created(&created), + "should satisfy the renderer timestamp check" + ); +} + +#[test] +fn spdx_created_renders_time_of_day_and_the_last_four_digit_second() { + let afternoon = spdx_created(1_700_000_000).expect("should render a time of day"); + assert_eq!( + afternoon, "2023-11-14T22:13:20Z", + "should render hours, minutes and seconds" + ); + let last = spdx_created(253_402_300_799).expect("should render the last four-digit second"); + assert_eq!( + last, "9999-12-31T23:59:59Z", + "should render the final representable second" + ); + for created in [afternoon, last] { + assert!( + valid_created(&created), + "should satisfy the renderer timestamp check" + ); + } +} + +#[test] +fn spdx_created_rejects_years_beyond_four_digits() { + for epoch in [253_402_300_800, u64::MAX] { + assert!( + spdx_created(epoch).is_err(), + "should reject epoch {epoch} past year 9999" + ); + } +} + +// ---- Step 2: root identity from the policy asset package ---- + +fn workspace_original(path: &str) -> Original { + Original::Workspace { + source_commit: COMMIT.to_owned(), + path: PathBuf::from(path), + } +} + +fn root_inputs(original: &Original) -> RootIdentityInputs<'_> { + RootIdentityInputs { + asset_package: ROOT_PACKAGE, + observed_name: ROOT_PACKAGE, + observed_version: "1.2.3", + origin_name: ROOT_PACKAGE, + origin_version: "1.2.3", + original, + source_commit: COMMIT, + } +} + +#[test] +fn root_identity_accepts_the_asset_package_workspace_origin() { + let original = workspace_original("crates/example-adapter/Cargo.toml"); + let version = checked_root_version(&root_inputs(&original)) + .expect("should accept the asset package's workspace origin"); + assert_eq!(version, "1.2.3", "should return the verified version"); +} + +#[test] +fn root_identity_rejects_a_registry_origin() { + let original = Original::Material { + source: "registry+https://example.com/index".to_owned(), + original: crate::materials::CargoOrigin::Registry { + archive_sha256: "a".repeat(64), + }, + }; + assert!( + checked_root_version(&root_inputs(&original)).is_err(), + "should reject a root package that resolves to a registry original" + ); +} + +#[test] +fn root_identity_rejects_another_manifest_path_or_commit() { + for path in [ + "crates/another-adapter/Cargo.toml", + "crates/example-adapter/sub/Cargo.toml", + "Cargo.toml", + ] { + let original = workspace_original(path); + assert!( + checked_root_version(&root_inputs(&original)).is_err(), + "should reject workspace manifest {path}" + ); + } + let original = Original::Workspace { + source_commit: "2".repeat(40), + path: PathBuf::from("crates/example-adapter/Cargo.toml"), + }; + assert!( + checked_root_version(&root_inputs(&original)).is_err(), + "should reject a workspace origin at another commit" + ); +} + +#[test] +fn root_identity_rejects_name_or_version_mismatches() { + let original = workspace_original("crates/example-adapter/Cargo.toml"); + let mut version_mismatch = root_inputs(&original); + version_mismatch.origin_version = "1.2.4"; + let mut name_mismatch = root_inputs(&original); + name_mismatch.observed_name = "another-adapter"; + let mut origin_name_mismatch = root_inputs(&original); + origin_name_mismatch.origin_name = "another-adapter"; + let mut empty_version = root_inputs(&original); + empty_version.observed_version = ""; + empty_version.origin_version = ""; + for (label, inputs) in [ + ("version", version_mismatch), + ("observed name", name_mismatch), + ("origin name", origin_name_mismatch), + ("empty version", empty_version), + ] { + assert!( + checked_root_version(&inputs).is_err(), + "should reject a root {label} mismatch" + ); + } +} + +// ---- Step 3: merging the four projected parts ---- + +fn source_component(identity: &str, role: ComponentRole) -> Component { + Component { + identity: identity.to_owned(), + name: identity.to_owned(), + version: Some("1.0.0".to_owned()), + roles: vec![role], + origin: Some(Origin::Source { + commit: COMMIT.to_owned(), + path: "crates/example-adapter/Cargo.toml".to_owned(), + }), + declared_license: None, + } +} + +fn tool_component(identity: &str) -> Component { + Component { + identity: identity.to_owned(), + name: identity.to_owned(), + version: None, + roles: vec![ComponentRole::BuildTool], + origin: Some(Origin::StructuredTree { + sha256: "d".repeat(64), + }), + declared_license: None, + } +} + +fn edge(from: &str, to: &str) -> Dependency { + Dependency { + from: from.to_owned(), + to: to.to_owned(), + build: false, + } +} + +fn parts() -> CandidateParts { + CandidateParts { + rust: ( + vec![ + source_component("rust:root", ComponentRole::ObservedRust), + source_component("rust:dependency", ComponentRole::ObservedRust), + ], + vec![edge("rust:root", "rust:dependency")], + ), + native: (Vec::new(), Vec::new()), + javascript: ( + vec![source_component( + "js:core", + ComponentRole::RetainedJavascript, + )], + Vec::new(), + ), + platform: (vec![tool_component("platform:rust-tree")], Vec::new()), + } +} + +#[test] +fn merge_counts_equal_the_sum_of_the_parts() { + let (components, dependencies) = merge_parts(parts()).expect("should merge disjoint parts"); + assert_eq!(components.len(), 4, "should keep every projected component"); + assert_eq!(dependencies.len(), 1, "should keep every projected edge"); +} + +#[test] +fn merge_rejects_a_cross_adapter_duplicate_identity() { + let mut duplicated = parts(); + duplicated + .platform + .0 + .push(source_component("js:core", ComponentRole::BuildMaterial)); + assert!( + merge_parts(duplicated).is_err(), + "should reject one identity projected by two adapters" + ); +} + +#[test] +fn merge_rejects_edges_that_dangle_or_cross_parts() { + let mut dangling = parts(); + dangling.rust.1.push(edge("rust:root", "rust:absent")); + assert!( + merge_parts(dangling).is_err(), + "should reject an edge to an absent component" + ); + let mut crossing = parts(); + crossing.rust.1.push(edge("rust:root", "js:core")); + assert!( + merge_parts(crossing).is_err(), + "should reject an edge whose endpoint belongs to another part" + ); +} + +#[test] +fn merge_rejects_an_empty_required_part() { + for label in ["rust", "javascript", "platform"] { + let mut missing = parts(); + match label { + "rust" => missing.rust = (Vec::new(), Vec::new()), + "javascript" => missing.javascript = (Vec::new(), Vec::new()), + "platform" => missing.platform = (Vec::new(), Vec::new()), + _ => unreachable!("should select a required part"), + } + assert!( + merge_parts(missing).is_err(), + "should reject an empty {label} part" + ); + } +} + +// ---- Step 4: gap-bearing candidate rendering ---- + +fn candidate_inputs() -> SpdxInputs<'static> { + let (components, dependencies) = merge_parts(parts()).expect("should merge fixture parts"); + SpdxInputs { + executable: EXECUTABLE, + expected_artifact_sha256: hex::encode(Sha256::digest(EXECUTABLE)), + name: "example-adapter.wasm".to_owned(), + version: "1.2.3".to_owned(), + source_commit: COMMIT.to_owned(), + target: crate::policy::ReleaseTarget::Wasm32Wasip1, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies, + } +} + +fn gaps(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() +} + +#[test] +fn candidate_rendering_rejects_an_empty_gap_list() { + assert!( + render_candidate(candidate_inputs(), Vec::new()).is_err(), + "should never present a gap-free candidate as complete" + ); +} + +#[test] +fn candidate_rendering_rejects_an_unplain_gap() { + for gap in ["", "line\nbreak"] { + assert!( + render_candidate(candidate_inputs(), gaps(&[gap])).is_err(), + "should reject gap {gap:?}" + ); + } +} + +#[test] +fn candidate_comment_names_the_candidate_and_lists_sorted_unique_gaps() { + let candidate = render_candidate( + candidate_inputs(), + gaps(&["zeta-gap", "alpha-gap", "zeta-gap"]), + ) + .expect("should render a gap-bearing candidate"); + assert_eq!( + candidate.gaps(), + ["alpha-gap", "zeta-gap"], + "should retain sorted unique gaps" + ); + let document: Value = + serde_json::from_slice(candidate.bytes()).expect("should parse candidate bytes"); + let comment = document["creationInfo"]["comment"] + .as_str() + .expect("should carry a creation comment"); + assert!( + comment.contains("candidate"), + "should name the document a candidate" + ); + let alpha = comment + .find("alpha-gap") + .expect("should list the first gap"); + let zeta = comment + .find("zeta-gap") + .expect("should list the second gap"); + assert!(alpha < zeta, "should list gaps in sorted order"); + assert_eq!( + comment.matches("zeta-gap").count(), + 1, + "should list each gap once" + ); +} + +#[test] +fn candidate_document_validates_and_binds_root_and_document_digests() { + let inputs = candidate_inputs(); + let artifact_sha256 = inputs.expected_artifact_sha256.clone(); + let candidate = render_candidate(inputs, gaps(&["native-material-coverage-incomplete"])) + .expect("should render a gap-bearing candidate"); + let document: Value = + serde_json::from_slice(candidate.bytes()).expect("should parse candidate bytes"); + crate::documents::validate_spdx(&document).expect("should satisfy the pinned SPDX schema"); + let root = document["packages"] + .as_array() + .expect("should contain packages") + .iter() + .find(|package| package["SPDXID"] == "SPDXRef-Artifact") + .expect("should contain the executable root"); + assert_eq!( + root["checksums"], + json!([{"algorithm":"SHA256","checksumValue":artifact_sha256}]), + "should bind the root to the artifact SHA-256" + ); + assert_eq!( + candidate.sha256(), + hex::encode(Sha256::digest(candidate.bytes())), + "should hash the exact document bytes" + ); +} + +#[test] +fn candidate_bounds_reject_documents_over_sixteen_mebibytes() { + let limit = usize::try_from(Role::Spdx.limit()).expect("should fit the SPDX limit in usize"); + assert!( + bounded_candidate(vec![b' '; limit + 1], gaps(&["example-gap"])).is_err(), + "should reject a document past the SPDX limit" + ); + let bounded = bounded_candidate(b"{}\n".to_vec(), gaps(&["example-gap"])) + .expect("should accept a small document"); + assert_eq!( + bounded.sha256(), + hex::encode(Sha256::digest(b"{}\n")), + "should hash accepted bytes" + ); +} diff --git a/crates/trusted-server-release/src/spdx/cargo.rs b/crates/trusted-server-release/src/spdx/cargo.rs new file mode 100644 index 0000000000..12d1adcceb --- /dev/null +++ b/crates/trusted-server-release/src/spdx/cargo.rs @@ -0,0 +1,412 @@ +//! Conservative projection of verified observed Cargo packages. +//! +//! This adapter preserves borrowed sealed unit observations separately from +//! package roles and potential package relationships. It grants no complete +//! composition, qualification, signing or publication authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde_json::json; + +use crate::composition::graph::GraphEvidence; +use crate::composition::origin::{Original, VerifiedPackageOrigins}; +use crate::composition::{CargoObservations, CompositionView, RustPackage, UnitContext}; +use crate::error::ReleaseError; +use crate::materials::{CargoOrigin, GitSourceConfig}; +use crate::recipe::RecipeCompleted; + +use super::{ + Component, ComponentRole, Dependency, Origin, lower_hex, plain, relative_source, spdx_error, +}; + +/// Renderer-ready observed Rust packages, retaining their sealed unit evidence. +/// +/// Native/tool completeness and complete SPDX generation remain unavailable. +pub(crate) struct CargoProjection<'a> { + view: CompositionView<'a>, + components: Vec, + dependencies: Vec, +} + +impl<'a> CargoProjection<'a> { + /// Projects exact observed package IDs after checking the completed owner. + /// + /// # Errors + /// + /// Rejects changed receipts, mismatched source/material identities, missing + /// origins, ambiguous package identities and unsupported compiler contexts. + pub(crate) fn from_completed( + completed: &'a RecipeCompleted, + ) -> Result> { + let view = completed.composition_view()?; + let origins = view.package_origins(); + if origins.source_commit() != completed.commit().as_str() + || !lower_hex(origins.source_commit(), 40) + || origins.materials_sha256() != completed.materials_digest() + || !lower_hex(origins.materials_sha256(), 64) + { + return Err(spdx_error( + "Cargo originals belong to another source or material tree", + )); + } + let (components, dependencies) = project(view.observations(), view.graph(), origins)?; + Ok(Self { + view, + components, + dependencies, + }) + } + + /// Returns the number of verified observed package components. + pub(crate) fn component_count(&self) -> usize { + self.components.len() + } + + /// Returns borrowed sealed units with their original contexts and features. + pub(crate) fn observations(&self) -> &CargoObservations { + self.view.observations() + } + + /// Releases the renderer components and potential package relations. + pub(super) fn into_parts(self) -> (Vec, Vec) { + (self.components, self.dependencies) + } +} + +fn project( + observed: &CargoObservations, + graph: &GraphEvidence, + origins: &VerifiedPackageOrigins, +) -> Result<(Vec, Vec), Report> { + let mut identities = BTreeMap::new(); + let mut stable = BTreeSet::new(); + let mut components = Vec::new(); + let observed_ids = observed + .packages() + .iter() + .map(RustPackage::id) + .collect::>(); + if observed_ids.len() != observed.packages().len() + || observed_ids + != graph + .observed_package_ids() + .iter() + .map(String::as_str) + .collect() + || !observed_ids.contains(graph.selected_package_id()) + || observed + .units() + .iter() + .any(|unit| !observed_ids.contains(unit.package_id())) + { + return Err(spdx_error( + "Cargo package, unit and graph observations disagree", + )); + } + for package in observed.packages() { + let verified = origins + .package(package.id()) + .ok_or_else(|| spdx_error("observed Cargo ID has no verified original"))?; + if package.name() != verified.name() + || package.version() != verified.version() + || !lower_hex(verified.prepared_manifest_sha256(), 64) + { + return Err(spdx_error( + "observed package facts conflict with verified original", + )); + } + let (identity, origin) = match verified.origin() { + Original::Workspace { + source_commit, + path, + } => { + let path = path + .to_str() + .ok_or_else(|| spdx_error("tracked manifest is not UTF-8"))?; + if source_commit != origins.source_commit() + || verified.relative_manifest_path() != std::path::Path::new(path) + { + return Err(spdx_error( + "workspace original conflicts with selected source", + )); + } + ( + workspace_identity(source_commit, path, package.name(), package.version())?, + Origin::Source { + commit: source_commit.clone(), + path: path.to_owned(), + }, + ) + } + Original::Material { source, original } => ( + material_identity(source, original, package.name(), package.version())?, + Origin::CargoMaterial { + source: source.clone(), + original: original.clone(), + prepared_manifest_sha256: verified.prepared_manifest_sha256().to_owned(), + }, + ), + }; + if !stable.insert(identity.clone()) + || identities.insert(package.id(), identity.clone()).is_some() + { + return Err(spdx_error( + "distinct observed Cargo aliases have the same stable original identity", + )); + } + let roles = package_roles(observed, package.id())?; + components.push(Component { + identity, + name: package.name().to_owned(), + version: Some(package.version().to_owned()), + roles, + origin: Some(origin), + declared_license: package.license().map(str::to_owned), + }); + } + let mut dependencies = Vec::new(); + let mut edges = BTreeSet::new(); + for edge in graph.potential_edges() { + for dependency in project_edge(edge.parent(), edge.dependency(), edge.kinds(), &identities)? + { + if !edges.insert(( + dependency.from.clone(), + dependency.to.clone(), + dependency.build, + )) { + return Err(spdx_error( + "duplicate or self-referential projected dependency", + )); + } + dependencies.push(dependency); + } + } + Ok((components, dependencies)) +} + +fn workspace_identity( + commit: &str, + path: &str, + name: &str, + version: &str, +) -> Result> { + if !lower_hex(commit, 40) { + return Err(spdx_error("workspace identity has no selected commit")); + } + relative_source(path)?; + identity( + &json!(["cargo-workspace-v1", commit, path, name, version]), + name, + version, + ) +} + +fn identity( + value: &serde_json::Value, + name: &str, + version: &str, +) -> Result> { + plain(name)?; + plain(version)?; + let key = serde_json::to_string(value).map_err(spdx_error)?; + plain(&key)?; + Ok(key) +} + +fn material_identity( + source: &str, + original: &CargoOrigin, + name: &str, + version: &str, +) -> Result> { + validate_material(source, original)?; + let facts = match original { + CargoOrigin::Registry { archive_sha256 } => { + json!(["cargo-registry-v1", source, archive_sha256, name, version]) + } + CargoOrigin::Git { + revision, + repository_tree, + package_tree, + package_path, + .. + } => json!([ + "cargo-git-v1", + source, + revision, + repository_tree, + package_tree, + package_path, + name, + version + ]), + }; + identity(&facts, name, version) +} + +pub(super) fn validate_material( + source: &str, + original: &CargoOrigin, +) -> Result<(), Report> { + plain(source)?; + match original { + CargoOrigin::Registry { archive_sha256 } => { + let location = source + .strip_prefix("registry+") + .or_else(|| source.strip_prefix("sparse+")) + .ok_or_else(|| { + spdx_error("original registry source is not a locked registry identity") + })?; + if !location.strip_prefix("https://").is_some_and(|rest| { + let authority = rest.split('/').next().unwrap_or_default(); + !authority.is_empty() + && authority.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b':') + }) + && rest.bytes().all(|byte| { + byte.is_ascii_graphic() && !matches!(byte, b'#' | b'?' | b'\\' | b'"') + }) + }) || !lower_hex(archive_sha256, 64) + { + return Err(spdx_error( + "registry original source or archive SHA-256 is invalid", + )); + } + } + CargoOrigin::Git { + revision, + repository_tree, + package_tree, + package_path, + committed_manifest_blob, + committed_manifest_sha256, + normalized_manifest_sha256, + } => { + let (selector, locked_revision) = source + .rsplit_once('#') + .ok_or_else(|| spdx_error("Git original has no locked revision"))?; + GitSourceConfig::parse(selector) + .map_err(|error| error.change_context(ReleaseError::Spdx))?; + if locked_revision != revision + || [ + revision, + repository_tree, + package_tree, + committed_manifest_blob, + ] + .iter() + .any(|value| !lower_hex(value, 40)) + || !lower_hex(committed_manifest_sha256, 64) + || normalized_manifest_sha256 + .as_ref() + .is_some_and(|value| !lower_hex(value, 64)) + { + return Err(spdx_error( + "Git original object or manifest identities are invalid", + )); + } + if !package_path.is_empty() { + relative_source(package_path)?; + } + } + } + Ok(()) +} + +fn classify( + context: UnitContext, + kinds: &[String], + crate_types: &[String], +) -> Result> { + if crate_types.is_empty() + || crate_types.iter().collect::>().len() != crate_types.len() + { + return Err(spdx_error("compiler crate types are empty or duplicated")); + } + if kinds.is_empty() || kinds.iter().collect::>().len() != kinds.len() { + return Err(spdx_error("compiler target kinds are empty or duplicated")); + } + let library_kind = |kind: &String| { + matches!( + kind.as_str(), + "lib" | "rlib" | "dylib" | "staticlib" | "cdylib" + ) + }; + if kinds.iter().all(library_kind) && crate_types.iter().all(library_kind) { + return Ok(if context == UnitContext::Host { + ComponentRole::BuildMaterial + } else { + ComponentRole::ObservedRust + }); + } + let [kind] = kinds else { + return Err(spdx_error("compiler target kinds are unsupported")); + }; + match (context, kind.as_str()) { + (UnitContext::Host, "proc-macro") if crate_types == ["proc-macro"] => { + Ok(ComponentRole::BuildTool) + } + (UnitContext::Host, "custom-build") if crate_types == ["bin"] => { + Ok(ComponentRole::BuildTool) + } + (UnitContext::Target, "bin") if crate_types == ["bin"] => Ok(ComponentRole::ObservedRust), + _ => Err(spdx_error( + "compiler unit context or kind is impossible or unsupported", + )), + } +} + +#[cfg(test)] +mod tests; + +fn package_roles( + observed: &CargoObservations, + package_id: &str, +) -> Result, Report> { + let mut roles = BTreeSet::new(); + for unit in observed + .units() + .iter() + .filter(|unit| unit.package_id() == package_id) + { + roles.insert(classify( + unit.context(), + unit.target_kind(), + unit.crate_types(), + )?); + } + if roles.is_empty() { + return Err(spdx_error("observed package has no compiler units")); + } + Ok(roles.into_iter().collect()) +} + +fn project_edge( + parent: &str, + dependency: &str, + kinds: &[String], + identities: &BTreeMap<&str, String>, +) -> Result, Report> { + let (Some(from), Some(to)) = (identities.get(parent), identities.get(dependency)) else { + return Ok(Vec::new()); + }; + let mut result = Vec::new(); + for kind in kinds { + let build = match kind.as_str() { + "normal" => false, + "build" => true, + "dev" => continue, + _ => return Err(spdx_error("unknown potential package dependency kind")), + }; + if from == to { + return Err(spdx_error("self-referential projected dependency")); + } + result.push(Dependency { + from: from.clone(), + to: to.clone(), + build, + }); + } + Ok(result) +} diff --git a/crates/trusted-server-release/src/spdx/cargo/tests.rs b/crates/trusted-server-release/src/spdx/cargo/tests.rs new file mode 100644 index 0000000000..ccaba8ef49 --- /dev/null +++ b/crates/trusted-server-release/src/spdx/cargo/tests.rs @@ -0,0 +1,370 @@ +use super::*; +use crate::composition::{CargoLayout, observe_cargo}; +use crate::policy::ReleaseTarget; +use std::path::PathBuf; + +fn git() -> CargoOrigin { + CargoOrigin::Git { + revision: "a".repeat(40), + repository_tree: "b".repeat(40), + package_tree: "c".repeat(40), + package_path: "packages/example".to_owned(), + committed_manifest_blob: "d".repeat(40), + committed_manifest_sha256: "e".repeat(64), + normalized_manifest_sha256: None, + } +} + +#[test] +fn original_identity_distinguishes_complete_registry_and_git_facts() { + let registry = CargoOrigin::Registry { + archive_sha256: "f".repeat(64), + }; + let identity = |source: &str, original: &CargoOrigin| { + material_identity(source, original, "example", "1.0.0") + .expect("should accept verified original") + }; + assert_ne!( + identity("registry+https://example.com/first", ®istry), + identity("registry+https://example.com/second", ®istry), + "should distinguish registry source identities" + ); + let source = format!("git+https://example.com/repo?tag=v1#{}", "a".repeat(40)); + let original = git(); + let first = identity(&source, &original); + assert_ne!( + first, + identity(&source.replace("tag=v1", "branch=v1"), &original), + "should preserve Git selector" + ); + let mut changed = git(); + if let CargoOrigin::Git { package_path, .. } = &mut changed { + *package_path = "other".to_owned(); + } + assert_ne!( + first, + identity(&source, &changed), + "should preserve package path" + ); + let mut changed = git(); + if let CargoOrigin::Git { revision, .. } = &mut changed { + *revision = "f".repeat(40); + } + assert_ne!( + first, + identity(&source.replace(&"a".repeat(40), &"f".repeat(40)), &changed), + "should preserve locked revision" + ); + assert!( + material_identity(&source, &changed, "example", "1.0.0").is_err(), + "should reject conflicting locked revision" + ); +} + +#[test] +fn malformed_originals_and_noncanonical_paths_are_rejected() { + for source in [ + "https://example.com/index", + "registry+file:///tmp/index", + "registry+https://", + "registry+https://example.com/index#extra", + ] { + assert!( + material_identity( + source, + &CargoOrigin::Registry { + archive_sha256: "f".repeat(64) + }, + "example", + "1" + ) + .is_err(), + "should reject malformed registry identity" + ); + } + for path in ["../outside", "/absolute", "a//b", "a/./b", "a\\b"] { + let mut original = git(); + if let CargoOrigin::Git { package_path, .. } = &mut original { + *package_path = path.to_owned(); + } + assert!( + material_identity( + &format!("git+https://example.com/repo#{}", "a".repeat(40)), + &original, + "example", + "1" + ) + .is_err(), + "should reject invalid package path" + ); + } + assert!( + material_identity( + "registry+https://example.com/index", + &CargoOrigin::Registry { + archive_sha256: "a".repeat(40) + }, + "example", + "1" + ) + .is_err(), + "should reject non-SHA256 archive identity" + ); +} + +#[test] +fn compiler_roles_fail_closed_and_preserve_host_target_distinction() { + for (context, kind, crate_type, expected) in [ + ( + UnitContext::Host, + "proc-macro", + "proc-macro", + ComponentRole::BuildTool, + ), + ( + UnitContext::Host, + "custom-build", + "bin", + ComponentRole::BuildTool, + ), + ( + UnitContext::Host, + "lib", + "lib", + ComponentRole::BuildMaterial, + ), + ( + UnitContext::Target, + "lib", + "lib", + ComponentRole::ObservedRust, + ), + ( + UnitContext::Target, + "bin", + "bin", + ComponentRole::ObservedRust, + ), + ] { + assert!( + classify(context, &[kind.to_owned()], &[crate_type.to_owned()]) + .expect("should classify known compiler unit") + == expected, + "should retain observed compiler role" + ); + } + for (context, kind, crate_type) in [ + (UnitContext::Target, "proc-macro", "proc-macro"), + (UnitContext::Target, "custom-build", "bin"), + (UnitContext::Host, "bin", "bin"), + (UnitContext::Target, "example", "bin"), + (UnitContext::Target, "lib", "unknown"), + ] { + assert!( + classify(context, &[kind.to_owned()], &[crate_type.to_owned()]).is_err(), + "should reject impossible or unknown unit" + ); + } +} + +#[test] +fn workspace_logical_identity_ignores_relocated_opaque_ids() { + let metadata = + include_str!("../../../tests/fixtures/composition/cargo-1.95-cross-host-metadata.json"); + let messages = + include_str!("../../../tests/fixtures/composition/cargo-1.95-cross-messages.jsonl"); + let capture = |root: &str| { + let layout = CargoLayout::new( + ReleaseTarget::Wasm32Wasip1, + PathBuf::from(format!("{root}/target")), + PathBuf::from(format!("{root}/source")), + ) + .expect("should accept layout"); + observe_cargo( + metadata.replace("/build", root).as_bytes(), + messages.replace("/build", root).as_bytes(), + &layout, + ) + .expect("should observe relocated fixture") + }; + let first = capture("/first"); + let second = capture("/second"); + assert_ne!( + first.packages()[0].id(), + second.packages()[0].id(), + "should actually relocate opaque Cargo identities" + ); + let keys = |observed: &CargoObservations| { + observed + .packages() + .iter() + .map(|package| { + workspace_identity( + &"a".repeat(40), + &format!("{}/Cargo.toml", package.name()), + package.name(), + package.version(), + ) + .expect("should accept tracked manifest") + }) + .collect::>() + }; + assert_eq!( + keys(&first), + keys(&second), + "should retain stable source identities after relocation" + ); +} + +#[test] +fn registry_locked_source_requires_an_actual_https_authority() { + let original = CargoOrigin::Registry { + archive_sha256: "a".repeat(64), + }; + for source in [ + "registry+https:///index", + "registry+https://@/index", + "sparse+https:///index", + ] { + assert!( + material_identity(source, &original, "example", "1").is_err(), + "should reject registry identity without a valid authority" + ); + } +} + +#[test] +fn per_unit_feature_receipts_remain_separate_for_multiple_roles() { + let metadata = + include_bytes!("../../../tests/fixtures/composition/cargo-1.95-cross-host-metadata.json"); + let messages = + include_str!("../../../tests/fixtures/composition/cargo-1.95-cross-messages.jsonl"); + let mut stream = String::new(); + for line in messages.lines() { + let mut message: serde_json::Value = + serde_json::from_str(line).expect("should parse observed compiler line"); + if message["reason"] == "compiler-artifact" + && message["package_id"] + .as_str() + .is_some_and(|id| id.contains("example-app@")) + { + message["features"] = if message["target"]["kind"][0] == "custom-build" { + json!(["host-only"]) + } else { + json!(["target-only"]) + }; + } + stream.push_str(&serde_json::to_string(&message).expect("should serialize compiler line")); + stream.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, stream.as_bytes(), &layout) + .expect("should observe distinct compiler units"); + let units = observed + .units() + .iter() + .filter(|unit| unit.package_id().contains("example-app@")) + .collect::>(); + let roles = package_roles(&observed, units[0].package_id()) + .expect("should classify all observed roles"); + assert!( + roles.contains(&ComponentRole::BuildTool) && roles.contains(&ComponentRole::ObservedRust), + "should retain multiple package roles" + ); + assert!( + units + .iter() + .any(|unit| unit.context() == UnitContext::Host && unit.features() == ["host-only"]), + "should retain host features separately" + ); + assert!(units.iter().any(|unit| unit.context() == UnitContext::Target && unit.features() == ["target-only"]),"should retain target features separately"); + assert!( + units.iter().all(|unit| unit.features().len() == 1), + "should never synthesize package feature unions" + ); +} + +#[test] +fn package_edges_require_both_observed_endpoints_and_skip_dev_kinds() { + let identities = BTreeMap::from([ + ("parent", "stable-parent".to_owned()), + ("child", "stable-child".to_owned()), + ]); + assert!( + project_edge( + "parent", + "potential-only", + &["normal".to_owned()], + &identities + ) + .expect("should exclude potential-only endpoint") + .is_empty(), + "should exclude potential graph membership" + ); + assert!( + project_edge("parent", "child", &["dev".to_owned()], &identities) + .expect("should skip dev-only relation") + .is_empty(), + "should omit dev-only dependencies" + ); + let edges = project_edge( + "parent", + "child", + &["normal".to_owned(), "build".to_owned()], + &identities, + ) + .expect("should preserve normal and build declarations"); + assert_eq!(edges.len(), 2, "should retain both relation kinds"); + assert!( + !edges[0].build && edges[1].build, + "should preserve normal versus build semantics" + ); +} + +#[test] +fn multiple_library_outputs_preserve_host_and_target_roles() { + let kinds = ["cdylib".to_owned(), "rlib".to_owned()]; + for (context, expected) in [ + (UnitContext::Host, ComponentRole::BuildMaterial), + (UnitContext::Target, ComponentRole::ObservedRust), + ] { + assert!( + classify(context, &kinds, &kinds) + .expect("should accept multiple compatible library outputs") + == expected, + "should preserve library compiler context" + ); + } +} + +#[test] +fn multiple_library_outputs_reject_mixed_duplicate_and_incompatible_kinds() { + for kinds in [ + vec!["lib", "bin"], + vec!["rlib", "proc-macro"], + vec!["rlib", "custom-build"], + vec!["rlib", "test"], + vec!["rlib", "rlib"], + vec![], + ] { + let kinds = kinds.into_iter().map(str::to_owned).collect::>(); + assert!( + classify(UnitContext::Target, &kinds, &["rlib".to_owned()]).is_err(), + "should reject non-library or duplicate target kinds" + ); + } + assert!( + classify( + UnitContext::Target, + &["cdylib".to_owned(), "rlib".to_owned()], + &["bin".to_owned()] + ) + .is_err(), + "should reject incompatible library crate types" + ); +} diff --git a/crates/trusted-server-release/src/spdx/javascript.rs b/crates/trusted-server-release/src/spdx/javascript.rs new file mode 100644 index 0000000000..3c4f0172f0 --- /dev/null +++ b/crates/trusted-server-release/src/spdx/javascript.rs @@ -0,0 +1,365 @@ +//! Artifact-specific conservative projection of checked original JavaScript facts. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde_json::json; + +use crate::composition::CompositionView; +use crate::composition::javascript::JsMembership; +use crate::error::ReleaseError; +use crate::javascript::contributions::{ + Material, ModuleContributions, Origin as JsOrigin, RetainedContributions, +}; +use crate::recipe::RecipeCompleted; + +use super::{Component, ComponentRole, Dependency, Origin, lower_hex, plain, spdx_error}; + +/// Checked renderer inputs without complete native/tool coverage authority. +pub(crate) struct JavascriptProjection<'a> { + view: CompositionView<'a>, + originals: &'a RetainedContributions, + components: Vec, +} + +impl<'a> JavascriptProjection<'a> { + /// Joins the sealed artifact observations to its frozen original inventory. + /// + /// # Errors + /// + /// Rejects changed receipts and conflicting source, inventory or module joins. + pub(crate) fn from_completed( + completed: &'a RecipeCompleted, + ) -> Result> { + let view = completed.composition_view()?; + let originals = completed.javascript_contributions()?; + let components = project( + view.javascript(), + originals, + completed.commit().as_str(), + completed.artifact().sha256(), + )?; + Ok(Self { + view, + originals, + components, + }) + } + /// Returns the exact checked collector facts, retaining observation lengths and stages. + pub(crate) fn originals(&self) -> &RetainedContributions { + self.originals + } + /// Returns the checked renderer component count. + pub(crate) fn component_count(&self) -> usize { + self.components.len() + } + /// Returns final-byte observations borrowed from the sealed owner. + pub(crate) fn membership(&self) -> &JsMembership { + self.view.javascript() + } + /// Releases the renderer components; JavaScript projects no package edges. + pub(super) fn into_parts(self) -> (Vec, Vec) { + (self.components, Vec::new()) + } +} + +/// Typed originals copied only from the checked collector export. +#[derive(Clone, serde::Serialize)] +pub(super) enum JavascriptOriginal { + Bundle { + id: String, + filename: String, + size: u64, + sha256: String, + }, + Contribution(JsOrigin), + Material(Material), +} + +fn key( + commit: &str, + kind: &str, + facts: &impl serde::Serialize, +) -> Result> { + let value = serde_json::to_string(&json!(["javascript-v1", commit, kind, facts])) + .map_err(spdx_error)?; + plain(&value)?; + Ok(value) +} + +fn material_id(material: &Material) -> &str { + match material { + Material::Source { id, .. } + | Material::NpmPackage { id, .. } + | Material::ToolDistribution { id, .. } + | Material::LifecycleEvent { id, .. } + | Material::LifecycleOutput { id, .. } => id, + } +} + +fn project( + membership: &JsMembership, + originals: &RetainedContributions, + commit: &str, + artifact: &str, +) -> Result, Report> { + if !lower_hex(commit, 40) + || originals.source_commit() != commit + || membership.artifact_sha256() != artifact + || !lower_hex(artifact, 64) + || membership.inventory_sha256() != originals.inventory_sha256() + { + return Err(spdx_error( + "JavaScript original source, inventory or artifact identity differs", + )); + } + let mut components = Vec::new(); + let mut material_keys = BTreeMap::new(); + let generated_owners = originals + .modules() + .iter() + .flat_map(ModuleContributions::contributions) + .filter_map(|entry| match entry.origin() { + JsOrigin::Generated { + owner_lock_path, .. + } => Some(format!("npm:{owner_lock_path}")), + _ => None, + }) + .collect::>(); + for material in originals.materials() { + let identity = key(commit, "material", material)?; + if material_keys + .insert(material_id(material), identity.clone()) + .is_some() + { + return Err(spdx_error("duplicate JavaScript material")); + } + let (name, version) = match material { + Material::NpmPackage { name, version, .. } + | Material::ToolDistribution { name, version, .. } => { + (name.clone(), Some(version.clone())) + } + _ => (material_id(material).to_owned(), None), + }; + let tool = generated_owners.contains(material_id(material)) + || originals + .tools() + .iter() + .any(|tool| tool.material_id() == material_id(material)); + components.push(component( + identity, + name, + version, + if tool { + ComponentRole::BuildTool + } else { + ComponentRole::BuildMaterial + }, + commit, + JavascriptOriginal::Material(material.clone()), + None, + )); + } + let modules = membership.modules().collect::>(); + if modules.len() != originals.modules().len() { + return Err(spdx_error("JavaScript module sets differ")); + } + let mut observed = BTreeMap::new(); + for (module, classification) in modules { + if observed + .insert(module.id(), (module, classification)) + .is_some() + { + return Err(spdx_error("duplicate JavaScript module")); + } + } + for module in originals.modules() { + let (found, classification) = observed + .remove(module.id()) + .ok_or_else(|| spdx_error("missing JavaScript module"))?; + if found.filename() != module.filename() + || found.size() != module.size() + || found.sha256() != module.sha256() + { + return Err(spdx_error("JavaScript complete module identities differ")); + } + let retained = match classification { + "conservative_embedded_js" => true, + "js_build_material" => false, + _ => return Err(spdx_error("unknown final JavaScript classification")), + }; + let bundle = JavascriptOriginal::Bundle { + id: module.id().to_owned(), + filename: module.filename().to_owned(), + size: module.size(), + sha256: module.sha256().to_owned(), + }; + components.push(component( + key(commit, "bundle", &bundle)?, + module.id().to_owned(), + None, + if retained { + ComponentRole::RetainedJavascript + } else { + ComponentRole::BuildMaterial + }, + commit, + bundle, + None, + )); + for contribution in module.contributions() { + if contribution.stage() != "rollup-rendered" || contribution.rendered_length() == 0 { + return Err(spdx_error("unknown JavaScript contribution observation")); + } + let origin = contribution.origin(); + let producer = match origin { + JsOrigin::Generated { + owner_lock_path, .. + } => Some( + material_keys + .get(format!("npm:{owner_lock_path}").as_str()) + .ok_or_else(|| spdx_error("missing generated JavaScript owner"))? + .clone(), + ), + _ => None, + }; + let original = JavascriptOriginal::Contribution(origin.clone()); + let identity = key(commit, "rendered", &json!([module.id(), original]))?; + components.push(component( + identity, + format!("{} rendered input", module.id()), + None, + if retained { + ComponentRole::ConservativeJavascript + } else { + ComponentRole::BuildMaterial + }, + commit, + original, + producer, + )); + } + } + Ok(components) +} + +fn component( + identity: String, + name: String, + version: Option, + role: ComponentRole, + commit: &str, + original: JavascriptOriginal, + producer: Option, +) -> Component { + Component { + identity, + name, + version, + roles: vec![role], + origin: Some(Origin::JavascriptVerified { + source_commit: commit.to_owned(), + original, + producer, + }), + declared_license: None, + } +} + +#[cfg(test)] +mod tests; + +pub(super) fn origin_fields( + component: &Component, + inputs: &super::SpdxInputs<'_>, + commit: &str, + original: &JavascriptOriginal, + producer: Option<&str>, +) -> Result> { + if commit != inputs.source_commit || !lower_hex(commit, 40) { + return Err(spdx_error("JavaScript source differs from selected source")); + } + if component.declared_license.is_some() { + return Err(spdx_error( + "JavaScript declared license exceeds measured facts", + )); + } + if !matches!( + (original, component.roles.as_slice()), + ( + JavascriptOriginal::Bundle { .. }, + [ComponentRole::RetainedJavascript | ComponentRole::BuildMaterial] + ) | ( + JavascriptOriginal::Contribution(_), + [ComponentRole::ConservativeJavascript | ComponentRole::BuildMaterial] + ) | ( + JavascriptOriginal::Material(_), + [ComponentRole::BuildTool | ComponentRole::BuildMaterial] + ) + ) { + return Err(spdx_error( + "JavaScript origin and component roles are incompatible", + )); + } + let generated = matches!( + original, + JavascriptOriginal::Contribution(JsOrigin::Generated { .. }) + ); + if generated != producer.is_some() + || producer.is_some_and(|id| { + !inputs.components.iter().any(|candidate| { + candidate.identity == id + && candidate.identity != component.identity + && candidate.roles.contains(&ComponentRole::BuildTool) + }) + }) + { + return Err(spdx_error( + "generated JavaScript has no checked build-tool owner", + )); + } + let checksum = match original { + JavascriptOriginal::Bundle { sha256, .. } => Some(("SHA256", sha256.clone())), + JavascriptOriginal::Contribution( + JsOrigin::Source { sha256, .. } | JsOrigin::Npm { sha256, .. }, + ) => Some(("SHA256", sha256.clone())), + JavascriptOriginal::Contribution(JsOrigin::Generated { + generated_sha256, .. + }) => Some(("SHA256", generated_sha256.clone())), + JavascriptOriginal::Material(Material::NpmPackage { integrity, .. }) => { + Some(("SHA512", archive_sha512(integrity)?)) + } + JavascriptOriginal::Material( + Material::Source { sha256, .. } + | Material::ToolDistribution { sha256, .. } + | Material::LifecycleOutput { sha256, .. }, + ) => Some(("SHA256", sha256.clone())), + JavascriptOriginal::Material(Material::LifecycleEvent { .. }) => None, + }; + if checksum.as_ref().is_some_and(|(algorithm, digest)| { + !lower_hex(digest, if *algorithm == "SHA512" { 128 } else { 64 }) + }) { + return Err(spdx_error("invalid JavaScript original digest")); + } + let facts = serde_json::to_string( + &json!({"source_commit":commit,"original":original,"producer":producer}), + ) + .map_err(spdx_error)?; + let info = format!( + "Verified original JavaScript facts {facts}; npm archive SHA512 integrity and structured installed-tree SHA256 are distinct. Rendered inputs are conservative post-Rollup, before esbuild minification; final function reachability is not asserted." + ); + plain(&info)?; + Ok(("NOASSERTION".to_owned(), info, checksum)) +} + +fn archive_sha512(integrity: &str) -> Result> { + let encoded = integrity + .strip_prefix("sha512-") + .ok_or_else(|| spdx_error("npm original integrity is not SHA512"))?; + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded) + .map_err(spdx_error)?; + if bytes.len() != 64 { + return Err(spdx_error("npm original SHA512 length differs")); + } + Ok(hex::encode(bytes)) +} diff --git a/crates/trusted-server-release/src/spdx/javascript/tests.rs b/crates/trusted-server-release/src/spdx/javascript/tests.rs new file mode 100644 index 0000000000..8f97272c1d --- /dev/null +++ b/crates/trusted-server-release/src/spdx/javascript/tests.rs @@ -0,0 +1,387 @@ +use super::*; +use crate::composition::javascript::{JsInputs, JsRetentionPolicy, collect_js}; +use crate::javascript::JsBuildCompleted; +use sha2::{Digest as _, Sha256}; + +fn role_originals() -> [JavascriptOriginal; 3] { + [ + JavascriptOriginal::Bundle { + id: "example".to_owned(), + filename: "tsjs-example.js".to_owned(), + size: 16, + sha256: "b".repeat(64), + }, + JavascriptOriginal::Contribution(JsOrigin::Source { + path: "src/example.ts".to_owned(), + transform: "source".to_owned(), + sha256: "c".repeat(64), + }), + JavascriptOriginal::Material(Material::Source { + id: "source:example".to_owned(), + path: "src/example.ts".to_owned(), + sha256: "d".repeat(64), + }), + ] +} + +fn role_inputs( + original: JavascriptOriginal, + role: ComponentRole, +) -> super::super::SpdxInputs<'static> { + let bytes = b"fictional executable"; + super::super::SpdxInputs { + executable: bytes, + expected_artifact_sha256: hex::encode(Sha256::digest(bytes)), + name: "example-artifact".to_owned(), + version: "1.0.0".to_owned(), + source_commit: "a".repeat(40), + target: crate::policy::ReleaseTarget::Wasm32Wasip1, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components: vec![component( + "javascript:example".to_owned(), + "example".to_owned(), + None, + role, + &"a".repeat(40), + original, + None, + )], + dependencies: Vec::new(), + } +} + +#[test] +fn materials_cannot_render_complete_module_containment() { + let original = role_originals()[2].clone(); + let inputs = role_inputs(original, ComponentRole::RetainedJavascript); + let error = super::super::render(&inputs) + .expect_err("should reject material evidence claiming complete module containment"); + assert!( + format!("{error:?}").contains("JavaScript origin and component roles are incompatible"), + "should reject the unsupported containment claim at the JavaScript origin boundary" + ); +} + +#[test] +fn javascript_originals_accept_only_their_single_supported_roles() { + let roles = [ + ComponentRole::ObservedRust, + ComponentRole::RetainedJavascript, + ComponentRole::ConservativeJavascript, + ComponentRole::ObservedNative, + ComponentRole::BuildMaterial, + ComponentRole::BuildTool, + ComponentRole::ExternalRuntime, + ]; + for (original, allowed) in role_originals().into_iter().zip([ + [ + ComponentRole::RetainedJavascript, + ComponentRole::BuildMaterial, + ], + [ + ComponentRole::ConservativeJavascript, + ComponentRole::BuildMaterial, + ], + [ComponentRole::BuildTool, ComponentRole::BuildMaterial], + ]) { + for role in roles { + let inputs = role_inputs(original.clone(), role); + assert_eq!( + super::super::render(&inputs).is_ok(), + allowed.contains(&role), + "should accept exactly the supported original and role pairs" + ); + } + for invalid in [vec![], vec![allowed[0], allowed[0]], allowed.to_vec()] { + let mut inputs = role_inputs(original.clone(), allowed[0]); + inputs.components[0].roles = invalid; + assert!( + super::super::render(&inputs).is_err(), + "should reject empty, duplicate or mixed JavaScript roles" + ); + } + } +} + +#[test] +fn javascript_originals_reject_unmeasured_declared_licenses() { + for (original, roles) in role_originals().into_iter().zip([ + [ + ComponentRole::RetainedJavascript, + ComponentRole::BuildMaterial, + ], + [ + ComponentRole::ConservativeJavascript, + ComponentRole::BuildMaterial, + ], + [ComponentRole::BuildTool, ComponentRole::BuildMaterial], + ]) { + for role in roles { + let mut inputs = role_inputs(original.clone(), role); + inputs.components[0].declared_license = Some("MIT".to_owned()); + + let error = super::super::render(&inputs) + .expect_err("should reject unmeasured JavaScript declared licenses"); + assert!( + format!("{error:?}").contains("JavaScript declared license exceeds measured facts"), + "should reject the unmeasured license at the JavaScript origin boundary" + ); + } + } +} + +#[test] +fn valid_javascript_role_document_bytes_remain_unchanged() { + let original = role_originals()[0].clone(); + let inputs = role_inputs(original, ComponentRole::RetainedJavascript); + let generated = super::super::render(&inputs).expect("should render valid module evidence"); + // Captured before JavaScript origin/role validation was introduced. + assert_eq!( + generated.sha256(), + "2a79890ec323d58b8c6b8f304b06977c8355c8a5c341301b9782a82ddc489ee8", + "should preserve valid rendered bytes" + ); +} + +#[test] +fn retained_and_absent_modules_have_different_contributor_roles() { + let completed = JsBuildCompleted::fixture_for_projection(&"a".repeat(40)); + let originals = completed + .retained_contributions() + .expect("should export originals"); + for (bytes, retained) in [ + (b"prefix fictional module suffix".as_slice(), true), + (b"operator binary".as_slice(), false), + ] { + let modules = completed.retained_modules().expect("should export modules"); + let root = + std::fs::canonicalize(completed.rehash_for_handoff().expect("should rehash owner")) + .expect("should canonicalize"); + let digest = hex::encode(Sha256::digest(bytes)); + let membership = collect_js( + bytes, + &digest, + &JsInputs { + retained_root: &root, + modules: &modules, + inventory_sha256: originals.inventory_sha256(), + policy: JsRetentionPolicy::ObserveCli, + }, + ) + .expect("should inspect final bytes"); + let components = project(&membership, &originals, &"a".repeat(40), &digest) + .expect("should project checked joins"); + assert!( + components + .iter() + .any(|component| component.roles.contains(&ComponentRole::RetainedJavascript)) + == retained, + "should claim retention only for complete final bytes" + ); + assert!( + components.iter().any(|component| component + .roles + .contains(&ComponentRole::ConservativeJavascript)) + == retained, + "should preserve conservative origin role only for retained module" + ); + assert!( + project(&membership, &originals, &"b".repeat(40), &digest).is_err(), + "should reject another source" + ); + assert!( + project(&membership, &originals, &"a".repeat(40), &"f".repeat(64)).is_err(), + "should reject another artifact" + ); + if !retained { + let document = super::super::render(&super::super::SpdxInputs { + executable: bytes, + expected_artifact_sha256: digest, + name: "example-cli".to_owned(), + version: "1.0.0".to_owned(), + source_commit: "a".repeat(40), + target: crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies: Vec::new(), + }) + .expect("should render absent CLI modules as build evidence"); + let value: serde_json::Value = serde_json::from_slice(document.bytes()) + .expect("should parse rendered CLI evidence"); + let relationships = value["relationships"] + .as_array() + .expect("should list relationships"); + assert!( + relationships.iter().all(|edge| { + edge["relationshipType"] != "CONTAINS" + && edge["relationshipType"] != "GENERATED_FROM" + }), + "should claim no retained or conservative JS contributors in an absent CLI artifact" + ); + assert!( + relationships + .iter() + .any(|edge| edge["relationshipType"] == "BUILD_DEPENDENCY_OF"), + "should preserve discarded JS as build evidence" + ); + } + } +} + +#[test] +fn checked_originals_render_distinct_archive_tree_and_generated_evidence() { + let completed = JsBuildCompleted::fixture_for_projection(&"a".repeat(40)); + let originals = completed + .retained_contributions() + .expect("should export originals"); + let bytes = b"prefix fictional module suffix"; + let digest = hex::encode(Sha256::digest(bytes)); + let modules = completed.retained_modules().expect("should export modules"); + let root = std::fs::canonicalize(completed.rehash_for_handoff().expect("should rehash owner")) + .expect("should canonicalize"); + let membership = collect_js( + bytes, + &digest, + &JsInputs { + retained_root: &root, + modules: &modules, + inventory_sha256: originals.inventory_sha256(), + policy: JsRetentionPolicy::FastlyAll, + }, + ) + .expect("should inspect final bytes"); + let components = project(&membership, &originals, &"a".repeat(40), &digest) + .expect("should project originals"); + let inputs = super::super::SpdxInputs { + executable: bytes, + expected_artifact_sha256: digest, + name: "example-artifact".to_owned(), + version: "1.0.0".to_owned(), + source_commit: "a".repeat(40), + target: crate::policy::ReleaseTarget::Wasm32Wasip1, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies: Vec::new(), + }; + let generated = super::super::render(&inputs).expect("should render checked original facts"); + let value: serde_json::Value = + serde_json::from_slice(generated.bytes()).expect("should parse document"); + let packages = value["packages"] + .as_array() + .expect("should contain packages"); + let npm = packages + .iter() + .find(|package| package["name"] == "vite") + .expect("should retain npm package"); + assert_eq!( + npm["downloadLocation"], "NOASSERTION", + "should not invent original archive URL" + ); + assert_eq!( + npm["checksums"][0]["algorithm"], "SHA512", + "should identify original npm archive algorithm" + ); + assert_eq!( + npm["checksums"][0]["checksumValue"], + "0".repeat(128), + "should decode original archive integrity" + ); + assert!( + npm["sourceInfo"] + .as_str() + .expect("should describe original") + .contains(&"d".repeat(64)), + "should retain installed tree separately" + ); + assert!( + packages + .iter() + .all(|package| package["licenseDeclared"] == "NOASSERTION"), + "should not infer licenses" + ); + assert!( + value["relationships"] + .as_array() + .expect("should contain relationships") + .iter() + .any(|edge| edge["relationshipType"] == "GENERATES"), + "should join checked generated owner" + ); + assert!( + !String::from_utf8(generated.bytes().to_vec()) + .expect("should encode UTF8") + .contains(root.to_str().expect("should encode root")), + "should exclude private root" + ); +} + +#[test] +fn relocated_owners_preserve_stable_component_identities() { + let mut identities = Vec::new(); + for _ in 0..2 { + let completed = JsBuildCompleted::fixture_for_projection(&"a".repeat(40)); + let originals = completed + .retained_contributions() + .expect("should export originals"); + let bytes = b"operator binary"; + let digest = hex::encode(Sha256::digest(bytes)); + let modules = completed.retained_modules().expect("should export modules"); + let root = + std::fs::canonicalize(completed.rehash_for_handoff().expect("should rehash owner")) + .expect("should canonicalize"); + let membership = collect_js( + bytes, + &digest, + &JsInputs { + retained_root: &root, + modules: &modules, + inventory_sha256: originals.inventory_sha256(), + policy: JsRetentionPolicy::ObserveCli, + }, + ) + .expect("should inspect final bytes"); + identities.push( + project(&membership, &originals, &"a".repeat(40), &digest) + .expect("should project") + .into_iter() + .map(|component| component.identity) + .collect::>(), + ); + } + assert_eq!( + identities[0], identities[1], + "should ignore private owner relocation" + ); +} + +#[test] +fn equal_module_bytes_do_not_substitute_for_exact_original_inventory() { + let owner = JsBuildCompleted::fixture_for_tests(&"a".repeat(40)); + let other = JsBuildCompleted::fixture_for_projection(&"a".repeat(40)); + let originals = other + .retained_contributions() + .expect("should export different originals"); + let bytes = b"fictional module"; + let digest = hex::encode(Sha256::digest(bytes)); + let modules = owner.retained_modules().expect("should export modules"); + let root = std::fs::canonicalize(owner.rehash_for_handoff().expect("should rehash owner")) + .expect("should canonicalize"); + let membership = collect_js( + bytes, + &digest, + &JsInputs { + retained_root: &root, + modules: &modules, + inventory_sha256: owner.inventory_sha256(), + policy: JsRetentionPolicy::FastlyAll, + }, + ) + .expect("should inspect same complete module bytes"); + assert!( + project(&membership, &originals, &"a".repeat(40), &digest).is_err(), + "should reject different raw inventory despite equal complete module bytes" + ); +} diff --git a/crates/trusted-server-release/src/spdx/native.rs b/crates/trusted-server-release/src/spdx/native.rs new file mode 100644 index 0000000000..434e33d16d --- /dev/null +++ b/crates/trusted-server-release/src/spdx/native.rs @@ -0,0 +1,358 @@ +//! Conservative native projection from sealed composition and verified originals. + +use std::collections::BTreeSet; + +use error_stack::Report; +use serde::Serialize; +use serde_json::json; + +use crate::composition::native::{NativeContributor, NativeInventory, reviewed_wrapper_version}; +use crate::composition::origin::{Original, VerifiedPackageOrigins}; +use crate::composition::{CargoObservations, CompositionView, UnitContext}; +use crate::error::ReleaseError; +use crate::materials::CargoOrigin; +use crate::recipe::RecipeCompleted; + +use super::{ + Component, ComponentRole, Dependency, Origin, OriginFields, cargo, lower_hex, plain, + relative_source, spdx_error, +}; + +/// Renderer inputs borrowed from sealed native receipts, without complete coverage. +pub(crate) struct NativeProjection<'a> { + view: CompositionView<'a>, + components: Vec, +} + +impl<'a> NativeProjection<'a> { + /// Joins measured native inputs to the completed build's verified originals. + /// + /// # Errors + /// + /// Rejects changed receipts, wrong source/material identities and conflicting + /// package, compiler-context, feature or original-source joins. + pub(crate) fn from_completed( + completed: &'a RecipeCompleted, + ) -> Result> { + let view = completed.composition_view()?; + let origins = view.package_origins(); + if origins.source_commit() != completed.commit().as_str() + || !lower_hex(origins.source_commit(), 40) + || origins.materials_sha256() != completed.materials_digest() + || !lower_hex(origins.materials_sha256(), 64) + { + return Err(spdx_error( + "native originals belong to another source or material tree", + )); + } + let components = project(view.native(), view.observations(), origins)?; + Ok(Self { view, components }) + } + + /// Returns the number of checked native wrapper-context components. + pub(crate) fn component_count(&self) -> usize { + self.components.len() + } + + /// Returns the borrowed measured receipts with their existing coverage gaps. + pub(crate) fn inventory(&self) -> &NativeInventory { + self.view.native() + } + + /// Releases the renderer components; native contexts project no package edges. + pub(super) fn into_parts(self) -> (Vec, Vec) { + (self.components, Vec::new()) + } +} + +/// Sanitized native facts; no opaque Cargo IDs or private working paths are kept. +#[derive(Serialize)] +pub(super) struct NativeOriginal { + locked_source: String, + cargo_wrapper_name: String, + cargo_wrapper_version: String, + native_implementation_version: Option, + original_registry_archive_sha256: String, + context: UnitContext, + features: Vec, + prepared_source_tree_sha256: String, + source_subtrees: Vec, + static_libraries: Vec, + static_output_archives: Vec, +} + +#[derive(Serialize)] +struct Subtree { + relative_path: String, + tree_sha256: String, +} + +#[derive(Serialize)] +struct Archive { + filename: String, + size: u64, + sha256: String, +} + +fn project( + inventory: &NativeInventory, + observed: &CargoObservations, + origins: &VerifiedPackageOrigins, +) -> Result, Report> { + let mut seen = BTreeSet::new(); + let mut stable = BTreeSet::new(); + let mut components = Vec::new(); + for contributor in inventory.contributors() { + if !seen.insert(( + contributor.package_id(), + contributor.context() == UnitContext::Target, + )) { + return Err(spdx_error("duplicate native package/context receipt")); + } + let mut matching = observed + .packages() + .iter() + .filter(|package| package.id() == contributor.package_id()); + let package = matching + .next() + .ok_or_else(|| spdx_error("native receipt has no observed package"))?; + if matching.next().is_some() + || package.name() != contributor.name() + || package.version() != contributor.version() + || !contributor.reviewed_wrapper() + { + return Err(spdx_error( + "native receipt has an unknown or conflicting Cargo wrapper", + )); + } + let verified = origins + .package(contributor.package_id()) + .ok_or_else(|| spdx_error("native receipt has no verified exact-ID original"))?; + if verified.name() != contributor.name() || verified.version() != contributor.version() { + return Err(spdx_error( + "native receipt conflicts with verified wrapper identity", + )); + } + let Original::Material { + source, + original: CargoOrigin::Registry { archive_sha256 }, + } = verified.origin() + else { + return Err(spdx_error( + "native input lacks a verified reviewed registry original", + )); + }; + if archive_sha256 != contributor.original_sha256() { + return Err(spdx_error( + "native original registry archive digest differs", + )); + } + cargo::validate_material( + source, + &CargoOrigin::Registry { + archive_sha256: archive_sha256.clone(), + }, + )?; + let expected_classification = match contributor.context() { + UnitContext::Target => "conservative_embedded_contributor", + UnitContext::Host => "build_tool_native_contributor", + }; + if contributor.classification() != expected_classification { + return Err(spdx_error( + "native classification conflicts with observed context", + )); + } + let consumers = observed + .units() + .iter() + .filter(|unit| { + unit.package_id() == contributor.package_id() + && unit.context() == contributor.context() + && !unit.target_kind().iter().any(|kind| kind == "custom-build") + }) + .collect::>(); + if consumers.is_empty() + || consumers + .iter() + .any(|unit| unit.features() != contributor.features()) + || contributor.features().iter().collect::>().len() + != contributor.features().len() + { + return Err(spdx_error( + "native compiler context or effective unit features differ", + )); + } + let original = original(contributor, source)?; + let identity = serde_json::to_string(&json!([ + "native-registry-v1", + source, + archive_sha256, + contributor.name(), + contributor.version(), + contributor.context() + ])) + .map_err(spdx_error)?; + plain(&identity)?; + if !stable.insert(identity.clone()) { + return Err(spdx_error( + "distinct native Cargo aliases have the same stable original/context identity", + )); + } + components.push(Component { + identity, + name: format!( + "Native inputs from Cargo wrapper {}@{}", + contributor.name(), + contributor.version() + ), + version: None, + roles: vec![if contributor.context() == UnitContext::Target { + ComponentRole::ObservedNative + } else { + ComponentRole::BuildMaterial + }], + origin: Some(Origin::NativeVerified { original }), + declared_license: None, + }); + } + let mut expected = BTreeSet::new(); + for unit in observed.units() { + if unit.target_kind().iter().any(|kind| kind == "custom-build") { + continue; + } + let package = observed + .packages() + .iter() + .find(|package| package.id() == unit.package_id()) + .ok_or_else(|| spdx_error("native compiler context has no observed package"))?; + if reviewed_wrapper_version(package.name()).is_some() { + expected.insert((package.id(), unit.context() == UnitContext::Target)); + } + } + if seen != expected { + return Err(spdx_error( + "observed native consuming contexts lack exact measured receipts", + )); + } + Ok(components) +} + +fn original( + contributor: &NativeContributor, + source: &str, +) -> Result> { + if !lower_hex(contributor.source_tree_sha256(), 64) { + return Err(spdx_error("native prepared tree digest is invalid")); + } + let mut paths = BTreeSet::new(); + let mut source_subtrees = Vec::new(); + for subtree in contributor.source_subtrees() { + relative_source(subtree.path())?; + if !paths.insert(subtree.path()) || !lower_hex(subtree.sha256(), 64) { + return Err(spdx_error( + "native source subtree identity is invalid or duplicated", + )); + } + source_subtrees.push(Subtree { + relative_path: subtree.path().to_owned(), + tree_sha256: subtree.sha256().to_owned(), + }); + } + let mut archives = BTreeSet::new(); + let mut static_output_archives = Vec::new(); + for archive in contributor.archives() { + let filename = archive + .filename() + .ok_or_else(|| spdx_error("native output has no logical filename"))?; + relative_source(filename)?; + if !archives.insert(filename.to_owned()) + || !filename.ends_with(".a") + || archive.size() < 8 + || !lower_hex(archive.sha256(), 64) + { + return Err(spdx_error( + "native output archive identity is invalid or duplicated", + )); + } + static_output_archives.push(Archive { + filename: filename.to_owned(), + size: archive.size(), + sha256: archive.sha256().to_owned(), + }); + } + let mut libraries = BTreeSet::new(); + for directive in contributor.linked_libs() { + let library = directive + .strip_prefix("static=") + .ok_or_else(|| spdx_error("native directive is not a static library"))?; + relative_source(library)?; + if library.contains('/') || !libraries.insert(format!("lib{library}.a")) { + return Err(spdx_error("native static library is invalid or duplicated")); + } + } + if source_subtrees.is_empty() || archives.is_empty() || archives != libraries { + return Err(spdx_error( + "native source or static-output coverage differs from link directives", + )); + } + Ok(NativeOriginal { + locked_source: source.to_owned(), + cargo_wrapper_name: contributor.name().to_owned(), + cargo_wrapper_version: contributor.version().to_owned(), + native_implementation_version: None, + original_registry_archive_sha256: contributor.original_sha256().to_owned(), + context: contributor.context(), + features: contributor.features().to_vec(), + prepared_source_tree_sha256: contributor.source_tree_sha256().to_owned(), + source_subtrees, + static_libraries: contributor.linked_libs().to_vec(), + static_output_archives, + }) +} + +pub(super) fn origin_fields( + component: &Component, + original: &NativeOriginal, +) -> Result> { + let expected = if original.context == UnitContext::Target { + ComponentRole::ObservedNative + } else { + ComponentRole::BuildMaterial + }; + if component.roles != [expected] + || component.version.is_some() + || component.declared_license.is_some() + { + return Err(spdx_error( + "native roles, implementation version or declared license exceed measured facts", + )); + } + cargo::validate_material( + &original.locked_source, + &CargoOrigin::Registry { + archive_sha256: original.original_registry_archive_sha256.clone(), + }, + )?; + let facts = serde_json::to_string(original).map_err(spdx_error)?; + let info = format!( + "Verified native inputs from Cargo wrapper facts {facts}. Cargo wrapper versions do not identify native implementation versions. Checksum identifies the original registry source archive; prepared/subtree digests are structured tree evidence and static output archives are measured build products. Conservative static-link inputs do not establish final function reachability. Complete native/tool coverage and original tool acquisition are not asserted." + ); + plain(&info)?; + Ok(( + "NOASSERTION".to_owned(), + info, + Some(("SHA256", original.original_registry_archive_sha256.clone())), + )) +} + +#[cfg(test)] +pub(crate) fn validate_for_tests( + inventory: &NativeInventory, + observed: &CargoObservations, + origins: &VerifiedPackageOrigins, +) -> Result<(), Report> { + project(inventory, observed, origins).map(|_| ()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/spdx/native/tests.rs b/crates/trusted-server-release/src/spdx/native/tests.rs new file mode 100644 index 0000000000..b5045928a0 --- /dev/null +++ b/crates/trusted-server-release/src/spdx/native/tests.rs @@ -0,0 +1,148 @@ +use super::*; +use crate::composition::UnitContext; +use crate::composition::native::fixture_for_projection; +use crate::composition::origin::VerifiedPackageOrigins; +use sha2::Digest as _; + +#[test] +fn native_license_declarations_require_native_license_evidence() { + for name in ["aws-lc-sys", "ring", "libsqlite3-sys"] { + for context in [UnitContext::Target, UnitContext::Host] { + let (inventory, observed) = fixture_for_projection(name, context); + let origins = VerifiedPackageOrigins::fixture_native_registry(&observed); + let mut components = project(&inventory, &observed, &origins) + .expect("should project measured native inputs"); + components[0].declared_license = Some("MIT".to_owned()); + let executable = b"fictional native executable"; + let result = super::super::render(&super::super::SpdxInputs { + executable, + expected_artifact_sha256: hex::encode(sha2::Sha256::digest(executable)), + name: "example-cli".to_owned(), + version: "1.0.0".to_owned(), + source_commit: "a".repeat(40), + target: crate::policy::ReleaseTarget::Aarch64AppleDarwin, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies: Vec::new(), + }); + assert!( + result.is_err(), + "should reject an unsupported native license declaration for {name} in {context:?}" + ); + } + } +} + +#[test] +fn target_and_host_native_inputs_keep_distinct_roles_and_unknown_implementation_versions() { + for context in [UnitContext::Target, UnitContext::Host] { + let (inventory, observed) = fixture_for_projection("ring", context); + let origins = VerifiedPackageOrigins::fixture_native_registry(&observed); + let components = + project(&inventory, &observed, &origins).expect("should project checked native facts"); + assert_eq!( + components.len(), + 1, + "should project one native wrapper context" + ); + assert!( + components[0] + .roles + .contains(&if context == UnitContext::Target { + ComponentRole::ObservedNative + } else { + ComponentRole::BuildMaterial + }), + "should distinguish target contribution from host build input" + ); + assert_eq!( + components[0].version, None, + "should not reuse Cargo wrapper version as native implementation version" + ); + } +} + +#[test] +fn native_original_archive_checksum_and_measured_outputs_remain_distinct() { + for name in ["aws-lc-sys", "ring", "libsqlite3-sys"] { + let (inventory, observed) = fixture_for_projection(name, UnitContext::Target); + let origins = VerifiedPackageOrigins::fixture_native_registry(&observed); + let components = + project(&inventory, &observed, &origins).expect("should project checked native facts"); + let executable = b"fictional native executable"; + let document = super::super::render(&super::super::SpdxInputs { + executable, + expected_artifact_sha256: hex::encode(sha2::Sha256::digest(executable)), + name: "example-cli".to_owned(), + version: "1.0.0".to_owned(), + source_commit: "a".repeat(40), + target: crate::policy::ReleaseTarget::Aarch64AppleDarwin, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies: Vec::new(), + }) + .expect("should render native original facts against authoritative schema"); + let value: serde_json::Value = + serde_json::from_slice(document.bytes()).expect("should parse SPDX"); + let package = &value["packages"][1]; + assert_eq!( + package["checksums"], + json!([{"algorithm":"SHA256","checksumValue":"a".repeat(64)}]), + "should checksum only the original registry source archive" + ); + assert!( + package.get("versionInfo").is_none(), + "should not label native implementation with Cargo wrapper version" + ); + assert_eq!( + package["downloadLocation"], "NOASSERTION", + "should not invent source archive URL" + ); + assert_eq!( + package["licenseDeclared"], "NOASSERTION", + "should not infer native implementation license from wrapper" + ); + let info = package["sourceInfo"] + .as_str() + .expect("should describe native provenance"); + assert!( + info.contains(inventory.contributors()[0].archives()[0].sha256()), + "should retain measured static output digest as provenance" + ); + assert!( + info.contains(inventory.contributors()[0].source_tree_sha256()), + "should retain structured prepared-tree provenance" + ); + assert!( + info.contains("Cargo wrapper versions do not identify native implementation versions"), + "should label wrapper version limitations" + ); + assert!( + !info.contains(observed.packages()[0].id()), + "should omit opaque package ID" + ); + assert!( + !info.contains("/target/") && !info.contains("/vendor/"), + "should omit private source/output paths" + ); + } +} + +#[test] +fn native_logical_ids_do_not_depend_on_private_source_and_output_roots() { + let identities = (0..2) + .map(|_| { + let (inventory, observed) = fixture_for_projection("ring", UnitContext::Target); + let origins = VerifiedPackageOrigins::fixture_native_registry(&observed); + project(&inventory, &observed, &origins).expect("should project relocated facts")[0] + .identity + .clone() + }) + .collect::>(); + assert_eq!( + identities[0], identities[1], + "should preserve native logical identity across private root relocation" + ); +} diff --git a/crates/trusted-server-release/src/spdx/platform.rs b/crates/trusted-server-release/src/spdx/platform.rs new file mode 100644 index 0000000000..a71e3aed48 --- /dev/null +++ b/crates/trusted-server-release/src/spdx/platform.rs @@ -0,0 +1,447 @@ +//! Conservative projection of sealed installed-tool and logical runtime facts. + +use std::collections::BTreeSet; + +use error_stack::Report; +use serde_json::json; + +use super::{ + Component, ComponentRole, Dependency, Origin, OriginFields, lower_hex, plain, spdx_error, +}; +use crate::composition::CompositionView; +use crate::composition::external::Runtime; +use crate::composition::platform::PlatformEvidence; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::recipe::RecipeCompleted; + +#[cfg(test)] +mod tests; + +/// Borrowed sealed platform evidence with conservative renderer components. +/// +/// Coverage exclusions and incompleteness remain in the exact borrowed receipt; +/// this adapter grants no complete-document or acquisition authority. +pub(crate) struct PlatformProjection<'a> { + view: CompositionView<'a>, + components: Vec, +} + +impl<'a> PlatformProjection<'a> { + /// Projects typed platform measurements after validating their completed owner. + /// + /// # Errors + /// + /// Rejects changed sealed receipts, another artifact, target, source, Rust + /// tree, GNU boundary/plan or an inconsistent Apple SDK observation. + pub(crate) fn from_completed( + completed: &'a RecipeCompleted, + ) -> Result> { + let view = completed.composition_view()?; + let owner = Owner { + target: completed.asset().artifact().target, + artifact: completed.artifact().sha256().to_owned(), + rust_tree: completed.rust_tools_digest().to_owned(), + source: completed.commit().as_str().to_owned(), + receipt_source: view.package_origins().source_commit().to_owned(), + boundary: completed.boundary_fingerprint(), + plan: completed.plan_digest().to_owned(), + }; + let components = project(view.platform(), &owner)?; + Ok(Self { view, components }) + } + /// Returns the number of renderer components without completeness authority. + pub(crate) fn component_count(&self) -> usize { + self.components.len() + } + /// Borrows exact sealed measurements, retaining exclusions and incompleteness. + pub(crate) fn evidence(&self) -> &PlatformEvidence { + self.view.platform() + } + /// Returns the digest of the exact sealed owning receipt. + pub(crate) fn receipt_sha256(&self) -> &str { + self.view.receipt_sha256() + } + /// Returns sorted, unique coverage gaps recorded in the sealed measurements. + pub(super) fn coverage_gaps(&self) -> Vec { + coverage_gaps(self.view.platform()) + } + /// Releases the renderer components; platform facts project no package edges. + pub(super) fn into_parts(self) -> (Vec, Vec) { + (self.components, Vec::new()) + } +} + +/// Names each recorded incompleteness without upgrading any measurement. +fn coverage_gaps(platform: &PlatformEvidence) -> Vec { + let mut gaps = BTreeSet::new(); + if !platform.material_native_coverage_complete() { + gaps.insert("native-material-coverage-incomplete".to_owned()); + } + for role in platform.unresolved_native_tool_roles() { + gaps.insert(format!("native-tool-host-path-unresolved:{role}")); + } + if platform.apple_tool_closure_unmeasured() { + gaps.insert("apple-tool-closure-unmeasured".to_owned()); + } + if let Some(apple) = platform.apple() { + for exclusion in apple.coverage_exclusions() { + gaps.insert(format!("apple-coverage-excluded:{exclusion}")); + } + } + if let Some(gnu) = platform.gnu() { + if gnu.tool_closure_unmeasured() { + gaps.insert("gnu-tool-closure-unmeasured".to_owned()); + } + if gnu.original_acquisition_unverified() { + gaps.insert("gnu-original-acquisition-unverified".to_owned()); + } + if !gnu.content_unmeasured_files().is_empty() { + gaps.insert("gnu-privileged-helper-content-unmeasured".to_owned()); + } + } + if platform.fastly_tools().is_some() { + gaps.insert("fastly-tool-closure-unmeasured".to_owned()); + gaps.insert("fastly-original-acquisition-unverified".to_owned()); + } + gaps.into_iter().collect() +} + +struct Owner { + target: ReleaseTarget, + artifact: String, + rust_tree: String, + source: String, + receipt_source: String, + boundary: [u8; 32], + plan: String, +} + +fn identity( + target: ReleaseTarget, + role: &str, + value: &str, +) -> Result> { + plain(role)?; + plain(value)?; + let key = serde_json::to_string(&json!(["platform-v1", target.triple(), role, value])) + .map_err(spdx_error)?; + plain(&key)?; + Ok(key) +} + +fn component( + target: ReleaseTarget, + role: &str, + value: &str, + name: String, + roles: Vec, + origin: Origin, +) -> Result> { + plain(&name)?; + Ok(Component { + identity: identity(target, role, value)?, + name, + version: None, + roles, + origin: Some(origin), + declared_license: None, + }) +} + +fn project( + platform: &PlatformEvidence, + owner: &Owner, +) -> Result, Report> { + let external = platform.external_requirements(); + if !lower_hex(&owner.source, 40) + || owner.source != owner.receipt_source + || !lower_hex(&owner.artifact, 64) + || !lower_hex(&owner.rust_tree, 64) + || platform.target() != owner.target.triple() + || platform.artifact_sha256() != owner.artifact + || platform.rust().target() != owner.target.triple() + || platform.rust().tree_sha256() != owner.rust_tree + || external.target() != owner.target + || external.artifact_sha256() != owner.artifact + || platform.apple().is_some() != owner.target.is_darwin() + { + return Err(spdx_error( + "platform measurements differ from the sealed source/artifact/target/tool owner", + )); + } + let mut components = vec![component( + owner.target, + "rust-installed-tree", + &owner.rust_tree, + "Measured Rust tool tree".to_owned(), + vec![ComponentRole::BuildMaterial, ComponentRole::BuildTool], + Origin::StructuredTree { + sha256: owner.rust_tree.clone(), + }, + )?]; + if let Some(apple) = platform.apple() { + if external.macho_sdk() != Some(apple.sdk_version()) { + return Err(spdx_error( + "Apple SDK differs from accepted executable requirements", + )); + } + for (role, sha256) in apple.tool_files() { + components.push(measured_file( + owner.target, + &format!("apple-{role}"), + sha256, + ComponentRole::BuildTool, + )?); + } + components.push(measured_file( + owner.target, + "apple-sdk-settings", + apple.sdk_settings_sha256(), + ComponentRole::BuildMaterial, + )?); + if let Some(sha256) = apple.sdk_tree_sha256() { + if !lower_hex(sha256, 64) { + return Err(spdx_error("invalid detached SDK tree identity")); + } + components.push(component( + owner.target, + "apple-sdk-installed-tree", + sha256, + "Measured detached Apple SDK tree".to_owned(), + vec![ComponentRole::BuildMaterial], + Origin::StructuredTree { + sha256: sha256.to_owned(), + }, + )?); + } + if let Some(sha256) = apple.liblto_sha256() { + components.push(measured_file( + owner.target, + "apple-liblto", + sha256, + ComponentRole::BuildTool, + )?); + } + if let Some(sha256) = apple.libtapi_sha256() { + components.push(measured_file( + owner.target, + "apple-libtapi", + sha256, + ComponentRole::BuildTool, + )?); + } + let toolchain_trees = [ + ( + "apple-toolchain-library-tree", + apple.library_tree_sha256(), + "Measured Apple toolchain library directory tree", + ComponentRole::BuildMaterial, + ), + ( + "apple-clang-resource-tree", + apple.clang_resource_tree_sha256(), + "Measured Apple clang resource directory tree", + ComponentRole::BuildMaterial, + ), + ( + "apple-toolchain-helper-tree", + apple.helper_tree_sha256(), + "Measured Apple toolchain helper directory tree", + ComponentRole::BuildTool, + ), + ]; + for (role, digest, name, component_role) in toolchain_trees { + let Some(sha256) = digest else { + continue; + }; + if !lower_hex(sha256, 64) { + return Err(spdx_error("invalid Apple toolchain tree identity")); + } + components.push(component( + owner.target, + role, + sha256, + name.to_owned(), + vec![component_role], + Origin::StructuredTree { + sha256: sha256.to_owned(), + }, + )?); + } + } + if let Some(gnu) = platform.gnu() { + if owner.target != ReleaseTarget::X86_64UnknownLinuxGnu { + return Err(spdx_error("GNU measurements belong to another platform")); + } + gnu.verify_binding(&owner.boundary, &owner.plan) + .map_err(|error| error.change_context(ReleaseError::Spdx))?; + let sha256 = gnu.installed_files_sha256(); + if !lower_hex(sha256, 64) { + return Err(spdx_error("invalid GNU installed-image identity")); + } + components.push(component( + owner.target, + "gnu-installed-image-tree", + sha256, + format!( + "Measured GNU installed image files; {} reviewed privileged helpers bound by metadata only", + gnu.content_unmeasured_files().len() + ), + vec![ComponentRole::BuildMaterial], + Origin::StructuredTree { + sha256: sha256.to_owned(), + }, + )?); + for (role, sha256) in gnu.measured_files() { + let component_role = match role { + "compiler_runtime" => ComponentRole::BuildMaterial, + "compiler" + | "archiver" + | "gcc_linker_candidate" + | "gcc_assembler_candidate" + | "configured_cc1" + | "configured_collect2" + | "configured_as" + | "configured_ld" => ComponentRole::BuildTool, + _ => return Err(spdx_error("GNU measured file role has not been reviewed")), + }; + components.push(measured_file( + owner.target, + &format!("gnu-selected-or-configured-{role}"), + sha256, + component_role, + )?); + } + } + if let Some(fastly) = platform.fastly_tools() { + if owner.target != ReleaseTarget::Wasm32Wasip1 { + return Err(spdx_error("Fastly measurements belong to another platform")); + } + fastly + .verify_binding(&owner.boundary, &owner.plan) + .map_err(|error| error.change_context(ReleaseError::Spdx))?; + for (role, sha256) in fastly.measured_files() { + if ![ + "host_compiler", + "host_archiver", + "configured_host_assembler", + "wasm_rust_linker", + ] + .contains(&role) + { + return Err(spdx_error("Fastly file role has not been reviewed")); + } + components.push(measured_file( + owner.target, + &format!("fastly-selected-or-configured-{role}"), + sha256, + ComponentRole::BuildTool, + )?); + } + } + let mut runtime = |kind: &str, value: &str| -> Result<(), Report> { + components.push(component( + owner.target, + kind, + value, + format!("Logical {kind}: {value}"), + vec![ComponentRole::ExternalRuntime], + Origin::RuntimeObservation { + artifact_sha256: owner.artifact.clone(), + }, + )?); + Ok(()) + }; + match external.runtime() { + Runtime::Elf { + interpreter, + needed, + version_requirements, + max_glibc, + } => { + runtime("ELF loader", interpreter)?; + for library in needed { + runtime("ELF needed library", library)?; + } + for requirement in version_requirements { + runtime( + "ELF symbol version", + &serde_json::to_string(requirement).map_err(spdx_error)?, + )?; + } + runtime("ELF maximum required GLIBC", max_glibc)?; + } + Runtime::Macho { + loader, + cpu, + dylibs, + minimum_os, + sdk, + } => { + runtime("Mach-O loader", loader)?; + runtime("Mach-O CPU", cpu)?; + for library in dylibs { + runtime("Mach-O dylib", library)?; + } + runtime("Mach-O minimum OS", minimum_os)?; + runtime("Mach-O SDK", sdk)?; + } + Runtime::Wasm { import_modules } => { + for module in import_modules { + runtime("Wasm host import", module)?; + } + } + } + Ok(components) +} + +fn measured_file( + target: ReleaseTarget, + role: &str, + sha256: &str, + component_role: ComponentRole, +) -> Result> { + if !lower_hex(sha256, 64) { + return Err(spdx_error("invalid measured platform raw-file identity")); + } + component( + target, + role, + sha256, + format!("Measured platform file ({role})"), + vec![component_role], + Origin::PlatformMeasuredFile { + role: role.to_owned(), + sha256: sha256.to_owned(), + }, + ) +} + +pub(super) fn file_origin_fields( + component: &Component, + role: &str, + sha256: &str, +) -> Result> { + plain(role)?; + if !lower_hex(sha256, 64) + || component.roles.is_empty() + || component.roles.iter().any(|role| { + !matches!( + role, + ComponentRole::BuildMaterial | ComponentRole::BuildTool + ) + }) + { + return Err(spdx_error( + "measured platform files cannot assert original acquisition or runtime bytes", + )); + } + Ok(( + "NOASSERTION".to_owned(), + format!( + "Measured installed platform raw file for {role}; no original acquisition, download location, license or executed helper claim is asserted." + ), + Some(("SHA256", sha256.to_owned())), + )) +} diff --git a/crates/trusted-server-release/src/spdx/platform/tests.rs b/crates/trusted-server-release/src/spdx/platform/tests.rs new file mode 100644 index 0000000000..71393b4fd5 --- /dev/null +++ b/crates/trusted-server-release/src/spdx/platform/tests.rs @@ -0,0 +1,646 @@ +use super::*; +use crate::composition::platform::tests::projection_fixture; +use sha2::{Digest as _, Sha256}; + +#[test] +fn libtapi_projects_only_present_measured_bytes_as_a_build_tool() { + for present in [false, true] { + let platform = + crate::composition::platform::tests::projection_toolchain_with_libtapi_fixture(present); + let components = project(&platform, &Owner::fixture(&platform)) + .expect("should project optional TAPI measurements"); + let tapi = components.iter().find(|component| { + matches!( + &component.origin, + Some(Origin::PlatformMeasuredFile { role, .. }) if role == "apple-libtapi" + ) + }); + assert_eq!( + tapi.is_some(), + present, + "should project only present libtapi" + ); + if let Some(component) = tapi { + assert!( + component.declared_license.is_none(), + "should not invent a libtapi license" + ); + let digest = hex::encode(Sha256::digest(b"fictional libtapi")); + assert!( + component.roles == [ComponentRole::BuildTool], + "should assert only a build tool role" + ); + let (location, comment, checksum) = + file_origin_fields(component, "apple-libtapi", &digest) + .expect("should render measured libtapi bytes"); + assert_eq!(location, "NOASSERTION", "should not claim acquisition"); + assert_eq!( + checksum, + Some(("SHA256", digest.clone())), + "should preserve raw-file checksum" + ); + assert!( + comment.contains("no original acquisition") + && comment.contains("executed helper claim"), + "should retain conservative measurement scope" + ); + assert!( + matches!(&component.origin, Some(Origin::PlatformMeasuredFile { sha256, .. }) if sha256 == &digest), + "should bind measured libtapi bytes" + ); + } + assert!( + coverage_gaps(&platform) + .contains(&"apple-coverage-excluded:toolchain_libraries".to_owned()), + "should retain unresolved library dependency/load closure coverage" + ); + let receipt = serde_json::to_value(&platform).expect("should serialize platform receipt"); + assert_eq!( + receipt["native_tools"]["full_closure_measured"], false, + "should retain incomplete closure" + ); + } +} + +impl Owner { + fn fixture(platform: &crate::composition::platform::PlatformEvidence) -> Self { + Self { + target: platform.external_requirements().target(), + artifact: platform.artifact_sha256().to_owned(), + rust_tree: platform.rust().tree_sha256().to_owned(), + source: "a".repeat(40), + receipt_source: "a".repeat(40), + boundary: [1; 32], + plan: "b".repeat(64), + } + } +} + +#[test] +fn projects_measured_trees_without_raw_checksums_and_runtime_without_byte_claims() { + let platform = projection_fixture(crate::policy::ReleaseTarget::Wasm32Wasip1); + let owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should project measured platform facts"); + let rust = components + .iter() + .find(|component| component.name == "Measured Rust tool tree") + .expect("should project Rust tool tree"); + assert!( + rust.roles.contains(&ComponentRole::BuildTool) + && rust.roles.contains(&ComponentRole::BuildMaterial), + "should preserve both installed Rust roles" + ); + assert!( + matches!(&rust.origin, Some(Origin::StructuredTree { .. })), + "should not treat a structured tree digest as file bytes" + ); + assert!( + components + .iter() + .any(|component| component.roles == [ComponentRole::ExternalRuntime]), + "should project actual Wasm logical imports" + ); + for component in &components { + assert!( + !component.identity.contains("/boundary/") + && !component.identity.contains("/private/") + && !component.identity.contains("/var/"), + "should use stable identities independent of temporary roots" + ); + assert!( + component.declared_license.is_none(), + "should not invent licenses" + ); + } +} + +#[test] +fn rejects_another_artifact_target_rust_tree_or_source_owner() { + let platform = projection_fixture(crate::policy::ReleaseTarget::Wasm32Wasip1); + for field in ["artifact", "target", "rust", "source"] { + let mut owner = Owner::fixture(&platform); + match field { + "artifact" => owner.artifact = "b".repeat(64), + "target" => owner.target = crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu, + "rust" => owner.rust_tree = "c".repeat(64), + "source" => owner.receipt_source = "d".repeat(40), + _ => unreachable!("should select a fixture field"), + } + assert!( + project(&platform, &owner).is_err(), + "should reject mismatched {field} owner" + ); + } +} + +#[test] +fn gnu_projection_requires_the_owning_boundary_and_plan_and_calls_candidates_honestly() { + let platform = projection_fixture(crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu) + .with_gnu(crate::recipe::gnu::GnuEvidence::projection_fixture()) + .expect("should attach fictional typed GNU observations"); + let mut owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should join GNU boundary and plan"); + assert!( + components + .iter() + .any(|component| component.name.contains("gcc_linker_candidate") + && component.roles == [ComponentRole::BuildTool]), + "should retain configured candidate meaning without claiming execution" + ); + owner.boundary = [2; 32]; + assert!( + project(&platform, &owner).is_err(), + "should reject another GNU boundary" + ); + owner.boundary = [1; 32]; + owner.plan = "f".repeat(64); + assert!( + project(&platform, &owner).is_err(), + "should reject another GNU invocation" + ); +} + +#[test] +fn apple_projection_preserves_raw_file_identity_and_logical_runtime_without_private_paths() { + let platform = projection_fixture(crate::policy::ReleaseTarget::Aarch64AppleDarwin); + let owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should project Apple platform"); + assert_eq!( + components + .iter() + .filter(|component| matches!( + &component.origin, + Some(Origin::PlatformMeasuredFile { .. }) + )) + .count(), + 4, + "should project three tools and SDK settings as raw files" + ); + assert!( + components.iter().any(|component| component.name + == format!( + "Logical Mach-O SDK: {}", + platform + .external_requirements() + .macho_sdk() + .expect("should preserve accepted SDK") + )), + "should preserve accepted SDK requirement" + ); + for component in &components { + assert!( + !component.identity.contains("MacOSX.platform") + && !component.identity.contains("/private/"), + "should exclude private SDK and tool paths from identity" + ); + } +} + +#[test] +fn measured_file_renderer_asserts_only_bytes_and_rejects_runtime_roles() { + let mut component = measured_file( + crate::policy::ReleaseTarget::Wasm32Wasip1, + "fictional-tool", + &"a".repeat(64), + ComponentRole::BuildTool, + ) + .expect("should describe installed file"); + let (location, information, checksum) = + file_origin_fields(&component, "fictional-tool", &"a".repeat(64)) + .expect("should render measured bytes"); + assert_eq!( + location, "NOASSERTION", + "should not invent original acquisition location" + ); + assert_eq!( + checksum, + Some(("SHA256", "a".repeat(64))), + "should checksum measured raw bytes" + ); + assert!( + information.contains("no original acquisition") && information.contains("executed helper"), + "should preserve limits of measured facts" + ); + component.roles = vec![ComponentRole::ExternalRuntime]; + assert!( + file_origin_fields(&component, "fictional-tool", &"a".repeat(64)).is_err(), + "should never assign installed bytes to logical runtime requirements" + ); +} + +#[test] +fn detached_sdk_is_structured_material_without_raw_file_checksum() { + let platform = crate::composition::platform::tests::projection_detached_sdk_fixture(); + let owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should project detached SDK"); + let sdk = components + .iter() + .find(|component| component.name == "Measured detached Apple SDK tree") + .expect("should retain SDK tree"); + assert!( + sdk.roles == [ComponentRole::BuildMaterial] + && matches!(&sdk.origin, Some(Origin::StructuredTree { .. })), + "should preserve SDK tree evidence as structured material" + ); + assert!( + platform + .apple() + .expect("should retain Apple receipt") + .sdk_tree_sha256() + .is_some(), + "should borrow original SDK tree receipt" + ); +} + +#[test] +fn measured_toolchain_projects_library_resource_and_helper_trees_and_retains_closure_gaps() { + let platform = crate::composition::platform::tests::projection_measured_toolchain_fixture(); + let owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should project measured toolchain"); + let apple = platform.apple().expect("should retain Apple receipt"); + for (name, sha256, role) in [ + ( + "Measured Apple toolchain library directory tree", + apple.library_tree_sha256(), + ComponentRole::BuildMaterial, + ), + ( + "Measured Apple clang resource directory tree", + apple.clang_resource_tree_sha256(), + ComponentRole::BuildMaterial, + ), + ( + "Measured Apple toolchain helper directory tree", + apple.helper_tree_sha256(), + ComponentRole::BuildTool, + ), + ] { + let sha256 = sha256.expect("should hold the measured tree digest"); + let tree = components + .iter() + .find(|component| component.name == name) + .unwrap_or_else(|| panic!("should project {name}")); + assert!( + tree.roles == [role] + && matches!(&tree.origin, Some(Origin::StructuredTree { sha256: digest }) if digest == sha256), + "should project {name} as a structured tree" + ); + assert!( + tree.declared_license.is_none(), + "should not invent a tree license" + ); + if name == "Measured Apple toolchain library directory tree" { + let inputs = super::super::SpdxInputs { + executable: b"", + expected_artifact_sha256: owner.artifact.clone(), + name: "fictional-artifact".to_owned(), + version: "1.0.0".to_owned(), + source_commit: owner.source.clone(), + target: owner.target, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components: vec![], + dependencies: vec![], + }; + let (location, comment, checksum) = super::super::origin_fields(tree, &inputs) + .expect("should render structured library material"); + assert_eq!(location, "NOASSERTION", "should not invent acquisition"); + assert!( + checksum.is_none(), + "should not render a raw-file checksum for a tree" + ); + assert!( + comment.contains("Structured installed-tree evidence"), + "should state only installed-tree evidence" + ); + assert_eq!( + tree.identity, + identity(owner.target, "apple-toolchain-library-tree", sha256) + .expect("should construct stable library material identity"), + "should retain stable library tree role" + ); + } + } + assert!( + components + .iter() + .any(|component| component.name == "Measured platform file (apple-liblto)"), + "should project libLTO as a measured file" + ); + let gaps = coverage_gaps(&platform); + for measured in ["tool_helpers", "liblto", "clang_resource_directory"] { + assert!( + !gaps.contains(&format!("apple-coverage-excluded:{measured}")), + "should not state measured {measured} as excluded" + ); + } + for retained in [ + "apple-coverage-excluded:toolchain_libraries", + "apple-tool-closure-unmeasured", + ] { + assert!( + gaps.iter().any(|gap| gap == retained), + "should retain {retained}" + ); + } +} + +#[test] +fn rendered_projection_preserves_runtime_constraints_without_runtime_byte_checksums() { + let platform = projection_fixture(crate::policy::ReleaseTarget::Wasm32Wasip1); + let owner = Owner::fixture(&platform); + let executable = + crate::inspect::tests::builders::wasm_module(&[("fastly_http_req", "downstream_get")]); + let components = project(&platform, &owner).expect("should project platform"); + let inputs = super::super::SpdxInputs { + executable: &executable, + expected_artifact_sha256: owner.artifact, + name: "fictional-artifact".to_owned(), + version: "1.0.0".to_owned(), + source_commit: owner.source, + target: owner.target, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies: vec![], + }; + let rendered = + super::super::render(&inputs).expect("should render schema-valid fictional projection"); + let json: serde_json::Value = + serde_json::from_slice(rendered.bytes()).expect("should inspect rendered document"); + let runtime = json["packages"] + .as_array() + .expect("should retain packages") + .iter() + .find(|package| package["name"] == "Logical Wasm host import: fastly_http_req") + .expect("should retain exact runtime requirement"); + assert!( + runtime.get("checksums").is_none(), + "should never checksum logical runtime bytes" + ); + assert_eq!( + runtime["downloadLocation"], "NOASSERTION", + "should not invent runtime acquisition" + ); +} + +#[test] +fn compiler_runtime_archive_is_a_build_dependency_with_no_tool_or_retention_claim() { + let platform = projection_fixture(crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu) + .with_gnu(crate::recipe::gnu::GnuEvidence::projection_fixture()) + .expect("should attach fictional GNU receipt"); + let owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should project installed GNU facts"); + let runtime = components + .iter() + .find(|component| component.name.contains("compiler_runtime")) + .expect("should project measured compiler runtime archive"); + assert!( + runtime.roles == [ComponentRole::BuildMaterial], + "should classify libgcc.a as build material rather than an executed tool" + ); + let id = super::super::component_id(&runtime.identity); + let executable = crate::inspect::tests::builders::elf( + &crate::inspect::tests::builders::ElfSpec::conforming(), + ); + let inputs = super::super::SpdxInputs { + executable: &executable, + expected_artifact_sha256: owner.artifact, + name: "fictional-artifact".to_owned(), + version: "1.0.0".to_owned(), + source_commit: owner.source, + target: owner.target, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components, + dependencies: vec![], + }; + let rendered = super::super::render(&inputs).expect("should render measured GNU projection"); + let json: serde_json::Value = + serde_json::from_slice(rendered.bytes()).expect("should inspect rendered relationships"); + let relationships = json["relationships"] + .as_array() + .expect("should retain relationships"); + assert!( + relationships + .iter() + .any(|relation| relation["spdxElementId"] == id + && relation["relationshipType"] == "BUILD_DEPENDENCY_OF" + && relation["comment"] + .as_str() + .is_some_and(|comment| comment.contains("retention is not asserted"))), + "should preserve archive as a measured build dependency without retention inference" + ); + assert!( + !relationships + .iter() + .any(|relation| relation["spdxElementId"] == id + && matches!( + relation["relationshipType"].as_str(), + Some("BUILD_TOOL_OF" | "RUNTIME_DEPENDENCY_OF" | "CONTAINS") + )), + "should not classify compiler runtime archive as tool, logical runtime, or retained bytes" + ); + let package = json["packages"] + .as_array() + .expect("should retain packages") + .iter() + .find(|package| package["SPDXID"] == id) + .expect("should retain measured archive package"); + assert_eq!( + package["checksums"][0]["checksumValue"], + "c".repeat(64), + "should retain measured archive raw-byte identity" + ); + assert_eq!( + package["downloadLocation"], "NOASSERTION", + "should not invent original archive acquisition" + ); +} + +#[test] +fn new_gnu_file_roles_require_review_before_projection() { + let platform = projection_fixture(crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu) + .with_gnu(crate::recipe::gnu::GnuEvidence::projection_fixture_with_unknown_file_role()) + .expect("should attach fictional unknown resource"); + assert!( + project(&platform, &Owner::fixture(&platform)).is_err(), + "should reject new resources until their component role is reviewed" + ); +} + +#[test] +fn coverage_gaps_name_unresolved_fastly_tools_and_incomplete_native_materials() { + let platform = projection_fixture(crate::policy::ReleaseTarget::Wasm32Wasip1); + let gaps = coverage_gaps(&platform); + for expected in [ + "native-material-coverage-incomplete", + "native-tool-host-path-unresolved:compiler", + "native-tool-host-path-unresolved:linker", + "native-tool-host-path-unresolved:build_tools", + ] { + assert!( + gaps.iter().any(|gap| gap == expected), + "should state gap {expected}" + ); + } + assert!( + gaps.windows(2).all(|pair| pair[0] < pair[1]), + "should list gaps sorted and unique" + ); + assert!( + !gaps.iter().any(|gap| gap.starts_with("apple-")), + "should not invent Apple gaps for a Wasm target" + ); +} + +#[test] +fn coverage_gaps_retain_gnu_tool_closure_without_unreadable_helpers() { + let platform = projection_fixture(crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu) + .with_gnu(crate::recipe::gnu::GnuEvidence::projection_fixture()) + .expect("should attach fictional GNU measurements"); + assert!( + platform + .gnu() + .expect("should hold GNU evidence") + .content_unmeasured_files() + .is_empty(), + "should exercise fully readable installed files" + ); + let gaps = coverage_gaps(&platform); + assert!( + gaps.iter().any(|gap| gap == "gnu-tool-closure-unmeasured"), + "should retain the unmeasured GNU tool closure" + ); + assert!( + gaps.windows(2).all(|pair| pair[0] < pair[1]), + "should list gaps sorted and unique" + ); +} + +#[test] +fn coverage_gaps_retain_gnu_unverified_acquisition_without_unreadable_helpers() { + let platform = projection_fixture(crate::policy::ReleaseTarget::X86_64UnknownLinuxGnu) + .with_gnu(crate::recipe::gnu::GnuEvidence::projection_fixture()) + .expect("should attach fictional GNU measurements"); + let gaps = coverage_gaps(&platform); + assert!( + gaps.iter() + .any(|gap| gap == "gnu-original-acquisition-unverified"), + "should retain unverified GNU original acquisition" + ); + assert!( + !gaps + .iter() + .any(|gap| gap == "gnu-privileged-helper-content-unmeasured"), + "should not invent unreadable helper content" + ); +} + +#[test] +fn coverage_gaps_do_not_invent_gnu_measurements_for_other_platforms() { + for target in [ + crate::policy::ReleaseTarget::Wasm32Wasip1, + crate::policy::ReleaseTarget::Aarch64AppleDarwin, + crate::policy::ReleaseTarget::X86_64AppleDarwin, + ] { + let platform = projection_fixture(target); + assert!( + !coverage_gaps(&platform) + .iter() + .any(|gap| gap.starts_with("gnu-")), + "should not invent GNU gaps for {}", + target.triple() + ); + } +} + +#[test] +fn coverage_gaps_retain_apple_exclusions_and_unmeasured_tool_closure() { + let platform = projection_fixture(crate::policy::ReleaseTarget::Aarch64AppleDarwin); + let gaps = coverage_gaps(&platform); + assert!( + gaps.iter() + .any(|gap| gap == "apple-tool-closure-unmeasured"), + "should state the unmeasured Apple tool closure" + ); + let apple = platform.apple().expect("should hold Apple evidence"); + assert!( + !apple.coverage_exclusions().is_empty(), + "should record fixture exclusions" + ); + for exclusion in apple.coverage_exclusions() { + let expected = format!("apple-coverage-excluded:{exclusion}"); + assert!( + gaps.contains(&expected), + "should state Apple exclusion {exclusion}" + ); + } + assert!( + !gaps + .iter() + .any(|gap| gap.starts_with("native-tool-host-path-unresolved:")), + "should not report unresolved host paths for explicit Apple files" + ); +} + +#[test] +fn fastly_selected_tools_project_with_owner_binding_and_conservative_gaps() { + let platform = projection_fixture(ReleaseTarget::Wasm32Wasip1) + .with_fastly_tools(crate::recipe::fastly_tools::FastlyToolEvidence::projection_fixture()) + .expect("should attach fictional Fastly selections"); + let mut owner = Owner::fixture(&platform); + let components = project(&platform, &owner).expect("should project fixed host files"); + let files = components + .iter() + .filter(|component| matches!(component.origin, Some(Origin::PlatformMeasuredFile { .. }))) + .collect::>(); + assert_eq!( + files.len(), + 4, + "should project four selected or configured files" + ); + for component in files { + assert!( + component.roles == [ComponentRole::BuildTool], + "should retain installed tool scope" + ); + assert!( + component.declared_license.is_none(), + "should not invent acquisition or license" + ); + assert!( + !component.identity.contains("/boundary"), + "should exclude private paths from identity" + ); + } + let gaps = coverage_gaps(&platform); + for gap in [ + "native-tool-host-path-unresolved:assembler", + "native-tool-host-path-unresolved:build_tools", + "fastly-tool-closure-unmeasured", + "native-material-coverage-incomplete", + ] { + assert!( + gaps.contains(&gap.to_owned()), + "should retain honest gap {gap}" + ); + } + for role in ["compiler", "archiver", "linker"] { + assert!( + !gaps.contains(&format!("native-tool-host-path-unresolved:{role}")), + "should resolve selected host role {role}" + ); + } + owner.boundary = [2; 32]; + assert!( + project(&platform, &owner).is_err(), + "should reject another boundary" + ); + owner.boundary = [1; 32]; + owner.plan = "f".repeat(64); + assert!( + project(&platform, &owner).is_err(), + "should reject another plan" + ); +} diff --git a/crates/trusted-server-release/src/spdx/tests.rs b/crates/trusted-server-release/src/spdx/tests.rs new file mode 100644 index 0000000000..4dbe3a681f --- /dev/null +++ b/crates/trusted-server-release/src/spdx/tests.rs @@ -0,0 +1,656 @@ +use serde_json::{Value, json}; +use sha2::Sha256; + +use super::*; + +const EXECUTABLE: &[u8] = b"fictional final executable bytes"; + +fn fixture() -> SpdxInputs<'static> { + let digest = hex::encode(Sha256::digest(EXECUTABLE)); + SpdxInputs { + executable: EXECUTABLE, + expected_artifact_sha256: digest.clone(), + name: "example-artifact".to_owned(), + version: "1.0.0".to_owned(), + source_commit: "a".repeat(40), + target: ReleaseTarget::Wasm32Wasip1, + created: "2026-01-01T00:00:00Z".to_owned(), + document_comment: None, + components: vec![ + component( + "rust:example", + ComponentRole::ObservedRust, + Origin::Source { + commit: "a".repeat(40), + path: "crates/example".to_owned(), + }, + ), + component( + "js:core", + ComponentRole::RetainedJavascript, + Origin::Distribution { + location: "https://example.com/core.tgz".to_owned(), + sha256: "b".repeat(64), + }, + ), + component( + "native:example", + ComponentRole::ObservedNative, + Origin::Distribution { + location: "https://example.com/native.tgz".to_owned(), + sha256: "c".repeat(64), + }, + ), + component( + "prepared:discarded", + ComponentRole::BuildMaterial, + Origin::Source { + commit: "a".repeat(40), + path: "src/discarded.ts".to_owned(), + }, + ), + component( + "tool:example", + ComponentRole::BuildTool, + Origin::StructuredTree { + sha256: "d".repeat(64), + }, + ), + component( + "runtime:example", + ComponentRole::ExternalRuntime, + Origin::RuntimeObservation { + artifact_sha256: digest, + }, + ), + ], + dependencies: vec![], + } +} + +fn component(identity: &str, role: ComponentRole, origin: Origin) -> Component { + Component { + identity: identity.to_owned(), + name: identity.to_owned(), + version: Some("1.0.0".to_owned()), + roles: vec![role], + origin: Some(origin), + declared_license: Some("MIT".to_owned()), + } +} + +fn document(inputs: &SpdxInputs<'_>) -> Value { + serde_json::from_slice( + render(inputs) + .expect("should render fictional complete inputs") + .bytes(), + ) + .expect("should parse generated SPDX") +} + +fn package<'a>(document: &'a Value, name: &str) -> &'a Value { + document["packages"] + .as_array() + .expect("should contain packages") + .iter() + .find(|package| package["name"] == name) + .expect("should find component package") +} + +fn relation(document: &Value, from: &str, kind: &str, to: &str) -> bool { + document["relationships"] + .as_array() + .expect("should contain relationships") + .iter() + .any(|relation| { + relation["spdxElementId"] == from + && relation["relationshipType"] == kind + && relation["relatedSpdxElement"] == to + }) +} + +#[test] +fn renders_schema_valid_document_with_exact_executable_root_and_document_digest() { + let inputs = fixture(); + let output = render(&inputs).expect("should render fictional complete inputs"); + let doc: Value = serde_json::from_slice(output.bytes()).expect("should parse generated bytes"); + crate::documents::validate_spdx(&doc) + .expect("should validate against the pinned authoritative schema"); + assert_eq!( + doc["spdxVersion"], "SPDX-2.3", + "should use the required SPDX version" + ); + assert_eq!( + doc["SPDXID"], "SPDXRef-DOCUMENT", + "should use the document identifier" + ); + let root = package(&doc, "example-artifact"); + assert_eq!( + root["checksums"], + json!([{"algorithm":"SHA256","checksumValue":inputs.expected_artifact_sha256}]), + "should bind root only to raw executable bytes" + ); + assert_eq!( + doc["documentDescribes"], + json!([root["SPDXID"]]), + "should describe exactly one root package" + ); + assert_eq!( + output.sha256(), + hex::encode(Sha256::digest(output.bytes())), + "should hash exact document bytes separately" + ); + assert!( + output.bytes().ends_with(b"\n"), + "should terminate bounded canonical JSON" + ); +} + +#[test] +fn distinguishes_observed_contributors_retained_js_materials_tools_and_external_requirements() { + let doc = document(&fixture()); + let root = package(&doc, "example-artifact")["SPDXID"] + .as_str() + .expect("should have root ID"); + for (name, kind, component_first) in [ + ("rust:example", "GENERATED_FROM", false), + ("native:example", "GENERATED_FROM", false), + ("js:core", "CONTAINS", false), + ("prepared:discarded", "BUILD_DEPENDENCY_OF", true), + ("tool:example", "BUILD_TOOL_OF", true), + ("runtime:example", "RUNTIME_DEPENDENCY_OF", true), + ] { + let id = package(&doc, name)["SPDXID"] + .as_str() + .expect("should identify component"); + assert!( + if component_first { + relation(&doc, id, kind, root) + } else { + relation(&doc, root, kind, id) + }, + "should preserve {name} role" + ); + } + assert!( + package(&doc, "tool:example").get("checksums").is_none(), + "should never mislabel a structured tree digest as a raw file checksum" + ); + assert!( + package(&doc, "tool:example")["sourceInfo"] + .as_str() + .expect("should describe measured tree") + .contains(&"d".repeat(64)), + "should preserve tree evidence without changing checksum meaning" + ); + assert_eq!( + package(&doc, "rust:example")["filesAnalyzed"], + false, + "should not claim file license analysis" + ); + assert!( + package(&doc, "rust:example") + .get("packageVerificationCode") + .is_none(), + "should omit file verification code without file analysis" + ); +} + +#[test] +fn stable_ids_and_document_bytes_do_not_depend_on_input_component_order() { + let inputs = fixture(); + let first = render(&inputs).expect("should render sorted inputs"); + let mut reordered = fixture(); + reordered.components.reverse(); + assert_eq!( + first.bytes(), + render(&reordered) + .expect("should render reordered inputs") + .bytes(), + "should sort identities and relationships deterministically" + ); + let doc = document(&inputs); + let ids: Vec<_> = doc["packages"] + .as_array() + .expect("should contain packages") + .iter() + .map(|package| package["SPDXID"].as_str().expect("should contain SPDX ID")) + .collect(); + assert_eq!( + ids.len(), + ids.iter().collect::>().len(), + "should issue unique stable SPDX IDs" + ); +} + +#[test] +fn one_cargo_identity_can_be_both_observed_target_and_host_build_tool() { + let mut inputs = fixture(); + inputs.components[0].roles.push(ComponentRole::BuildTool); + let first = render(&inputs).expect("should render both roles of one Cargo identity"); + let doc: Value = + serde_json::from_slice(first.bytes()).expect("should parse dual-role document"); + let root = package(&doc, "example-artifact")["SPDXID"] + .as_str() + .expect("should identify artifact"); + let identity = package(&doc, "rust:example")["SPDXID"] + .as_str() + .expect("should identify Cargo package"); + assert!( + relation(&doc, root, "GENERATED_FROM", identity), + "should retain observed target contributor role" + ); + assert!( + relation(&doc, identity, "BUILD_TOOL_OF", root), + "should retain host build tool role" + ); + assert_eq!( + doc["packages"] + .as_array() + .expect("should contain packages") + .iter() + .filter(|package| package["name"] == "rust:example") + .count(), + 1, + "should represent one exact Cargo identity with one SPDX package" + ); + inputs.components[0].roles.reverse(); + assert_eq!( + first.bytes(), + render(&inputs) + .expect("should render reversed roles") + .bytes(), + "should preserve stable bytes independently of role order" + ); +} + +#[test] +fn component_roles_must_be_nonempty_unique_and_compatible_with_origin() { + for mutation in ["empty", "duplicate", "runtime_mixed", "tree_contributor"] { + let mut inputs = fixture(); + match mutation { + "empty" => inputs.components[0].roles.clear(), + "duplicate" => inputs.components[0].roles.push(ComponentRole::ObservedRust), + "runtime_mixed" => inputs.components[5] + .roles + .push(ComponentRole::BuildMaterial), + "tree_contributor" => inputs.components[4].roles.push(ComponentRole::ObservedRust), + _ => unreachable!(), + } + assert!( + render(&inputs).is_err(), + "should reject {mutation} component roles" + ); + } +} + +#[test] +fn normal_and_build_dependencies_preserve_direction_and_reject_duplicates() { + let mut inputs = fixture(); + inputs.dependencies = vec![ + Dependency { + from: "rust:example".to_owned(), + to: "native:example".to_owned(), + build: false, + }, + Dependency { + from: "rust:example".to_owned(), + to: "tool:example".to_owned(), + build: true, + }, + ]; + let first = render(&inputs).expect("should render normal and build dependency edges"); + let doc: Value = + serde_json::from_slice(first.bytes()).expect("should parse dependency document"); + let parent = package(&doc, "rust:example")["SPDXID"] + .as_str() + .expect("should identify parent package"); + let dependency = package(&doc, "native:example")["SPDXID"] + .as_str() + .expect("should identify ordinary dependency"); + let build = package(&doc, "tool:example")["SPDXID"] + .as_str() + .expect("should identify build dependency"); + assert!( + relation(&doc, parent, "DEPENDS_ON", dependency), + "should point normal dependency from parent to dependency" + ); + assert!( + relation(&doc, build, "BUILD_DEPENDENCY_OF", parent), + "should point build dependency toward its consuming parent" + ); + inputs.dependencies.reverse(); + assert_eq!( + first.bytes(), + render(&inputs) + .expect("should render reordered dependencies") + .bytes(), + "should sort dependency relationships independently of input order" + ); + inputs.dependencies.push(Dependency { + from: "rust:example".to_owned(), + to: "native:example".to_owned(), + build: false, + }); + assert!( + render(&inputs).is_err(), + "should reject duplicate dependency edges" + ); +} + +#[test] +fn rejects_root_digest_changes_duplicate_identities_missing_origins_and_dangling_edges() { + for mutation in [ + "digest", + "duplicate", + "origin", + "dangling", + "runtime_digest", + "origin_role", + "invalid_license", + ] { + let mut inputs = fixture(); + match mutation { + "digest" => inputs.expected_artifact_sha256 = "f".repeat(64), + "duplicate" => inputs.components[1].identity = inputs.components[0].identity.clone(), + "origin" => inputs.components[0].origin = None, + "dangling" => inputs.dependencies.push(Dependency { + from: "rust:example".to_owned(), + to: "missing".to_owned(), + build: false, + }), + "runtime_digest" => { + inputs.components[5].origin = Some(Origin::RuntimeObservation { + artifact_sha256: "f".repeat(64), + }) + } + "origin_role" => { + inputs.components[0].origin = Some(Origin::RuntimeObservation { + artifact_sha256: inputs.expected_artifact_sha256.clone(), + }) + } + "invalid_license" => { + inputs.components[0].declared_license = Some("MIT\0injected".to_owned()) + } + _ => unreachable!(), + } + assert!(render(&inputs).is_err(), "should reject {mutation}"); + } +} + +#[test] +fn declared_license_facts_remain_exact_without_becoming_license_conclusions() { + for declaration in [ + "MIT", + "Apache-2.0", + "MIT OR Apache-2.0", + "Apache-2.0 OR MIT", + ] { + let mut inputs = fixture(); + inputs.components[0].declared_license = Some(declaration.to_owned()); + let doc = document(&inputs); + let observed = package(&doc, "rust:example"); + assert_eq!( + observed["licenseDeclared"], declaration, + "should retain the exact reviewed observed declaration" + ); + assert_eq!( + observed["licenseConcluded"], "NOASSERTION", + "should not turn a declaration into license analysis" + ); + assert_eq!( + observed["filesAnalyzed"], false, + "should not imply license-file inspection" + ); + } + for declaration in [ + None, + Some("Example unreviewed license"), + Some("MIT AND LicenseRef-Example-Unreviewed"), + Some("NONE"), + ] { + let mut inputs = fixture(); + inputs.components[0].declared_license = declaration.map(str::to_owned); + let doc = document(&inputs); + let observed = package(&doc, "rust:example"); + assert_eq!( + observed["licenseDeclared"], "NOASSERTION", + "should not infer a declaration from missing or unreviewed facts" + ); + assert_eq!( + observed["licenseConcluded"], "NOASSERTION", + "should not infer a legal conclusion or license absence" + ); + if let Some(declaration) = declaration { + assert!( + observed["licenseComments"] + .as_str() + .expect("should retain bounded declaration context") + .contains(declaration), + "should preserve an unreviewed declaration without reinterpreting it" + ); + } + } +} + +#[test] +fn unknown_licenses_use_noassertion_and_never_become_unsupported_expressions() { + let mut inputs = fixture(); + inputs.components[0].declared_license = Some("Example unknown license".to_owned()); + inputs.components[1].declared_license = None; + let doc = document(&inputs); + for name in ["rust:example", "js:core"] { + assert_eq!( + package(&doc, name)["licenseDeclared"], + "NOASSERTION", + "should not invent a declared license for {name}" + ); + assert_eq!( + package(&doc, name)["licenseConcluded"], + "NOASSERTION", + "should not claim a license conclusion" + ); + } + assert!( + package(&doc, "rust:example")["licenseComments"] + .as_str() + .expect("should explain unknown declaration") + .contains("Example unknown license"), + "should retain bounded observed declaration as an explanation" + ); + assert_eq!( + package(&doc, "native:example")["licenseDeclared"], + "MIT", + "should preserve a reviewed exact known declaration" + ); +} + +#[test] +fn generated_js_requires_a_known_producer_and_retains_generated_bytes_identity() { + let mut inputs = fixture(); + inputs.components.push(component( + "generated:helper", + ComponentRole::BuildMaterial, + Origin::Generated { + producer: "tool:example".to_owned(), + sha256: "e".repeat(64), + }, + )); + let doc = document(&inputs); + let generated = package(&doc, "generated:helper"); + assert_eq!( + generated["checksums"][0]["checksumValue"], + "e".repeat(64), + "should preserve hash of actual generated code bytes" + ); + assert!( + relation( + &doc, + package(&doc, "tool:example")["SPDXID"] + .as_str() + .expect("should identify producer"), + "GENERATES", + generated["SPDXID"] + .as_str() + .expect("should identify generated module") + ), + "should retain generated producer relationship" + ); + inputs + .components + .last_mut() + .expect("should contain generated helper") + .origin = Some(Origin::Generated { + producer: "missing".to_owned(), + sha256: "e".repeat(64), + }); + assert!( + render(&inputs).is_err(), + "should reject unknown virtual/plugin producer" + ); +} + +#[test] +fn rejects_invalid_source_identity_and_creation_time() { + for mutation in [ + "commit", + "empty_artifact", + "timestamp", + "calendar", + "source_escape", + "source_alias", + ] { + let mut inputs = fixture(); + match mutation { + "commit" => inputs.source_commit = "short".to_owned(), + "empty_artifact" => inputs.executable = b"", + "timestamp" => inputs.created = "yesterday".to_owned(), + "calendar" => inputs.created = "2026-02-30T00:00:00Z".to_owned(), + "source_escape" => { + inputs.components[0].origin = Some(Origin::Source { + commit: "a".repeat(40), + path: "../outside".to_owned(), + }) + } + "source_alias" => { + inputs.components[0].origin = Some(Origin::Source { + commit: "a".repeat(40), + path: "src//example.rs".to_owned(), + }) + } + _ => unreachable!(), + } + assert!(render(&inputs).is_err(), "should reject {mutation}"); + } +} + +#[test] +fn rejects_an_spdx_document_exceeding_the_attestation_byte_limit() { + let mut inputs = fixture(); + for index in 0..4096 { + let mut item = component( + &format!("oversize:{index}"), + ComponentRole::BuildMaterial, + Origin::Source { + commit: "a".repeat(40), + path: format!("source/{index}"), + }, + ); + item.name = "x".repeat(8192); + inputs.components.push(item); + } + let error = + render(&inputs).expect_err("should reject oversized predicates before returning bytes"); + assert!( + format!("{error:?}").contains("output exceeds role byte limit"), + "should exercise the 16 MiB serialization bound" + ); +} + +#[test] +fn verified_cargo_originals_render_without_inventing_download_locations() { + let mut inputs = fixture(); + inputs.components = vec![ + component( + "cargo:registry", + ComponentRole::ObservedRust, + Origin::CargoMaterial { + source: "registry+https://example.com/index".to_owned(), + original: CargoOrigin::Registry { + archive_sha256: "b".repeat(64), + }, + prepared_manifest_sha256: "c".repeat(64), + }, + ), + component( + "cargo:git", + ComponentRole::BuildTool, + Origin::CargoMaterial { + source: format!("git+https://example.com/repo#{}", "a".repeat(40)), + original: CargoOrigin::Git { + revision: "a".repeat(40), + repository_tree: "b".repeat(40), + package_tree: "c".repeat(40), + package_path: String::new(), + committed_manifest_blob: "d".repeat(40), + committed_manifest_sha256: "e".repeat(64), + normalized_manifest_sha256: None, + }, + prepared_manifest_sha256: "f".repeat(64), + }, + ), + ]; + let rendered = render(&inputs).expect("should render schema-valid verified Cargo originals"); + let document: Value = + serde_json::from_slice(rendered.bytes()).expect("should parse rendered document"); + let packages = document["packages"] + .as_array() + .expect("should have packages"); + let registry = packages + .iter() + .find(|package| package["name"] == "cargo:registry") + .expect("should include registry"); + let git = packages + .iter() + .find(|package| package["name"] == "cargo:git") + .expect("should include Git"); + assert_eq!( + registry["downloadLocation"], "NOASSERTION", + "should avoid treating index as archive URL" + ); + assert_eq!( + registry["checksums"][0]["checksumValue"], + "b".repeat(64), + "should identify original archive only" + ); + assert!( + git.get("checksums").is_none(), + "should not label Git object or manifest evidence as raw package SHA256" + ); + assert!( + git["sourceInfo"] + .as_str() + .expect("should have source information") + .contains(&"d".repeat(40)), + "should preserve original Git facts" + ); +} + +#[test] +fn javascript_preminification_contributors_do_not_claim_complete_module_retention() { + let mut inputs = fixture(); + inputs.components[0].roles = vec![ComponentRole::ConservativeJavascript]; + let output = render(&inputs).expect("should render conservative input"); + let value: Value = serde_json::from_slice(output.bytes()).expect("should parse SPDX"); + assert!( + value["relationships"] + .as_array() + .expect("should contain relationships") + .iter() + .any(|edge| edge["relationshipType"] == "GENERATED_FROM" + && edge["comment"] + .as_str() + .is_some_and(|text| text.contains("minification"))), + "should preserve pre-minification limitation" + ); +} From 4741ab712365efa9f578a9b461f9a26a48bb9c5d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 18:12:07 +0530 Subject: [PATCH 6/8] Record Phase 3 port boundaries deviations and test evidence --- ...se3-seven-artifact-index-implementation.md | 10 +- .../binary-attestations-phase3-step1-port.md | 360 ++++++++ .../plans/binary-attestations-phases-2-4.md | 26 +- .../plans/binary-attestations-slice-map.json | 874 +++++++++++++++++- ...-1235-binary-attestations-phase3-design.md | 45 + 5 files changed, 1278 insertions(+), 37 deletions(-) create mode 100644 docs/superpowers/plans/binary-attestations-phase3-step1-port.md create mode 100644 docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase3-design.md diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md index dc6ead87d1..e6f30566c6 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md @@ -1,6 +1,6 @@ # Gate 3 implementation plan: versioned seven-artifact index -**Status:** Proposed plan awaiting user approval. The Gate 3 design is approved with a new neutral v2 cohort ID, `trusted-server-binaries`. This plan authorizes no implementation, new schema/policy file, production selector or rehearsal tag. Consumer implementation remains Phase 4; live signing/publication and fork trust remain Gates 4/6. +**Status:** Step 1 approved on 2026-10-28 for frozen-v1 regression checks, the assigned source port and restored assertions only. The Gate 3 design is approved with a new neutral v2 cohort ID, `trusted-server-binaries`. No v2 schema/policy/cohort/descriptor/admission/assembly, workflow, release script, signing, production selector or rehearsal tag is authorized. Consumer implementation remains Phase 4; live signing/publication and fork trust remain Gates 4/6. **Tracking:** Part of #1235; draft #1463. Read with the [approved design](binary-attestations-phase3-seven-artifact-index.md), [Gate 2 coverage/rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) and [phase assignment](binary-attestations-phases-2-4.md). @@ -19,7 +19,7 @@ Existing v1 policy and schema currently match frozen source `122b78664b1235c717e ## Explicit meaning of `provenance-sbom-repeatable` -**Proposed v2 definition:** authenticated provenance and the approved composition/SPDX evidence, plus mandatory matched complete-final-byte A/B equality under an explicitly approved comparison profile. For the Fastly default row, the accepted ordinary profile is exactly `ordinary-release-fastly-byte-equality-v1`. +**Approved v2 definition:** authenticated provenance and the approved composition/SPDX evidence, plus mandatory matched complete-final-byte A/B equality under an explicitly approved comparison profile. For the Fastly default row, the accepted ordinary profile is exactly `ordinary-release-fastly-byte-equality-v1`. That ordinary profile proves the project's observed ordinary-build repeatability with its recorded source/input/tool/environment/run/transfer joins. It does **not** establish controlled-recipe, acquisition, confinement, alternate-user or independent-builder qualification. Policy and public verification documentation must state this definition before v2 is accepted. @@ -49,7 +49,7 @@ The proposed producer cohort source is `scripts/release/release-cohort-v2.json`, ## Implementation sequence after plan approval -**Sequencing:** revised-plan approval permits **Step 1 only**. Steps 2–5, including v2 policy/cohort/schema files, metadata descriptors, ordinary-receipt admission and assembly, wait for the **final Gate 2 rollout/assurance decision**. Choosing the current full-cohort cost assessment is not that final decision. Step 6 integration follows those held steps; planning documents may still be revised now. A target-specific choice could change the v2 rows’ assurance profiles, so do not freeze them prematurely. +**Sequencing:** Step 1 is approved and its source port, restored assertions and local execution are recorded in the [Step 1 ledger](binary-attestations-phase3-step1-port.md). Steps 2–5, including v2 policy/cohort/schema files, metadata descriptors, ordinary-receipt admission and assembly, wait for the **final Gate 2 rollout/assurance decision**. Choosing the current full-cohort cost assessment is not that final decision. Step 6 integration follows those held steps; planning documents may still be revised now. A target-specific choice could change the v2 rows’ assurance profiles, so do not freeze them prematurely. 1. **After revised-plan approval:** freeze v1 bytes/fixtures with regression checks. Complete the approved source port and restore Phase 3 assertions in separate coherent commits. Prove Phase 2 plus Phase 3 dependency closure with host all-target check/clippy before integrating v2. 2. **Held for Gate 2 decision:** add the separately approved v2 policy/cohort sources and a metadata-only seven-row descriptor matching every tuple in the approved design: filename, package/bin, role, kind, target, runtime/minimum/architecture, variant, capability and assurance. Never extend `FIXED_ARTIFACTS` to select Axum controlled recipes. @@ -87,10 +87,10 @@ For each coherent metadata/schema/join change, first add a discriminating failin Use the host target explicitly for producer checks, for example `cargo +1.98.1 check -p trusted-server-release --target aarch64-apple-darwin --all-targets --locked` and the matching `clippy --all-targets --all-features -- -D warnings`. List each audited pure test by its full name, then use `cargo +1.98.1 test -p trusted-server-release --target aarch64-apple-darwin --locked -- --exact` with a nonzero/one-passing-test guard. Hosted Linux CI must exercise Linux-only code and process fixtures; do not run the whole producer suite locally. Finish each approved chunk with Rust/docs formatting, domain lint and independent review before pushing. -Before Step 1, approve this revised plan and its sequencing. The repeatability definition, frozen v1 hashes, narrow rebuild-check exception and 53/52 contract are already approved. Before Steps 2–5, obtain the final Gate 2 rollout/assurance decision; this plan alone does not open them. Before complete production/index release, close Gate 2 coverage for the selected cohort and separately approve Gate 4 publication and Gate 6 rehearsal trust. Consumer implementation remains Phase 4. No schema, policy, source, workflow or tag is created by this plan. +Step 1 is approved with the explicit Gate 4 workflow-contract deferral recorded in the assignment. The repeatability definition, frozen v1 hashes, narrow rebuild-check exception and 53/52 contract are already approved. Before Steps 2–5, obtain the final Gate 2 rollout/assurance decision; this plan alone does not open them. Before complete production/index release, close Gate 2 coverage for the selected cohort and separately approve Gate 4 publication and Gate 6 rehearsal trust. Consumer implementation remains Phase 4. No schema, policy, source, workflow or tag is created by this plan. ## Plan review -Independent read-only plan review on 2026-10-28 found no blocking findings against Phase 2 source `5fa384336` and frozen source `122b78664`. It confirmed the strict all-seven coverage stop, frozen v1 bytes, narrow ordinary-profile handling and cycle-free inventory. A policy-source wording inconsistency in the design was corrected to refer to a separately approved v2 policy. No tests, implementation or runtime qualification were performed by this review. User rollout/plan approvals remain pending. +Independent read-only plan review on 2026-10-28 found no blocking findings against Phase 2 source `5fa384336` and frozen source `122b78664`. It confirmed the strict all-seven coverage stop, frozen v1 bytes, narrow ordinary-profile handling and cycle-free inventory. A policy-source wording inconsistency in the design was corrected to refer to a separately approved v2 policy. No tests, implementation or runtime qualification were performed by this review. Step 1 subsequently received user approval with the workflow-contract unit deferred to Gate 4. The final Gate 2 rollout/assurance decision and later implementation remain pending. A subsequent independent review checked the user-requested CLI policy location, separate producer cohort source, Step-1-only approval boundary and hold on Steps 2–5. It found no blocking findings; no source or configuration implementation was performed. diff --git a/docs/superpowers/plans/binary-attestations-phase3-step1-port.md b/docs/superpowers/plans/binary-attestations-phase3-step1-port.md new file mode 100644 index 0000000000..6ae2741dfa --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-step1-port.md @@ -0,0 +1,360 @@ +# Phase 3 Step 1 source-port and execution record + +**Issue:** #1235. **PR:** #1463, draft, stacked on Phase 2. **Frozen source:** `122b78664b1235c717e9208fd200f9a9b0c335df`. + +Implementation commits: `faf6e8efb3d71a1254e7f256ae60f4a82221c2e1` restores fixtures and frozen-v1 guards; `4682306b1c7c68171da32b05e668e34df8cc69bf` ports the dependency-coupled source and shared assertions. Later record/spec changes are documentation-only. + +## Scope and contract checks + +The approved source-only slice imports 63 exclusive paths: 62 retain exact frozen bytes; `recipe/tests/candidate_entry.rs` uses the shared Phase 2 helper rather than duplicating it. Fourteen shared support/dependency/assertion paths complete the dependency closure. The [slice map](binary-attestations-slice-map.json) records source and destination SHA-256 plus a reason for every path; the tables below enumerate all 77 paths. Hashes describe source content, not a qualification result. + +No workflow, release script, signer, publisher, v2 file or operator runtime changes. No rehearsal tag is required. `spdx::generate_from_completed` still returns an error; all seven `Qualified*` enums remain uninhabited. Candidate evidence cannot activate production generation, signing or index publication. GNU masking remains excluded. The [phase spec](../specs/2026-10-28-1235-binary-attestations-phase3-design.md) records two fresh independent review rounds. + +The workflow-contract unit is deferred as a whole: eleven paths, `check-workflow-contract` and three named process tests. See the [full Gate 4 deferral table](binary-attestations-phases-2-4.md#approved-phase-3-step-1-scope-and-gate-4-deferrals). Ownership remains Phase 3; Gate 4 must check the actual integrated release workflows, without a substitute fixture. No imported source references or compiles against the deferred unit. + +## Exclusive frozen-source imports + +| Path | Frozen-byte disposition | +| -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-release/src/recipe/tests/candidate_entry.rs` | Retain Phase 2 shared js_inventory_sha256 in candidate.rs; remove duplicate and import shared helper. | +| `crates/trusted-server-release/src/recipe/tests/candidate_index.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/candidate.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/candidate/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/cargo.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/cargo/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/javascript.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/javascript/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/native.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/native/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/platform.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/platform/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/spdx/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/archive.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/producer.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/producer/strict_record.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/producer/strict_record/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/records.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/records/receipt.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/transfer.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/transfer/ordinary.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/transfer/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/workflow_jobs.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/workflow_jobs/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/entry.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/joins.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/ordinary.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/summary.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/tests.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/tests/golden.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/tests/summary.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/tests/transfer.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/src/distribution/transfer.rs` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/collection/README.md` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/collection/build-record.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/collection/composition.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json` | Exact frozen bytes. | +| `crates/trusted-server-cli/tests/fixtures/release/release-index.json` | Exact frozen bytes. | +| `crates/trusted-server-release/tests/fixtures/README.md` | Exact frozen bytes. | + +## Shared support and restored assertion paths + +| Path | Integration reason | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `crates/trusted-server-release/Cargo.toml` | Restore only reqwest hickory-dns, tokio, rcgen and rustls via current workspace pins; preserve Rust version 1.98; defer serde_yaml_ng. | +| `crates/trusted-server-release/src/main.rs` | Register collection, distribution and spdx with dead-code allowance while no production callers exist; preserve ordinary commands and all existing modules; defer workflow_contract and publication. | +| `crates/trusted-server-release/src/error.rs` | Restore exact frozen Collection, Spdx and Distribution error variants and displays; defer Publication. | +| `crates/trusted-server-release/src/recipe.rs` | Restore only frozen evidence-consumed reexports; retain approved ordinary remapping reexports. | +| `crates/trusted-server-release/src/recipe/startup.rs` | Remove test-only gate on accepted NativeStartupAccepted reexport for restored collection/distribution imports; preserve current startup implementation. | +| `crates/trusted-server-release/src/recipe/tests.rs` | Register frozen tests; retain current ordinary-only Axum exclusion from controlled table. | +| `crates/trusted-server-release/src/recipe/tests/candidate.rs` | Restore frozen export helpers while retaining approved moved js_inventory_sha256 helper. | +| `crates/trusted-server-release/src/recipe/tests/complete.rs` | Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. | +| `crates/trusted-server-release/src/recipe/tests/full.rs` | Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. Retain existing named ignores and annotate reviewed esbuild hook 0.27.3 versus current JS lock 0.28.2; no hook policy adaptation. | +| `crates/trusted-server-release/src/recipe/tests/cli.rs` | Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. Retain existing named ignores and annotate reviewed esbuild hook 0.27.3 versus current JS lock 0.28.2; no hook policy adaptation. | +| `crates/trusted-server-release/src/recipe/tests/macos_cli.rs` | Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. Retain existing named ignores and annotate reviewed esbuild hook 0.27.3 versus current JS lock 0.28.2; no hook policy adaptation. | +| `crates/trusted-server-release/src/composition/native/tests.rs` | Restore frozen SPDX/collection assertions removed for Phase 2. | +| `Cargo.lock` | Add only graph entries needed by reqwest hickory-dns and restored release-crate dependencies; preserve every existing package identity/version. | +| `crates/trusted-server-release/src/documents/tests.rs` | Add pure SHA-256 regression guards for frozen v1 policy/schema before source import, then exact v1 CLI and candidate index fixtures after import. No contract edits. | + +## Frozen v1 guards and dependency graph + +Two pure regression tests check raw bytes, without rewriting the policy, schema or fixtures: + +| Guard input | SHA-256 | +| ------------------------------- | ------------------------------------------------------------------ | +| CLI v1 policy | `f08ce10cd16ff2d1acc482db2b85eb53b667ec2b4753792f277d12f41d1f636c` | +| Release-index v1 schema | `06a7e598f1d14b499d087ec0c894829b3aab26a690b35e5922d96987a877bb9e` | +| CLI release-index fixture | `4ff7a4b64e2724172e19d8ef0b02f500f133719411a15b0ce74d8fce4c8b7034` | +| Candidate release-index fixture | `2e35dd6998ec958eae1f23454c50a5b6306be29d2f5eabdb60fd7360f0ca4203` | + +The carried reqwest DNS feature and release-crate dependencies add eleven lockfile package identities: critical-section 1.2.0, crossbeam-channel 0.5.17, crossbeam-epoch 0.9.21, enum-as-inner 0.6.1, hickory-proto/resolver 0.25.2, ipconfig 0.3.4, moka 0.12.16, resolv-conf 0.7.6, tagptr 0.2.0 and widestring 1.2.1. No existing package identity/version is removed or changed. Existing once_cell/reqwest dependency edges expand through feature resolution. Rust/current workspace pins and Phase 2 ordinary normalization stay intact; the deferred workflow-contract dependency is not added. + +## Local execution and retained coverage limits + +On Apple Silicon, host-target `--all-targets --all-features --locked` check and strict clippy passed with Rust 1.98.1. Formatting, whitespace and an explicit-path domain scan passed. Exactly 198 audited pure unit tests ran individually with exact selectors: 198 passed, zero failed, zero ignored. This is a selected pure suite, not the full process/transport suite. + +| Executed pure family | Count | +| --------------------------------------------------------- | --------: | +| SPDX | 70 | +| Collection | 63 | +| Distribution | 45 | +| Native composition/SPDX joins | 4 | +| Frozen document guards | 2 | +| Recipe candidate-entry / candidate-index / SPDX-candidate | 1 / 4 / 6 | +| Recipe incomplete-composition/export negative cases | 2 | +| Fixed-table recipe asset selection | 1 | + +The restored pure assertions include the four `native_spdx_*` join tests, candidate-index identity/reproduction/acceptance guards, all six SPDX-candidate cases, `a_completed_candidate_without_complete_composition_cannot_generate_spdx` and `pure_completion_fixtures_cannot_export_candidate_transfers`. Existing startup-acceptance tests were already exact frozen bytes; this slice does not replace or claim new execution for them. + +The restored real-build export/projection/cleanup assertions remain inside these explicitly ignored owners; they were **not executed** and are **not reported as passing**: + +| Ignored test | Named prerequisites / limitation | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `recipe::tests::full::fastly_canonical_and_reproduction_builds_are_byte_identical` | Explicit JS/Cargo fixtures, Linux Docker and pinned Viceroy; controlled observer accepts esbuild 0.27.3, current lock is 0.28.2. | +| `recipe::tests::cli::linux_cli_candidate_builds_inspects_and_starts_cleanly` | Explicit JS/Cargo/CLI fixtures and Linux Docker; the same controlled-observer mismatch. | +| `recipe::tests::macos_cli::macos_cli_candidate_builds_inspects_and_starts_cleanly` | Explicit macOS Cargo fixture, exported Linux JS handoff, Xcode and native macOS 15; the same controlled-observer mismatch. | + +The existing ignored Cargo-child denial and full preparation collector fixtures retain their named prerequisites. No assertion is deleted to turn an unavailable fixture into a passing test. Ordinary release builds do not use the frozen esbuild observer. Adapting the observer remains separately gated. + +Socket/HTTP/TLS, process and command tests retain their existing non-ignored status and run through the existing hosted producer test job. They were not run locally. Hosted results must be read at the exact pushed head before this slice is declared complete; current results belong in the PR test plan. + +## Independent reviews and maintainer review + +The scope review checked the 63-path import, all shared support/restorations, raw source hashes, closed authority and the eleven-path deferral; no blocking findings. A subsequent quality/security review inspected dependency changes and ownership, archive, credential, transfer and candidate joins; no actionable blocking findings. These were source reviews, not production qualification or execution of ignored fixtures. + +The unchanged frozen `collection/transfer.rs` carries one `allow-domain: api.github.com` suppression on its API root. It needs maintainer review under the domain maintenance policy; this slice adds no global allowlist host. + +Gate 2's [full-cohort feasibility assessment](binary-attestations-phase3-full-cohort-feasibility.md) is available for the next rollout/assurance decision. Its recommendation and identity alternative remain unadopted; Step 1 does not authorize capture code or v2 Steps 2–5. + +## Exact locally executed pure selectors + +Each selector below was audited before execution and run separately with the host-target producer binary and `-- --exact`. Every invocation required one passing test and zero ignored tests; no process or transport fixture was selected. + +
+198 exact selectors + +```text +collection::producer::strict_record::tests::each_changed_producer_fact_is_rejected_by_the_collector +collection::producer::strict_record::tests::each_changed_producer_receipt_fact_is_rejected_by_the_collector +collection::producer::strict_record::tests::producer_composition_receipt_passes_the_strict_collector +collection::producer::strict_record::tests::producer_job_identity_matches_the_collector_upload_check_and_checkout_root_is_bounded +collection::producer::strict_record::tests::producer_rendered_records_pass_the_unchanged_strict_collector +collection::producer::strict_record::tests::rendered_claim_and_runtime_follow_owned_policy_and_acceptance +collection::producer::tests::each_changed_export_member_blocks_handoff +collection::producer::tests::flat_diagnostic_members_survive_without_original_build_roots +collection::producer::tests::linked_export_members_block_handoff_even_when_bytes_match +collection::producer::tests::missing_and_extra_export_members_block_handoff +collection::tests::bounded_zip_accepts_exact_members_and_streaming_descriptor +collection::tests::candidate_rebuild_details_preserve_observations_and_absence_without_authority +collection::tests::collection_compares_complete_fastly_final_bytes_without_exclusions +collection::tests::collection_rejects_missing_a_and_every_mandatory_gate_even_with_optional_b_absent +collection::tests::collection_rejects_unchanged_supported_variations_and_dishonest_claims +collection::tests::composition_receipt_shape_is_strict +collection::tests::contradictory_premature_or_unbounded_optional_job_states_are_rejected +collection::tests::dependency_channels_only_accept_their_own_build_roles +collection::tests::dependency_repository_cannot_change_the_fixed_api_path +collection::tests::every_composition_receipt_gate_rejects_its_changed_fact +collection::tests::every_optional_reproduction_state_is_accepted_with_honest_outcomes +collection::tests::exact_current_run_archives_feed_the_complete_candidate_comparison +collection::tests::fastly_receipt_must_embed_every_js_module_while_native_may_not +collection::tests::fastly_runtime_proof_is_required_for_both_roles_and_absent_from_native +collection::tests::fastly_runtime_proof_must_belong_to_its_own_build_role +collection::tests::mandatory_fastly_and_native_a_pass_with_never_started_native_b_absent +collection::tests::missing_mandatory_canonical_rejects_under_every_optional_reproduction_state +collection::tests::native_b_records_match_mismatch_failed_and_unavailable_without_masking_a +collection::tests::no_optional_reproduction_state_hides_a_mandatory_canonical_failure +collection::tests::optional_native_b_cannot_hide_any_canonical_composition_or_js_failure +collection::tests::optional_reproduction_job_states_map_to_bounded_honest_outcomes +collection::tests::optional_upload_identity_requires_a_started_successful_job +collection::tests::receipt_coverage_claims_apple_evidence_and_runtime_format_follow_the_target +collection::tests::receipt_entry_point_binds_producer_facts_for_every_fixed_asset +collection::tests::reproduction_reusing_the_canonical_boundary_instance_is_rejected +collection::tests::reproduction_reusing_the_canonical_exact_invocation_is_rejected +collection::tests::reproduction_selected_without_a_supported_variation_is_rejected_by_the_pair_check +collection::tests::transfer_checks_exact_dependency_id_and_archive_bytes +collection::tests::transfer_rejects_stale_metadata_warnings_reused_ids_and_changed_bytes +collection::tests::unix_zip32_deflate_upload_profile_accepts_flat_files_and_rejects_nested_names +collection::tests::uploaded_build_deadlines_are_bounded_before_rest_or_comparison +collection::tests::zip_deflate_preserves_full_bytes_and_rejects_extra_compressed_data +collection::tests::zip_rejects_oversized_declarations_and_unsupported_features +collection::tests::zip_rejects_paths_duplicates_hidden_data_and_corrupt_descriptors +collection::tests::zip_rejects_symlinks_zip64_and_local_central_disagreement +collection::tests::zip_requires_deflate_stream_end_even_when_crc_size_and_consumption_match +collection::workflow_jobs::tests::a_job_without_a_conclusion_is_rejected_before_the_deadline_and_accepted_at_it +collection::workflow_jobs::tests::a_truncated_default_page_is_rejected_as_incomplete +collection::workflow_jobs::tests::completed_job_without_a_conclusion_is_rejected_for_every_role +collection::workflow_jobs::tests::duplicate_missing_or_extra_job_names_are_rejected +collection::workflow_jobs::tests::expected_topology_must_name_every_fixed_build_exactly_once +collection::workflow_jobs::tests::jobs_from_another_run_or_source_are_rejected +collection::workflow_jobs::tests::malformed_oversized_incomplete_or_duplicate_key_responses_are_rejected +collection::workflow_jobs::tests::mandatory_jobs_must_start_succeed_and_upload_under_every_wait +collection::workflow_jobs::tests::native_reproduction_job_states_derive_bounded_honest_outcomes +collection::workflow_jobs::tests::only_a_single_fresh_attempt_is_attestable +collection::workflow_jobs::tests::only_the_wasm_asset_reproduction_is_mandatory +collection::workflow_jobs::tests::started_at_without_an_executed_step_is_not_started +collection::workflow_jobs::tests::successful_run_binds_every_fixed_asset_to_its_exact_job_and_upload +collection::workflow_jobs::tests::transfer::adapted_jobs_feed_exact_uploads_and_absent_reproductions_into_transfer_validation +collection::workflow_jobs::tests::unknown_or_inconsistent_github_states_are_rejected +collection::workflow_jobs::tests::unsuccessful_optional_reproduction_upload_output_is_dropped_without_blocking +collection::workflow_jobs::tests::upload_outputs_must_name_exactly_one_expected_job +composition::native::tests::native_spdx_join_rejects_changed_source_features_context_and_duplicate_contributors +composition::native::tests::native_spdx_rejects_aliases_of_the_same_verified_original_context +composition::native::tests::native_spdx_rejects_missing_or_conflicting_verified_package_facts +composition::native::tests::native_spdx_requires_each_reviewed_consuming_context_and_unambiguous_features +distribution::tests::artifact_validator_rejects_a_fastly_entry_with_the_cli_capability +distribution::tests::assembly_is_deterministic_across_entry_order +distribution::tests::awaiting_signature_renders_the_true_empty_file_record +distribution::tests::build_evidence_lists_sorted_prepared_modules_and_the_embedded_subset +distribution::tests::entry_rejects_a_wrong_spdx_root_or_missing_gaps +distribution::tests::every_informational_companion_is_unqualified_with_gaps_and_null_transfers +distribution::tests::fastly_entry_rejects_a_js_build_material_module +distribution::tests::fixed_identity_accessor_equals_the_schema_row_for_every_asset +distribution::tests::four_valid_entries_assemble_a_schema_valid_candidate_index +distribution::tests::golden::committed_fixture_names_the_actual_tracked_consumer_policy +distribution::tests::golden::producer_rendering_equals_the_committed_candidate_fixtures +distribution::tests::inventory_join_uses_the_consumer_inventory_byte_limit +distribution::tests::join_rejects_companion_size_or_digest_mismatch +distribution::tests::join_rejects_missing_extra_and_duplicate_assets +distribution::tests::join_rejects_mixed_source_policy_or_inventory +distribution::tests::join_rejects_unsafe_colliding_or_misnamed_companions +distribution::tests::policy_identity_derives_version_and_digest_from_the_same_bytes +distribution::tests::policy_identity_rejects_mismatched_missing_zero_or_non_integer_versions +distribution::tests::rebuild_check_is_matched_only_with_fastly_equality +distribution::tests::release_index_validator_accepts_the_existing_positive_fixture +distribution::tests::rendered_check_rejects_fastly_staging_and_non_sentinel_bundles +distribution::tests::summary::fastly_entry_summary_records_matched_outcome_and_recomputed_companions +distribution::tests::summary::native_entry_summary_records_unavailable_outcome_and_reproduction_gap +distribution::tests::summary::recheck_rejects_a_companion_stating_other_gaps +distribution::tests::summary::recheck_rejects_companion_bytes_differing_from_their_record +distribution::tests::summary::recheck_reruns_joins_on_re_recorded_companions +distribution::tests::transfer::collector_rejects_members_or_entry_digest_differing_from_the_descriptor +distribution::tests::transfer::collector_rejects_repeated_upload_ids +distribution::tests::transfer::descriptor_accepts_the_exact_upload_outputs +distribution::tests::transfer::descriptor_rejects_bad_identity_digest_or_name +distribution::tests::transfer::descriptor_rejects_unknown_or_missing_keys_and_another_asset +distribution::tests::transfer::descriptor_rejects_wrong_member_names_or_sizes +distribution::tests::transfer::export_refuses_existing_paths_nested_transfer_or_other_executable_bytes +distribution::tests::transfer::export_writes_exactly_the_six_declared_members +distribution::tests::transfer::exported_entries_rebuild_into_exactly_the_golden_candidate_index +distribution::tests::transfer::index_writer_writes_the_index_inventory_and_sixteen_companions_once +distribution::tests::transfer::rebuild_enforces_the_comparison_rule_per_asset +distribution::tests::transfer::rebuild_rejects_another_policy_version_or_an_oversized_companion +distribution::tests::transfer::rebuild_rejects_changed_executable_inventory_or_spdx_root +distribution::tests::transfer::rebuild_rejects_differing_or_incomplete_gaps +distribution::tests::transfer::rebuild_rejects_evidence_for_another_asset_target_or_commit +distribution::tests::transfer::rebuild_rejects_missing_extra_or_misnamed_members +distribution::tests::transfer::rebuild_rejects_qualified_or_workflow_bound_companions +distribution::tests::transfer::rebuilt_entries_still_reject_mixed_commits_or_inventories +distribution::tests::transfer::rebuilt_entry_rechecks_to_the_original_entry_digest +documents::tests::phase3_preserves_frozen_v1_policy_and_release_index_schema +documents::tests::phase3_restores_exact_frozen_v1_release_index_fixtures +recipe::tests::asset_selection_comes_from_fixed_table_not_json +recipe::tests::candidate_entry::only_the_fastly_asset_expects_a_matched_rebuild_check +recipe::tests::candidate_index::candidate_owner_accepts_the_canonical_build_with_its_acceptance_family +recipe::tests::candidate_index::candidate_owner_rejects_a_reproduction_build +recipe::tests::candidate_index::candidate_owner_rejects_a_source_at_another_commit +recipe::tests::candidate_index::candidate_owner_rejects_the_wrong_acceptance_family +recipe::tests::complete::a_completed_candidate_without_complete_composition_cannot_generate_spdx +recipe::tests::complete::pure_completion_fixtures_cannot_export_candidate_transfers +recipe::tests::spdx_candidate::apple_requires_exclusions_and_the_unmeasured_closure +recipe::tests::spdx_candidate::candidate_measurement_records_identity_without_gating +recipe::tests::spdx_candidate::candidate_rejects_a_completion_without_retained_composition +recipe::tests::spdx_candidate::candidate_rejects_an_approved_source_at_another_commit +recipe::tests::spdx_candidate::fastly_requires_every_unresolved_tool_role +recipe::tests::spdx_candidate::linux_requires_the_gnu_privileged_helper_gap +spdx::candidate::tests::candidate_bounds_reject_documents_over_sixteen_mebibytes +spdx::candidate::tests::candidate_comment_names_the_candidate_and_lists_sorted_unique_gaps +spdx::candidate::tests::candidate_document_validates_and_binds_root_and_document_digests +spdx::candidate::tests::candidate_rendering_rejects_an_empty_gap_list +spdx::candidate::tests::candidate_rendering_rejects_an_unplain_gap +spdx::candidate::tests::merge_counts_equal_the_sum_of_the_parts +spdx::candidate::tests::merge_rejects_a_cross_adapter_duplicate_identity +spdx::candidate::tests::merge_rejects_an_empty_required_part +spdx::candidate::tests::merge_rejects_edges_that_dangle_or_cross_parts +spdx::candidate::tests::root_identity_accepts_the_asset_package_workspace_origin +spdx::candidate::tests::root_identity_rejects_a_registry_origin +spdx::candidate::tests::root_identity_rejects_another_manifest_path_or_commit +spdx::candidate::tests::root_identity_rejects_name_or_version_mismatches +spdx::candidate::tests::spdx_created_rejects_years_beyond_four_digits +spdx::candidate::tests::spdx_created_renders_a_leap_day +spdx::candidate::tests::spdx_created_renders_the_unix_epoch_start +spdx::candidate::tests::spdx_created_renders_time_of_day_and_the_last_four_digit_second +spdx::candidate::tests::spdx_created_skips_february_29_in_a_non_leap_century +spdx::cargo::tests::compiler_roles_fail_closed_and_preserve_host_target_distinction +spdx::cargo::tests::malformed_originals_and_noncanonical_paths_are_rejected +spdx::cargo::tests::multiple_library_outputs_preserve_host_and_target_roles +spdx::cargo::tests::multiple_library_outputs_reject_mixed_duplicate_and_incompatible_kinds +spdx::cargo::tests::original_identity_distinguishes_complete_registry_and_git_facts +spdx::cargo::tests::package_edges_require_both_observed_endpoints_and_skip_dev_kinds +spdx::cargo::tests::per_unit_feature_receipts_remain_separate_for_multiple_roles +spdx::cargo::tests::registry_locked_source_requires_an_actual_https_authority +spdx::cargo::tests::workspace_logical_identity_ignores_relocated_opaque_ids +spdx::javascript::tests::checked_originals_render_distinct_archive_tree_and_generated_evidence +spdx::javascript::tests::equal_module_bytes_do_not_substitute_for_exact_original_inventory +spdx::javascript::tests::javascript_originals_accept_only_their_single_supported_roles +spdx::javascript::tests::javascript_originals_reject_unmeasured_declared_licenses +spdx::javascript::tests::materials_cannot_render_complete_module_containment +spdx::javascript::tests::relocated_owners_preserve_stable_component_identities +spdx::javascript::tests::retained_and_absent_modules_have_different_contributor_roles +spdx::javascript::tests::valid_javascript_role_document_bytes_remain_unchanged +spdx::native::tests::native_license_declarations_require_native_license_evidence +spdx::native::tests::native_logical_ids_do_not_depend_on_private_source_and_output_roots +spdx::native::tests::native_original_archive_checksum_and_measured_outputs_remain_distinct +spdx::native::tests::target_and_host_native_inputs_keep_distinct_roles_and_unknown_implementation_versions +spdx::platform::tests::apple_projection_preserves_raw_file_identity_and_logical_runtime_without_private_paths +spdx::platform::tests::compiler_runtime_archive_is_a_build_dependency_with_no_tool_or_retention_claim +spdx::platform::tests::coverage_gaps_do_not_invent_gnu_measurements_for_other_platforms +spdx::platform::tests::coverage_gaps_name_unresolved_fastly_tools_and_incomplete_native_materials +spdx::platform::tests::coverage_gaps_retain_apple_exclusions_and_unmeasured_tool_closure +spdx::platform::tests::coverage_gaps_retain_gnu_tool_closure_without_unreadable_helpers +spdx::platform::tests::coverage_gaps_retain_gnu_unverified_acquisition_without_unreadable_helpers +spdx::platform::tests::detached_sdk_is_structured_material_without_raw_file_checksum +spdx::platform::tests::fastly_selected_tools_project_with_owner_binding_and_conservative_gaps +spdx::platform::tests::gnu_projection_requires_the_owning_boundary_and_plan_and_calls_candidates_honestly +spdx::platform::tests::libtapi_projects_only_present_measured_bytes_as_a_build_tool +spdx::platform::tests::measured_file_renderer_asserts_only_bytes_and_rejects_runtime_roles +spdx::platform::tests::measured_toolchain_projects_library_resource_and_helper_trees_and_retains_closure_gaps +spdx::platform::tests::new_gnu_file_roles_require_review_before_projection +spdx::platform::tests::projects_measured_trees_without_raw_checksums_and_runtime_without_byte_claims +spdx::platform::tests::rejects_another_artifact_target_rust_tree_or_source_owner +spdx::platform::tests::rendered_projection_preserves_runtime_constraints_without_runtime_byte_checksums +spdx::tests::component_roles_must_be_nonempty_unique_and_compatible_with_origin +spdx::tests::declared_license_facts_remain_exact_without_becoming_license_conclusions +spdx::tests::distinguishes_observed_contributors_retained_js_materials_tools_and_external_requirements +spdx::tests::generated_js_requires_a_known_producer_and_retains_generated_bytes_identity +spdx::tests::javascript_preminification_contributors_do_not_claim_complete_module_retention +spdx::tests::normal_and_build_dependencies_preserve_direction_and_reject_duplicates +spdx::tests::one_cargo_identity_can_be_both_observed_target_and_host_build_tool +spdx::tests::rejects_an_spdx_document_exceeding_the_attestation_byte_limit +spdx::tests::rejects_invalid_source_identity_and_creation_time +spdx::tests::rejects_root_digest_changes_duplicate_identities_missing_origins_and_dangling_edges +spdx::tests::renders_schema_valid_document_with_exact_executable_root_and_document_digest +spdx::tests::stable_ids_and_document_bytes_do_not_depend_on_input_component_order +spdx::tests::unknown_licenses_use_noassertion_and_never_become_unsupported_expressions +spdx::tests::verified_cargo_originals_render_without_inventing_download_locations +``` + +
diff --git a/docs/superpowers/plans/binary-attestations-phases-2-4.md b/docs/superpowers/plans/binary-attestations-phases-2-4.md index 766a49b4cb..b3bfc1f97b 100644 --- a/docs/superpowers/plans/binary-attestations-phases-2-4.md +++ b/docs/superpowers/plans/binary-attestations-phases-2-4.md @@ -2,7 +2,7 @@ **Phase 2 spec:** [ordinary Fastly release equality](../specs/2026-10-28-1235-binary-attestations-phase2-design.md), with its own source-checked review rounds. Tracking issue: #1235. -**Status:** Phase 2 implementation `5fa3843364f247fdc261c438ab9257323637a42c` passed exact-source CI and rehearsal `.16`; [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408). Phase 2 `fb4843355` is its docs-only evidence head. Phase 3 is design-only draft #1463: Gate 2/3 designs are approved, but the [full-cohort closure/cost assessment](binary-attestations-phase3-full-cohort-feasibility.md) and [index plan](binary-attestations-phase3-seven-artifact-index-implementation.md) still require their decisions. Implementation, qualification and later gates remain closed. +**Status:** Phase 2 implementation `5fa3843364f247fdc261c438ab9257323637a42c` passed exact-source CI and rehearsal `.16`; [full transcript](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6467368408). Phase 2 `fb4843355` is its docs-only evidence head. Phase 3 draft #1463 contains the approved Step 1 frozen-source port and restored assertions; see the [per-path and execution record](binary-attestations-phase3-step1-port.md). Gate 2/3 designs are approved, but the final Gate 2 rollout/assurance decision remains pending after the [full-cohort closure/cost assessment](binary-attestations-phase3-full-cohort-feasibility.md). The v2 [index plan](binary-attestations-phase3-seven-artifact-index-implementation.md) Steps 2–5, production generation, qualification and later gates remain closed. **Tracking issue:** #1235. The earlier separate tracking-issue draft is retained as planning history, not an additional issue prerequisite. @@ -31,7 +31,7 @@ Finish and rehearse each implementation phase before the next. Design-only propo - 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. -- Three `workflow_contract_command_*` process tests in `tests/commands.rs` are restored in Phase 3 with the workflow-contract command; all other frozen command tests remain in Phase 2. +- Three `workflow_contract_command_*` process tests in `tests/commands.rs` belong with the Phase 3 workflow-contract command but are deferred together to Gate 4, as recorded below; all other frozen command tests remain in Phase 2. - Main command/module registrations, error variants, re-exports, manifests, lockfile, workflows and shared tests are staged by dependency. Stage unused evidence/publication variants until their owning phase rather than introducing broad warning suppressions. ### Avoid duplicate build-facts ports @@ -606,3 +606,25 @@ At `7beaa5483166d073c88f12dda295ffd26bdbec97`, the [real hosted A/B comparison]( The additive signing handoff keeps binary/SPDX inputs unchanged, adds the real matched companion only for Fastly, checks its source/run/attempt and binary/SPDX digests, and signs it as a third provenance subject from the existing reusable signer. The companion travels with the signed Fastly artifact and its digest receipt. Publication validates that handoff before any mutation and adds only this companion, for 30 files and 29 checksum entries. Binary-only SPDX attestations and native input sets remain unchanged. Rehearsal `.10` at `3ccfe29715a073bd37010ea86037b9c8f89801c7` proved attestation compatibility and publication; its separate rerun-all proved current-attempt transfer and expected refusal of an existing immutable release. [Verification](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6438603951) and [recovery](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6439423827) are recorded independently of local tests. The subsequently merged head `770a425df4b28caaaef68dfd930bceabd9928046` has green CI after one lost-runner retry and passed fresh [rehearsal `.13` with full independent verification](https://github.com/IABTechLab/trusted-server/pull/1449#issuecomment-6441704601). Completion commit `2ed1b941a212aeb27e08f8d1e27cb8353f40f535` was docs-only after that exact rehearsed implementation. The later main merge is recorded separately in the compatibility document; `.13` does not attest its changed application inputs. The CI-only negative fixture invokes the real comparison executable on synthetic bound archives containing accurately hashed different fixture bytes. It requires the actual mismatch diagnostic, nonzero exit and absence of matched/later-stage sentinels within fixed time/output bounds. Separate read-only canaries and their audit exercise skipped-stage wiring without a release-path bypass, new trigger or altered-source tag. Process tests remain hosted-only. + +## Approved Phase 3 Step 1 scope and Gate 4 deferrals + +Step 1 is approved on 2026-10-28 for the 63 independent frozen-source paths and required shared support/restored assertions. No workflow, release script, signing, v2 contract or qualification changes are included. The following paths remain Phase 3 ownership but are retargeted from this source-port step to **Gate 4**, where they must guard the actual approved release workflows. No frozen workflow fixture or passing replacement is introduced. + +| Deferred path | Reason / future execution | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `.github/actions/setup-integration-test-env/action.yml` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `.github/workflows/release-validation.mjs` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `.github/workflows/release-validation.test.mjs` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `.github/workflows/release-validation.yml` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `.github/workflows/test.yml` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `crates/trusted-server-release/src/workflow_contract.rs` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `crates/trusted-server-release/src/workflow_contract/tests.rs` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `scripts/release/qualification/approvals.json` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `scripts/release/qualification/hosted-workflow.proposal.yml` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `scripts/release/tests/candidate-changes.sh` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | +| `scripts/release/tests/candidate-changes.test.mjs` | Gate 4 actual-workflow integration; excluded from the approved source-only Step 1 | + +Also defer the `check-workflow-contract` command in `src/main.rs` and these hunks of `tests/commands.rs`: `workflow_contract_command_checks_reviewed_files_without_release_authority`, `workflow_contract_command_requires_an_explicit_readable_workflow`, `workflow_contract_command_rejects_changed_workflow_or_runtime_helper`. The shared files themselves keep their Phase 2 work and only receive the separately required Step 1 support hunks. + +Frozen-source search confirms the ported SPDX, collection, distribution and candidate test modules do not import or compile against `workflow_contract`. If a later integration reveals such a dependency, stop and report it instead of stubbing it. All `Qualified*` types and production generation remain closed. diff --git a/docs/superpowers/plans/binary-attestations-slice-map.json b/docs/superpowers/plans/binary-attestations-slice-map.json index 601b8191c1..ace07edfde 100644 --- a/docs/superpowers/plans/binary-attestations-slice-map.json +++ b/docs/superpowers/plans/binary-attestations-slice-map.json @@ -692,7 +692,7 @@ "3": { "branch": "feature/binary-attestations-phase3", "base": "feature/binary-attestations-phase2", - "pull_request": null + "pull_request": 1463 }, "4": { "branch": "feature/binary-attestations-phase4", @@ -1138,36 +1138,68 @@ }, "landing_status": { "Cargo.lock": { - "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", - "state": "Staged Phase 2 dependency/support subset" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "35f42034e47cb6dbdd689076c10ce46dc369c324ed80887f651484a63782ad03", + "destination_sha256": "a17f7689a2395e73318d8f24c451d956cd814fdfb6e4b567b599bec06be7532e", + "source_equal": false, + "reason": "Add only graph entries needed by reqwest hickory-dns and restored release-crate dependencies; preserve every existing package identity/version." }, "Cargo.toml": { "commit": "1534d462d052debae8b5d886268701077bf879a5", "state": "Staged Phase 2 dependency/support subset" }, "crates/trusted-server-release/Cargo.toml": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Staged Phase 2 dependency/support subset" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "a91db83f6e90626ccce1f8d5998ec409f51f0e188a289a2168b468ab8ce3072b", + "destination_sha256": "26c41f80b285c1bcfc90182dff1722a806cdee8ff5607c27205ce16167e11684", + "source_equal": false, + "reason": "Restore only reqwest hickory-dns, tokio, rcgen and rustls via current workspace pins; preserve Rust version 1.98; defer serde_yaml_ng." }, "crates/trusted-server-release/src/documents.rs": { "commit": "1534d462d052debae8b5d886268701077bf879a5", "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/documents/tests.rs": { - "commit": "1534d462d052debae8b5d886268701077bf879a5", - "state": "Ported Phase 2 source or approved split" + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "e6070f1f8ff815c9f11b65dc6adab62bf9f6e458a5c7823a00706197bdeca851", + "destination_sha256": "02b003fbd1c78d6dce6c6bb1c28f45b7034df50bb0439628bdb25fe8080bdc14", + "source_equal": false, + "reason": "Add pure SHA-256 regression guards for frozen v1 policy/schema before source import, then exact v1 CLI and candidate index fixtures after import. No contract edits." }, "crates/trusted-server-release/src/error.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Staged Phase 2 dependency/support subset" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "92490415449d2baa1425b59b1d715ea0a15c9e8b65327329b647fe62e3a32912", + "destination_sha256": "4d7ecf02012ba028f316e380c8cd72ff6ec6d7b03d9118809d5a0f889f37e73c", + "source_equal": false, + "reason": "Restore exact frozen Collection, Spdx and Distribution error variants and displays; defer Publication." }, "crates/trusted-server-release/src/git_objects.rs": { "commit": "1534d462d052debae8b5d886268701077bf879a5", "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/main.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Staged Phase 2 dependency/support subset" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "deferred_command": "check-workflow-contract", + "deferred_to_gate": 4, + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "6589a60114c2de9beb9f0033684ec6581845d713babc58e8d4c6d2dd3b16bd72", + "destination_sha256": "d5c59ff9c2564e8e570bc80f94ff438accb90306abd2ea3469acef883df6159a", + "source_equal": false, + "reason": "Register collection, distribution and spdx with dead-code allowance while no production callers exist; preserve ordinary commands and all existing modules; defer workflow_contract and publication." }, "crates/trusted-server-release/src/policy.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -1255,7 +1287,9 @@ }, "crates/trusted-server-release/tests/commands.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "state": "Ported Phase 2 source or approved split", + "deferred_test_pattern": "workflow_contract_command_*", + "deferred_to_gate": 4 }, "crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -1794,8 +1828,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "f79891e9bc590d9e748c7f78c01c06198917c479d8b4118ea12bd0183519c245", + "destination_sha256": "1490effab9f0715e632b8509858077182c9508a8955ce7d532c6ebf1d255343f", + "source_equal": false, + "reason": "Restore only frozen evidence-consumed reexports; retain approved ordinary remapping reexports." }, "crates/trusted-server-release/src/recipe/ambient.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -1938,8 +1978,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe/startup.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "e974798c80f8e53d465fda97d9340bed01f2172218c441ee901eee7fed931ac6", + "destination_sha256": "589c54af1c42546fa38f8416f419d4b9fcdb440c32d1627105447843762f9c68", + "source_equal": false, + "reason": "Remove test-only gate on accepted NativeStartupAccepted reexport for restored collection/distribution imports; preserve current startup implementation." }, "crates/trusted-server-release/src/recipe/startup/accepted.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -1958,8 +2004,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe/tests.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "ecdb5e4fb040c71c189881e4bc7a60550d8463e6bd51d6caea6752f3ffd9a443", + "destination_sha256": "0a010946a7098bfa955133b63c337ff867fe5a1002d3ddcff76e5d101b0b85f3", + "source_equal": false, + "reason": "Register frozen tests; retain current ordinary-only Axum exclusion from controlled table." }, "crates/trusted-server-release/src/recipe/tests/ambient.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -1970,12 +2022,24 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe/tests/candidate.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "6fda5e6361b841834fc14b22f6fc8c9fdee1fade5a76296570a806ecc85e6754", + "destination_sha256": "4f3beeb6bf3920631c94f2488ab76c4332bdb98e0ac443a351f825cb939cd4ef", + "source_equal": false, + "reason": "Restore frozen export helpers while retaining approved moved js_inventory_sha256 helper." }, "crates/trusted-server-release/src/recipe/tests/cli.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "ba7bf52e7f25bb83ae6bf5a7fe087866d80b11acc6e06c28cc575502d2a12a6b", + "destination_sha256": "9048279a8d50e9a220868edcddc6ecce89edbd2a218d838ea247e9bb85d75a84", + "source_equal": false, + "reason": "Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. Retain existing named ignores and annotate reviewed esbuild hook 0.27.3 versus current JS lock 0.28.2; no hook policy adaptation." }, "crates/trusted-server-release/src/recipe/tests/cli_candidate.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -1986,8 +2050,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe/tests/complete.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "fc6c6412456fe628a388f1ae21815165e67d9097c199a1ba006fe14793b3eb4b", + "destination_sha256": "fc6c6412456fe628a388f1ae21815165e67d9097c199a1ba006fe14793b3eb4b", + "source_equal": true, + "reason": "Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location." }, "crates/trusted-server-release/src/recipe/tests/complete_darwin.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -2006,8 +2076,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe/tests/full.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "a3e1e31b0f85d05a14f680d4d2b8c962fe2339a86e6be3c4003e5babf56d12fe", + "destination_sha256": "3e8d7b868a006fa436a18f690231b6f5fd869d1f1bbb09805a32884e0e59dbf4", + "source_equal": false, + "reason": "Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. Retain existing named ignores and annotate reviewed esbuild hook 0.27.3 versus current JS lock 0.28.2; no hook policy adaptation." }, "crates/trusted-server-release/src/recipe/tests/full_macos.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -2018,8 +2094,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/recipe/tests/macos_cli.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "e78f4692b18613f98f6971673bd4f0c66a4036151dac86a32936e6d7334f499c", + "destination_sha256": "df212449611d0b5c540fa935df9dec05acc96ee6b99aea0f5b62197f20854fdb", + "source_equal": false, + "reason": "Restore frozen assertions and imports; preserve Phase 2 shared JS inventory helper location. Retain existing named ignores and annotate reviewed esbuild hook 0.27.3 versus current JS lock 0.28.2; no hook policy adaptation." }, "crates/trusted-server-release/src/recipe/tests/native.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -2118,8 +2200,14 @@ "state": "Ported Phase 2 source or approved split" }, "crates/trusted-server-release/src/composition/native/tests.rs": { - "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", - "state": "Ported Phase 2 source or approved split" + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf", + "state": "Phase 3 Step 1 shared support or restored assertions", + "pull_request": 1463, + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "ae2f6fc5b815918605ab1a3e1daf12a7690ee3c015e3d0fa9b8076508ee489f5", + "destination_sha256": "ae2f6fc5b815918605ab1a3e1daf12a7690ee3c015e3d0fa9b8076508ee489f5", + "source_equal": true, + "reason": "Restore frozen SPDX/collection assertions removed for Phase 2." }, "crates/trusted-server-release/src/composition/origin.rs": { "commit": "13f63f020766a0a7b28f9c0401aa708350b563c0", @@ -2220,6 +2308,732 @@ "scripts/release/tests/fixtures/README.md": { "commit": "c0f53a5b1ee0928bf03d5e9123c35efdc8c943f6", "state": "Ported Phase 2 source or approved split" + }, + ".github/actions/setup-integration-test-env/action.yml": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + ".github/workflows/release-validation.mjs": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + ".github/workflows/release-validation.test.mjs": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + ".github/workflows/release-validation.yml": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + ".github/workflows/test.yml": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "crates/trusted-server-release/src/workflow_contract.rs": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "crates/trusted-server-release/src/workflow_contract/tests.rs": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "scripts/release/qualification/approvals.json": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "scripts/release/qualification/hosted-workflow.proposal.yml": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "scripts/release/tests/candidate-changes.sh": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "scripts/release/tests/candidate-changes.test.mjs": { + "state": "Deferred from Phase 3 Step 1 to Gate 4", + "deferred_to_gate": 4, + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute." + }, + "crates/trusted-server-release/src/recipe/tests/candidate_entry.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "a22b2dd22cc8c031754675379fd2af70a0a205d77e9f683852001d811e0641f4", + "destination_sha256": "43d30080922aa61b297ab8cf882b2ca9c66413ad2bd5fd79bc6bcf76fa025893", + "source_equal": false, + "reason": "Retain Phase 2 shared js_inventory_sha256 in candidate.rs; remove duplicate and import shared helper.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/recipe/tests/candidate_index.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "cf22748697b017cd48e7f10f0c351ce144a2c3aed72a6060e6b6baf20c155019", + "destination_sha256": "cf22748697b017cd48e7f10f0c351ce144a2c3aed72a6060e6b6baf20c155019", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/recipe/tests/spdx_candidate.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "1b39552d8da757b6d5a477ae1b33740412fcb963b5bba613a5465993bccb9139", + "destination_sha256": "1b39552d8da757b6d5a477ae1b33740412fcb963b5bba613a5465993bccb9139", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "a0d4be45fff2491813f8d4d2e26ecd9157843777702ebec03aa01da853a94859", + "destination_sha256": "a0d4be45fff2491813f8d4d2e26ecd9157843777702ebec03aa01da853a94859", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/candidate.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "354a04e725065d93f0f8518bfbc112a1cdb65700e09fb4a5d49aa16d05c9dda9", + "destination_sha256": "354a04e725065d93f0f8518bfbc112a1cdb65700e09fb4a5d49aa16d05c9dda9", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/candidate/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "5f89d63749555a6921d4353c8671a1d2aea1e982cc3ed6eba61fe6fa0ad37680", + "destination_sha256": "5f89d63749555a6921d4353c8671a1d2aea1e982cc3ed6eba61fe6fa0ad37680", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/cargo.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "8f171102e033efc52d21dcbfa625f3d7d6855c1f29be0df55f8773858aafeaa8", + "destination_sha256": "8f171102e033efc52d21dcbfa625f3d7d6855c1f29be0df55f8773858aafeaa8", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/cargo/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "f9c7a12fe8c77e0963dae2614658d2189155201578afc594bf7eb9b94df7008e", + "destination_sha256": "f9c7a12fe8c77e0963dae2614658d2189155201578afc594bf7eb9b94df7008e", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/javascript.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "a6e2346c43ebda2676a9941e7fe6591bec25be246c58ceecd4ba968ecb87907e", + "destination_sha256": "a6e2346c43ebda2676a9941e7fe6591bec25be246c58ceecd4ba968ecb87907e", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/javascript/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "ef84ac81eb3092a2edcd56a180b138df0e89fb458ca7182ec3023211ae03812a", + "destination_sha256": "ef84ac81eb3092a2edcd56a180b138df0e89fb458ca7182ec3023211ae03812a", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/native.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "9e6bc4f8014818185e9a2a662455c428672f74c79f950317d7f2a47ebfc0ec24", + "destination_sha256": "9e6bc4f8014818185e9a2a662455c428672f74c79f950317d7f2a47ebfc0ec24", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/native/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "1e5f928fa79303906201518eb8b7b8ceb7792aa024577b385748061d0d837426", + "destination_sha256": "1e5f928fa79303906201518eb8b7b8ceb7792aa024577b385748061d0d837426", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/platform.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "2a07658e558983cfeee3392c157eaab59db155b2191e457e731afe819f5e0e7b", + "destination_sha256": "2a07658e558983cfeee3392c157eaab59db155b2191e457e731afe819f5e0e7b", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/platform/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "cc63c4467533658a7a2e257d05d9c382469bbea974f99549b6869797267437ef", + "destination_sha256": "cc63c4467533658a7a2e257d05d9c382469bbea974f99549b6869797267437ef", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/spdx/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "4c545a42d00853862c988a41fd6f42255898afb4015260fef8293ebd616d27ab", + "destination_sha256": "4c545a42d00853862c988a41fd6f42255898afb4015260fef8293ebd616d27ab", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "87bc240c1a5241e7ffc541081ef4ef67b244f6cad0ac4debfc73064655ad3431", + "destination_sha256": "87bc240c1a5241e7ffc541081ef4ef67b244f6cad0ac4debfc73064655ad3431", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/archive.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "37767ff99de206171db88b961fd3441f2b1d376fe3623d1494b84651d6479482", + "destination_sha256": "37767ff99de206171db88b961fd3441f2b1d376fe3623d1494b84651d6479482", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/producer.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "2c9827af017301052e072600ecc6c096ce39067de05fef9bbdded38abc2a4f8a", + "destination_sha256": "2c9827af017301052e072600ecc6c096ce39067de05fef9bbdded38abc2a4f8a", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/producer/strict_record.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "533e1ccbedb002213c5f8478ee3070b8aac59e6299e2466bf50f7a053a394bee", + "destination_sha256": "533e1ccbedb002213c5f8478ee3070b8aac59e6299e2466bf50f7a053a394bee", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/producer/strict_record/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "31154adb6665371574cb41cb885965fe3c38e3234e17d6d65b6653b3b98d0988", + "destination_sha256": "31154adb6665371574cb41cb885965fe3c38e3234e17d6d65b6653b3b98d0988", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/records.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "7244efb318e8f612441994666fa6b94976ffebaa3dda2c05da38766acf1929a4", + "destination_sha256": "7244efb318e8f612441994666fa6b94976ffebaa3dda2c05da38766acf1929a4", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/records/receipt.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "c455b60e08372d0459f28a6d0d851a80af8eac86edbaaff291bb086c8dccd2da", + "destination_sha256": "c455b60e08372d0459f28a6d0d851a80af8eac86edbaaff291bb086c8dccd2da", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "3d654ba42d1c3b8f149a279fd9ce28181d9d4cc83eed2b9aa50592dcf5cc54c7", + "destination_sha256": "3d654ba42d1c3b8f149a279fd9ce28181d9d4cc83eed2b9aa50592dcf5cc54c7", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/transfer.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "b7c33bdf6dfbdd845582e92e5366f3e4dbedb969d86efa8525c8d2a9bb7f5001", + "destination_sha256": "b7c33bdf6dfbdd845582e92e5366f3e4dbedb969d86efa8525c8d2a9bb7f5001", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/transfer/ordinary.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "f0de7b092d652b16c2f1e2f93ccc02ae35cc705720183cfbf3363f2169ed4bf0", + "destination_sha256": "f0de7b092d652b16c2f1e2f93ccc02ae35cc705720183cfbf3363f2169ed4bf0", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/transfer/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "4a6734fb4cd0cad27a82fea2fd9189d501dfc174410ba7eacc3ffe308e9a25c5", + "destination_sha256": "4a6734fb4cd0cad27a82fea2fd9189d501dfc174410ba7eacc3ffe308e9a25c5", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/workflow_jobs.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "d8bbd89c7bb21ad16d0be431eac9a806d57f9f35ffdcda829ee511b82a9b4f5f", + "destination_sha256": "d8bbd89c7bb21ad16d0be431eac9a806d57f9f35ffdcda829ee511b82a9b4f5f", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/workflow_jobs/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "56abce6fa72e75e65f45b90d1151602c22f52c5bb1ab2b2151201208c7448b47", + "destination_sha256": "56abce6fa72e75e65f45b90d1151602c22f52c5bb1ab2b2151201208c7448b47", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/workflow_jobs/tests/support.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "112ed0cfcb662f10838a6382f0e1ccf85de3c17c7f669876fa1f756edc50b8e5", + "destination_sha256": "112ed0cfcb662f10838a6382f0e1ccf85de3c17c7f669876fa1f756edc50b8e5", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/collection/workflow_jobs/tests/transfer.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "30bc20f9c630bbd03c5e7330340eeb01db5c36925a1e6a8d8839bf8532f35049", + "destination_sha256": "30bc20f9c630bbd03c5e7330340eeb01db5c36925a1e6a8d8839bf8532f35049", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "40173f501546a147f1c5d74b987dd694335b170b838ba9d1cf97c357067a77f7", + "destination_sha256": "40173f501546a147f1c5d74b987dd694335b170b838ba9d1cf97c357067a77f7", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/entry.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "45b987fb6f96a4eca909fff8a2c84a695f4bee24f044cac586e1497fb2ebef79", + "destination_sha256": "45b987fb6f96a4eca909fff8a2c84a695f4bee24f044cac586e1497fb2ebef79", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/joins.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "c7fba6075a3796b2aa5658e49d12bafd70c50f269bb56c219704e5fbf67111b6", + "destination_sha256": "c7fba6075a3796b2aa5658e49d12bafd70c50f269bb56c219704e5fbf67111b6", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/ordinary.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "6e3b3b92cc5097ba0ed2ee84035326101029da99b2ba846d782e167a4f139d6c", + "destination_sha256": "6e3b3b92cc5097ba0ed2ee84035326101029da99b2ba846d782e167a4f139d6c", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/summary.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "7a75133a6aeea70927a7b44e060c2214b43dcadca3ce113ab5f67eab92e67a2f", + "destination_sha256": "7a75133a6aeea70927a7b44e060c2214b43dcadca3ce113ab5f67eab92e67a2f", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/tests.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "363d65136821ba0888dd4a4a75dc10d744263eceb82d39de8452c655d65c0e98", + "destination_sha256": "363d65136821ba0888dd4a4a75dc10d744263eceb82d39de8452c655d65c0e98", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/tests/golden.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "942bfdf99bd054fb953aa5e597fa915941ee42bc7b33e85f96648df1d0101ad2", + "destination_sha256": "942bfdf99bd054fb953aa5e597fa915941ee42bc7b33e85f96648df1d0101ad2", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/tests/summary.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "9f94c44bf9a0c4f29de636270cd49832a46da9515a98bcf79c9f2c6641fabf77", + "destination_sha256": "9f94c44bf9a0c4f29de636270cd49832a46da9515a98bcf79c9f2c6641fabf77", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/tests/transfer.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "60e8ac6931da10e5659c89db4e38674226a963cb876da4064072cdbb533f8ec8", + "destination_sha256": "60e8ac6931da10e5659c89db4e38674226a963cb876da4064072cdbb533f8ec8", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/src/distribution/transfer.rs": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "cd1a0074230da10f987bbe86f1be7d6bd3df9ac85f2fd084af59a145ff430539", + "destination_sha256": "cd1a0074230da10f987bbe86f1be7d6bd3df9ac85f2fd084af59a145ff430539", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "4682306b1c7c68171da32b05e668e34df8cc69bf" + }, + "crates/trusted-server-release/tests/fixtures/collection/README.md": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "d817494ec395b76c46fd894d4283e56add57b3dd944d13d7f7013ed6708c96be", + "destination_sha256": "d817494ec395b76c46fd894d4283e56add57b3dd944d13d7f7013ed6708c96be", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/collection/build-record.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "7b6076eec22b47d1a455395caec9ac3411a7ff7c3174026a6f85c24a99a21d21", + "destination_sha256": "7b6076eec22b47d1a455395caec9ac3411a7ff7c3174026a6f85c24a99a21d21", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/collection/composition.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "7966b1d8fe20efa0d0201ea27097286d68ee2c17534fa3230c8444ed7db1733d", + "destination_sha256": "7966b1d8fe20efa0d0201ea27097286d68ee2c17534fa3230c8444ed7db1733d", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/candidate-release-index.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "2e35dd6998ec958eae1f23454c50a5b6306be29d2f5eabdb60fd7360f0ca4203", + "destination_sha256": "2e35dd6998ec958eae1f23454c50a5b6306be29d2f5eabdb60fd7360f0ca4203", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-evidence.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "d507ab4f6377db0ffc5f35c0471322aeaf5b0fd454dcd6e223d5801d68017230", + "destination_sha256": "d507ab4f6377db0ffc5f35c0471322aeaf5b0fd454dcd6e223d5801d68017230", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.build-recipe.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "5dd2af6404b6554d20cdb67be8228f88f0726447612f29476972f6f8f3ff98d1", + "destination_sha256": "5dd2af6404b6554d20cdb67be8228f88f0726447612f29476972f6f8f3ff98d1", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.rebuild-check.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "381505ae1be7d5c46e7019a6e514c2e1ef7844bc85faba3ddb0da67513190437", + "destination_sha256": "381505ae1be7d5c46e7019a6e514c2e1ef7844bc85faba3ddb0da67513190437", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/trusted-server-fastly.wasm.spdx.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "ac94b402aa1d0fb889bc9cb063e81ff41573c16d52fd93a02290bec75b3f7dcb", + "destination_sha256": "ac94b402aa1d0fb889bc9cb063e81ff41573c16d52fd93a02290bec75b3f7dcb", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-evidence.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "4398ff1d660f8f25741f0d26128312c856f8a80ed45fad92ce1961e3f89f6b66", + "destination_sha256": "4398ff1d660f8f25741f0d26128312c856f8a80ed45fad92ce1961e3f89f6b66", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.build-recipe.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "c2ae63588adfd51957a42b943c668a8b0c9119f12b5cf13098c927662a82f95b", + "destination_sha256": "c2ae63588adfd51957a42b943c668a8b0c9119f12b5cf13098c927662a82f95b", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.rebuild-check.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "e20a78e71af499d34b2d1aeb16b1429cfe7281a8eec715e571ebc7a840fa000f", + "destination_sha256": "e20a78e71af499d34b2d1aeb16b1429cfe7281a8eec715e571ebc7a840fa000f", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-aarch64-apple-darwin.spdx.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "fe487f344797a42c97bdfe44248af98fc8d3e05aff8453c7e2f57f5bd5b88992", + "destination_sha256": "fe487f344797a42c97bdfe44248af98fc8d3e05aff8453c7e2f57f5bd5b88992", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-evidence.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "248b207f66e9b325327779d493b353d9ccf88942d5739eaa89a42962d17c5fb1", + "destination_sha256": "248b207f66e9b325327779d493b353d9ccf88942d5739eaa89a42962d17c5fb1", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.build-recipe.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "0b01813499eceb2aafc9e35311f6b14dce56c6b499fd0f5b97333c5267d2b114", + "destination_sha256": "0b01813499eceb2aafc9e35311f6b14dce56c6b499fd0f5b97333c5267d2b114", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.rebuild-check.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "26737bde3396202434cd28fb6d9f3e7b085e4a82e7762e3f0177cb09d6966e21", + "destination_sha256": "26737bde3396202434cd28fb6d9f3e7b085e4a82e7762e3f0177cb09d6966e21", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-apple-darwin.spdx.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "dbaef04e50548077cced269f7d4baade02717ce3d9b054bbbd335d9bdea1020a", + "destination_sha256": "dbaef04e50548077cced269f7d4baade02717ce3d9b054bbbd335d9bdea1020a", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-evidence.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "5a8f3a8ae4277cc3bcd03a7b58c600469b22205a957718b6570d5ddc1644ee63", + "destination_sha256": "5a8f3a8ae4277cc3bcd03a7b58c600469b22205a957718b6570d5ddc1644ee63", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.build-recipe.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "de836324f0ca933580b1eb336f177fc21e1cc43140004ff5ecba735371cd3ee1", + "destination_sha256": "de836324f0ca933580b1eb336f177fc21e1cc43140004ff5ecba735371cd3ee1", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.rebuild-check.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "c1f61f93204390231ef694c26eb0c6b3035475490ef4e4ae2850fa8d13714ce6", + "destination_sha256": "c1f61f93204390231ef694c26eb0c6b3035475490ef4e4ae2850fa8d13714ce6", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/ts-x86_64-unknown-linux-gnu.spdx.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "dd5cb2569a0ca2a1abbe110be9b966a282dff42dae9059cebcfa8b15268b274f", + "destination_sha256": "dd5cb2569a0ca2a1abbe110be9b966a282dff42dae9059cebcfa8b15268b274f", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/index/candidate/tsjs-inventory.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796", + "destination_sha256": "64540e73dc3fb484a0fb0e5ee96b54e5ed12fcb34c7038107d7fbf75b59e9796", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-cli/tests/fixtures/release/release-index.json": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "4ff7a4b64e2724172e19d8ef0b02f500f133719411a15b0ce74d8fce4c8b7034", + "destination_sha256": "4ff7a4b64e2724172e19d8ef0b02f500f133719411a15b0ce74d8fce4c8b7034", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" + }, + "crates/trusted-server-release/tests/fixtures/README.md": { + "pull_request": 1463, + "state": "Ported Phase 3 Step 1 source", + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "source_sha256": "d1ff7de4b6deaef76a96852909733d92757e0c6653d30ed156c96e481978235c", + "destination_sha256": "d1ff7de4b6deaef76a96852909733d92757e0c6653d30ed156c96e481978235c", + "source_equal": true, + "reason": "Exact frozen full-file source import; no adaptation.", + "commit": "faf6e8efb3d71a1254e7f256ae60f4a82221c2e1" } + }, + "phase3_step1": { + "source": "122b78664b1235c717e9208fd200f9a9b0c335df", + "approved_import_paths": 63, + "deferred_paths": [ + ".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/tests/candidate-changes.sh", + "scripts/release/tests/candidate-changes.test.mjs" + ], + "deferred_command": "check-workflow-contract", + "deferred_process_tests": [ + "workflow_contract_command_checks_reviewed_files_without_release_authority", + "workflow_contract_command_requires_an_explicit_readable_workflow", + "workflow_contract_command_rejects_changed_workflow_or_runtime_helper" + ], + "reason": "Deferred to Gate 4: guards release workflows replaced only by the later approved integration; validate actual release workflow files, with no frozen fixture substitute.", + "port_ledger": "docs/superpowers/plans/binary-attestations-phase3-step1-port.md", + "support_paths": [ + "crates/trusted-server-release/Cargo.toml", + "crates/trusted-server-release/src/main.rs", + "crates/trusted-server-release/src/error.rs", + "crates/trusted-server-release/src/recipe.rs", + "crates/trusted-server-release/src/recipe/startup.rs", + "crates/trusted-server-release/src/recipe/tests.rs", + "crates/trusted-server-release/src/recipe/tests/candidate.rs", + "crates/trusted-server-release/src/recipe/tests/complete.rs", + "crates/trusted-server-release/src/recipe/tests/full.rs", + "crates/trusted-server-release/src/recipe/tests/cli.rs", + "crates/trusted-server-release/src/recipe/tests/macos_cli.rs", + "crates/trusted-server-release/src/composition/native/tests.rs", + "Cargo.lock", + "crates/trusted-server-release/src/documents/tests.rs" + ] } } diff --git a/docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase3-design.md b/docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase3-design.md new file mode 100644 index 0000000000..b8e1ae705e --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-1235-binary-attestations-phase3-design.md @@ -0,0 +1,45 @@ +# Binary attestations Phase 3: retained evidence and index foundations + +**Issue:** #1235. **Date:** 2026-10-28. **Status:** Step 1 source port approved; later implementation remains gated. Draft #1463 is stacked on Phase 2. This phase-specific spec consolidates existing approvals; it introduces no new release design. + +## Problem and approved slice + +Phase 2 supplies ordinary Fastly equality and an authenticated rebuild-check, not complete composition or a release index. The approved Step 1 imports exactly 63 assigned frozen-source paths, plus their required shared support/assertion hunks, for SPDX, collection and distribution foundations. It restores the assertions deferred from Phase 2. Source: `122b78664b1235c717e9208fd200f9a9b0c335df`; exclusive and shared ownership follows the [slice map](../plans/binary-attestations-slice-map.json) and [phase assignment](../plans/binary-attestations-phases-2-4.md). + +Every imported path is compared to frozen bytes. Necessary integration, current-main or test-split adaptations are recorded per path with their reason. Already-carried Phase 1/2 work is retained. GNU masking WIP is excluded and untouched. + +## Boundaries and compatibility + +`spdx::generate_from_completed` remains a closed error path. All `Qualified*` types stay uninhabited; no production/qualified selector, live index writer, signer or publisher is enabled. Candidate diagnostics retain their gaps and cannot become complete generation or publication authority. The four-entry v1 policy, schema and fixtures remain frozen. + +The approved [Gate 2 design](../plans/binary-attestations-phase3-complete-spdx.md) requires retained, artifact-bound owning evidence. Complete production generation stays closed until its approved selected-cohort conditions are actually met. The [full-cohort assessment](../plans/binary-attestations-phase3-full-cohort-feasibility.md) presents costly unresolved cells and an unadopted identity alternative; its recommendation does not amend scope or authorize capture code. + +The approved [Gate 3 design](../plans/binary-attestations-phase3-seven-artifact-index.md) names future v2 cohort `trusted-server-binaries`; frozen v1 keeps `phase1-fastly-cli`. This slice creates no v2 policy, cohort, schema, descriptor, admission or assembly files. Steps 2–5 wait for the final Gate 2 rollout/assurance decision under the [revised plan](../plans/binary-attestations-phase3-seven-artifact-index-implementation.md). Consumer code remains Phase 4; publication and fork trust remain Gates 4/6. + +Seven release binaries, SPDX 2.3/predicate/companion names, reusable signer identity and checksum rules remain unchanged. No workflow, release script or signing changes are included. No rehearsal tag is needed for this source-only slice; a dependency that would require changing release machinery must be reported before proceeding. + +## Integration and affected adapters + +Register the imported private producer modules and only their required error variants, dependencies and owning-accessor/re-export support. Preserve ordinary Phase 2 commands and current toolchain/dependency adaptations. Restore the approved shared recipe/native-composition assertion hunks, preserving the Phase 2 location of `js_inventory_sha256` rather than duplicating it. + +Fastly, Axum, Cloudflare, Spin and the operator CLI gain no runtime behavior, configuration, consent/privacy or deployment changes. No operator configuration or secret becomes a compile-time input. Candidate metadata is internal producer/test evidence; it is not a new release asset. + +The map also assigns workflow/script files to Phase 3. They remain outside this Step 1 authorization. The frozen workflow-contract module/test unit, its command and three `workflow_contract_command_*` process tests are explicitly deferred to Gate 4 with the nine excluded workflow/script/config paths. At Gate 4 the unit must validate the actual release workflows; no frozen workflow fixture substitutes for them. Nothing in this source-only port may import or compile against the deferred unit. No dependency is silently deleted or replaced with a passing stub. + +## Tests and failure handling + +Add a pure regression guard for exact raw v1 policy/schema hashes and preserve imported v1 fixture bytes. Run host all-target check and strict all-target/all-feature producer clippy after coherent chunks. Audit pure test bodies before exact-name local execution, require nonzero selection and a passing/non-ignored result. Process-spawning tests remain hosted-only. + +Every restored assertion must be accounted for: executed locally when pure, executed in hosted CI where supported, or retained with an explicit named ignore reason where its required fixture/provider is not available. The frozen controlled observer's esbuild 0.27.3 restriction is not adapted to the current 0.28.2 lock. Ignored collector/candidate tests are never reported as passing. + +Parsing, ownership, digest, candidate and incomplete-evidence failures retain their frozen fail-closed semantics. No test-only constructor becomes a production entrypoint. Source/fixture hash guards and independent scope/quality reviews must detect accidental activation or frozen-contract changes. + +## Delivery + +Use separate coherent signed commits, explicit paths and a per-path deviation/test-execution ledger. Run touched formatting/domain/documentation gates and require exact-head hosted CI. Keep #1463 draft with no reviewers; do not modify #1435/#1449 or create tags/releases/settings. + +## Review log + +- Round 1 (2026-10-28, fresh independent AI source review): found the workflow-contract disposition was not yet explicit in the spec/assignment. Corrected it to the user-approved 63-path core scope and Gate 4 deferral of the nine workflow/script/config paths, both workflow-contract files, command and three process tests. Confirmed the frozen core has no dependency on that unit. No other blocking findings in the inspected generator/qualification, candidate, ownership/failure, adapter/configuration/privacy or v1 compatibility boundaries. + +- Round 2 (2026-10-28, a second fresh independent AI source review): no blocking findings. Rechecked all 63 paths for deferred dependencies, the exact 11-path/command/three-test Gate 4 boundary, frozen v1 hashes, the closed generator, seven uninhabited qualification enums and candidate-only signing/index semantics. No tests, runtime qualification or implementation were performed by the review. From 57b208ad61bbb02c9e8b9553978f3ab8774d6c0b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 28 Oct 2026 18:54:01 +0530 Subject: [PATCH 7/8] Propose issue-scoped build-derived SBOMs for the seven binaries --- ...ons-phase3-complete-spdx-implementation.md | 2 + ...inary-attestations-phase3-complete-spdx.md | 2 + ...stations-phase3-full-cohort-feasibility.md | 2 + ...tations-phase3-issue-scoped-sbom-option.md | 191 ++++++++++++++++++ ...se3-seven-artifact-index-implementation.md | 2 + 5 files changed, 199 insertions(+) create mode 100644 docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md index ce7ac64ada..36e8bbf9fd 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx-implementation.md @@ -14,6 +14,8 @@ Each cell names its **exact closure contract** below. **P** means a bounded ordi **Status:** The user selected full-cohort closure and cost assessment first; see the [21-cell feasibility and scope comparison](binary-attestations-phase3-full-cohort-feasibility.md). Planning remains docs-only, with no capture implementation. Gate 2's strict design is approved; no implementation plan or code is approved. The matrix exposes costly native/platform/acquisition cells for every target, especially Apple SDK/toolchain, GNU derived images and musl startup/compiler runtimes. No promise is made that the existing hosted setup can close them within current job limits. No target is silently removed and no unresolved contributor is excluded. +**New option for decision:** The user has not approved target-specific activation. The [issue-scoped all-seven build-derived Cargo/npm SBOM option](binary-attestations-phase3-issue-scoped-sbom-option.md) proposes an explicit narrower coverage amendment, with per-SBOM/guide limits and a separate estimate. It is not adopted; strict Gate 2 still governs and Gate 3 Steps 2–5 remain held. + **Tracking:** Part of #1235; draft #1463. Source: Phase 2 `5fa3843364f247fdc261c438ab9257323637a42c` and frozen #1262 `122b78664b1235c717e9208fd200f9a9b0c335df`. This document changes no schema, policy, producer, workflow or release selection. ## Evidence that closes each cell diff --git a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md index ef356ce678..5ac90c808b 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md +++ b/docs/superpowers/plans/binary-attestations-phase3-complete-spdx.md @@ -2,6 +2,8 @@ **Status:** Gate 2 design approved on 2026-10-28 for strict admission from retained, artifact-bound owning evidence. Plan only: production generation stays closed until all seven binaries pass. The [coverage matrix](binary-attestations-phase3-complete-spdx-implementation.md) and [full-cohort cost assessment](binary-attestations-phase3-full-cohort-feasibility.md) expose costly evidence gaps before any implementation; no code, qualification or rehearsal tag is authorized. +**New option for decision:** The user has not approved target-specific activation. The [issue-scoped all-seven build-derived Cargo/npm SBOM option](binary-attestations-phase3-issue-scoped-sbom-option.md) proposes an explicit narrower coverage amendment, with per-SBOM/guide limits and a separate estimate. It is not adopted; strict Gate 2 still governs and Gate 3 Steps 2–5 remain held. + **Historical reviewed baseline:** Phase 2 `8a797dbcb0467b867dfbcc571597110b9254d672`, tree-equivalent to `08e1c33b8` after application integration `7d551777daeaee09e24bce9bf4f96edac6e5e0df`. Rehearsal `.13` remains evidence only for `770a425df4b28caaaef68dfd930bceabd9928046`. **Verification baseline:** [Historical Phase 2 `8a797dbcb` CI passed](https://github.com/IABTechLab/trusted-server/actions/runs/40654745582); signing/publication and manual negative canaries were skipped as configured. diff --git a/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md b/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md index 03689b33ee..2474101791 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md +++ b/docs/superpowers/plans/binary-attestations-phase3-full-cohort-feasibility.md @@ -2,6 +2,8 @@ **Status:** Docs-only assessment requested on 2026-10-28. No capture implementation, acquisition, image build, qualification, production selection or rehearsal tag is authorized. The strict Gate 2 scope remains in force; the identity-only alternative below is a proposed amendment for a user decision, not an adopted definition of completeness. +**New option for decision:** The user has not approved target-specific activation. The [issue-scoped all-seven build-derived Cargo/npm SBOM option](binary-attestations-phase3-issue-scoped-sbom-option.md) proposes an explicit narrower coverage amendment, with per-SBOM/guide limits and a separate estimate. It is not adopted; strict Gate 2 still governs and Gate 3 Steps 2–5 remain held. + **Scope:** All 21 unresolved cells in the [coverage matrix](binary-attestations-phase3-complete-spdx-implementation.md): seven native, seven platform/tool and seven licence cells. Cargo/JS/runtime capture and complete-generator integration also remain work, but their costs are not hidden inside these U-cell estimates. Part of #1235; draft #1463. ## Basis and limits diff --git a/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md b/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md new file mode 100644 index 0000000000..72dbd54c58 --- /dev/null +++ b/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md @@ -0,0 +1,191 @@ +# Gate 2 option: build-derived Cargo/npm SBOMs for all seven binaries + +**Status:** Proposed scope amendment for the user and maintainers, requested on 2026-10-28. Docs only; not approved or implemented. The approved strict Gate 2 remains the governing contract until this amendment is accepted. The target-specific rollout recommendation has not been accepted. Gate 3 Steps 2–5 remain held. Draft #1463 stays draft, without reviewers. + +**Issue:** [#1235](https://github.com/IABTechLab/trusted-server/issues/1235). **Source baseline:** Phase 3 `4741ab712365efa9f578a9b461f9a26a48bb9c5d`; frozen port source `122b78664b1235c717e9208fd200f9a9b0c335df`. No implementation, resources, acquisition, workflow/signing changes or rehearsal tags follow from this proposal. + +## Decision and recommended claim + +Adopt, if approved, an **issue-scoped build-derived Cargo/npm SBOM** for every existing release binary, together with artifact-bound logical runtime requirements. Keep all seven assets in one admission/publication cohort. This aligns the next SBOM milestone with #1235's release-build Cargo/npm requirement; it explicitly amends the broader strict-composition scope rather than claiming its native/platform gaps are solved. + +Recommended public document name: **Build-derived Cargo/npm SBOM**. Recommended design title after approval: **Gate 2: build-derived Cargo/npm SBOMs and runtime requirements**. Use **admitted under the issue-scoped build profile**, never “complete SBOM,” “complete composition,” “fully covered,” or “qualified build.” Keep SPDX 2.3, `https://spdx.dev/Document/v2.3`, `.spdx.json`, all bundle names, source pins and signer identity unchanged. Renaming or superseding the existing `complete-spdx` planning documents would be a later documentation migration; this proposal renames no existing file or release asset. + +| Direction | Coverage and cohort | Decision status | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| **Issue-scoped all-seven — recommended for this decision** | Actual Cargo/npm build evidence, declared licences and logical runtime requirements; explicit native/platform limits | Proposed amendment in this document | +| Strict all-seven | Also requires retained native/tool/sysroot/SDK input closure and corresponding licence evidence | Previously approved Gate 2; expensive unresolved cells remain | +| Target-specific strict rollout | Changes when individual targets could activate | Not approved; not used by this option | + +The previous [80–136 person-day estimate](binary-attestations-phase3-full-cohort-feasibility.md) prices unresolved strict native/platform/licence closure. It explicitly excludes Cargo/JS/runtime capture and generator integration. It is not the cost of satisfying the issue-scoped Cargo/npm requirement, and cannot be reduced to this option by subtracting an arbitrary percentage. + +## Common scope for all seven + +| Release binary | Required evidence | Existing execution location | +| ----------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- | +| `trusted-server-fastly.wasm` | C + J + Cargo/npm declarations + RW + existing identity context | Ubuntu Fastly canonical job; retain the existing independent A/B gate | +| `ts-x86_64-unknown-linux-gnu` | C + J + Cargo/npm declarations + RG + existing identity context | Actual x64 UBI build container | +| `ts-aarch64-apple-darwin` | C + J + Cargo/npm declarations + RM + existing identity context | Actual Apple Silicon macOS job | +| `ts-x86_64-apple-darwin` | C + J + Cargo/npm declarations + RM + existing identity context | Actual Intel macOS job | +| `trusted-server-axum-x86_64-unknown-linux-gnu` | C + J + Cargo/npm declarations + RG + existing identity context | Actual x64 UBI build container | +| `trusted-server-axum-x86_64-unknown-linux-musl` | C + J + Cargo/npm declarations + RS + existing identity context | Existing static musl job | +| `trusted-server-axum-aarch64-unknown-linux-gnu` | C + J + Cargo/npm declarations + RA + existing identity context | Actual native ARM UBI build container | + +Every record must join the actual successful invocation, source commit, package/bin, target, features, profile, private output set and final executable SHA-256. Evidence is retained before the producing container/private directory disappears and rehashed at handoff. Missing or ambiguous evidence **inside this scope** fails the entire cohort; a limit on native/platform closure does not excuse a missing Cargo unit, unknown npm origin or wrong binary. No new builders, images, original-acquisition service, qualification resource or local capture experiment is required or authorized. + +### C: actual Cargo compilation + +Capture the successful Cargo JSON message stream at the real invocation for every target, including inside each GNU container. Capture an **unfiltered** same-execution metadata identity catalog and explicit-target non-dev tree/query context. Catalog/tree membership identifies possible packages and edges; it does not prove compilation. Select compiled package/unit membership from observed compiler/build-script messages, retaining package ID, source, target kind, host/target context and effective unit features. A package used both as a host build dependency and a target library retains both roles. Uncompiled dev-only units are not binary contributors. + +Join observed package identities to the source/lock/workspace or installed Cargo manifests actually used by that invocation, retaining available archive checksums/Git revisions and metadata provenance. Do not require newly acquired source distributions beyond materials already fetched for the release build. Do not turn a lock checksum or compiled Rust wrapper crate into proof of its bundled C implementation. Compiled Rust units eliminated later by linking may remain conservative compiled inputs; exact machine-code reachability and an exact compiler-unit dependency DAG are not claimed. + +Today only Fastly retains actual Cargo JSON; native commands do not. Current post-build `--filter-platform` metadata and cargo-cyclonedx output cannot substitute for these observations. Existing `composition/capture.rs`, `composition/origin.rs` and `recipe/plan.rs` supply reusable distinctions, not an already implemented ordinary all-target provider. + +### J: actual private bundles and npm membership + +Observe the private `OUT_DIR/tsjs-dist` belonging to the successful Cargo invocation, identified through its build-script output. Record the actual discovered module set, source/npm/generated origins, nonzero Rollup contributions, exact lock installation path/name/version/integrity, and final bundle hashes **after** Vite/esbuild transformations. Attribute npm packages from those module records; the entire installed npm tree is not a substitute. Keep installed/executed bundler and other known build tools in a separate build-material role. Retain generated-module producer identity and the evidence actually observed; an unexplained contributing origin fails admission. + +Bind the final module bytes to that build's generated Rust include inputs and inspect the final executable for the reviewed whole-module retention observation. Distinguish a positively observed full retained module from a prepared/compiled JS input. Absence of a whole-bundle substring is **not** proof that no transformed fragment survived: keep its package inputs as build material with fragment retention unestablished, rather than declaring npm absent. CLI depends on core and JS too; inspect every CLI output instead of assuming an empty npm set. + +Rollup contributions precede final minification. They are conservative evidence of package inputs to the actual private bundle, not an assertion that every recorded package's functions survive in the final executable. State that limit in the SBOM. Type-only imports and Rollup-eliminated modules do not become embedded npm components. The embedded Prebid shim is covered; the separately produced/operator-selected external Prebid bundle is outside this release binary. + +The ordinary provider must record the **actual Cargo-built private bundles**, not run an additional bundle build and claim it produced the signed binary. `build-all.mjs`/`release-composition.mjs` already support opt-in contribution recording, but ordinary builds do not enable it yet. `ordinary/inputs.rs` only hashes a Fastly-specific private layout; `collect-js.mjs` requires checked preparation and is not an ordinary drop-in. Extending this owning evidence is real work included in the estimate. + +Ordinary capture does **not** depend on `observe-npm.mjs`. Its controlled esbuild 0.27.3 restriction remains frozen while the current lock uses 0.28.2. This option neither reuses nor adapts that observer. If the later provider design needs it, stop for its separate decision; do not silently change the pin. Native bundler/lifecycle helper closure remains a stated scope limit, without weakening npm module identity, actual-build or generated-origin checks. + +### Logical runtime requirements + +| Contract | Required observation from the final bytes | Limit | +| -------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| **RW** | Wasm imports/host requirements, accepted Wasm target/runtime and binary digest | Not the installed Fastly runtime's identity or implementation | +| **RM** | Mach-O CPU, loader, dylibs, minimum OS and SDK load facts, binary digest | Not SDK input closure or proof of execution on macOS 13 | +| **RG** | GNU x64 ELF interpreter, needed libraries, symbol-version/ABI requirements and binary digest | Not the deployed loader/libraries' bytes or package closure | +| **RS** | Static x64 ELF/architecture facts and absence of a loader/needed libraries, binary digest | Static does not imply complete musl/crt/compiler-runtime material or licence coverage | +| **RA** | Actual ARM ELF interpreter, needed libraries and symbol-version requirements, binary digest | Never reuse x64 observations for ARM | + +Keep external requirements separate from embedded-component claims. Axum's operator-provided CA bundle, runtime configuration and stores are runtime data, not embedded build components. Existing `composition/external.rs` supplies RW/RM/GNU-x64 projection; its hard-coded x64 constructor and interpreter-bearing ELF shape require reviewed ordinary ARM/static extensions for RA/RS. Existing smoke/format checks support capture but do not already produce all these retained receipts. + +### Cargo/npm declared licences + +For each admitted Cargo/npm package, retain the declaration text and its observed source: the exact manifest/package metadata path and digest, package/source identity and build binding. Cargo workspace-inherited declarations must resolve against that build's actual workspace. npm declarations come from that exact installation's package metadata, checked against its lock identity; distinguish observed installed metadata from independently authenticated upstream originals. Reuse already available licence-file references where supportable; no new source-acquisition programme or full file-level licence scan is implied. + +Use `licenseDeclared` only for supported expressions faithfully derived from those declarations. For missing, legacy, unsupported or unresolvable declarations, use `NOASSERTION` and `licenseComments` explaining which case occurred, preserving the observed text/reference where available. `licenseConcluded` remains `NOASSERTION`; the generator makes no legal conclusion. Unknown declarations do not justify omitting known compiled package identities. A declaration capture/identity failure is an admission error, not a missing-license excuse. + +The frozen renderer recognizes MIT, Apache-2.0 and their two ordered OR combinations. Its npm projections currently provide no licence field, so declaration capture/projection requires review and implementation; it is not already closed by the source port. Do not extend expression support silently. A Rust wrapper's declaration does not establish all bundled native licences; neither Cargo/npm declarations nor explained `NOASSERTION` imply complete native/tool/SDK licence coverage. + +## Existing tool and builder identity context + +Record configured pins **and actual selected identities** with their source and scope. Read facts in the existing producing job/container; do not add acquisition, image builds or whole-SDK hashing for this option. + +| Context | Identity available / proposed observation | What must not be inferred | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Rust | `rust-toolchain.toml` 1.98.1; selected compiler/Cargo version/target, existing source pins and available executable identities | Rust std/compiler-builtins, helpers, libraries and sysroot material closure | +| Node/npm | `.tool-versions` Node 24.20.0; selected Node/npm versions and existing distribution verification | Native npm helper/lifecycle closure | +| GNU builder | Existing UBI 9.6 base reference/digest from `phase1-build.sh`, actual platform and local derived image ID | The base digest covers later unpinned `dnf install`; a local derived ID proves authenticated package acquisition | +| macOS | Existing `macos-15`/`macos-15-intel` runner labels; observe selected Xcode version, SDK version/path selection and corresponding Mach-O SDK facts | Runner labels or deployment target 13.0 are Xcode/SDK pins, an immutable VM digest, SDK input closure or old-OS execution proof | +| Static musl | Existing musl/musl-dev/musl-tools 1.2.4-2 selections and `gcc-13` selector; actual selected versions | Complete GCC/musl/crt materials or authenticated platform-image closure | + +There is **no explicit Xcode/SDK pin** in the inspected ordinary release workflow. Recording the selected versions is proposed same-job observation, not evidence already retained by that producer. Do not invent a pin or use controlled-recipe fixtures as ordinary-job facts. Required identity fields that cannot be observed must fail the proposed profile rather than be guessed. No new pin, resource or stronger acquisition requirement is adopted here. Fastly's existing pinned CLI route remains packaging verification, not toolchain closure. + +## Scope statement inside every SBOM + +Proposed common profile identifier: `issue-cargo-npm-build-v1`. This is a proposed coverage claim, not a new schema/policy file. Encode a bounded, versioned scope statement in SPDX's existing `creationInfo.comment`; preserve per-component origin/role/`licenseComments`. The later plan must specify deterministic rendering and admission of that statement, with no new undeclared SPDX properties. + +Draft required text, present in **each** shipped document: + +> Coverage profile: issue-cargo-npm-build-v1. Build-derived Cargo/npm SBOM with logical runtime requirements for the identified executable. Cargo membership derives from this release build's observed compilation; npm membership derives from its actual private bundle contributions and module evidence. Compiled inputs and build tools are distinguished from positively observed retained modules. Further linking/minification and fragment retention are not fully attributed. Tool and builder records identify selected versions/pins only. This document does not establish complete native C/assembly, static libraries, Rust standard-library/compiler-runtime, linker/helper, system-library, SDK/header/stub/framework, image-package or corresponding licence closure. External requirements describe logical dependencies, not the operator's installed runtime or configuration. This is not a complete-composition or controlled-build qualification claim. + +The runtime requirements and source/target/artifact joins remain structured package/relationship evidence, not just prose. The common scope marker is required even if a target has no positively retained JS module. Tool identities are build context/materials, never evidence that their complete contents are in the binary. Do not set a whole-document completeness or qualified-coverage flag to true. Existing strict `generate_from_completed`, candidate rejection and uninhabited `Qualified*` capabilities remain unchanged; any later issue-scoped admission must be a separately reviewed owning-evidence path, not candidate promotion or a fallback on strict failure. + +### Draft verification-guide wording after approval and implementation + +The existing guide describes the current interim inventory correctly. Do not replace it with a future claim in this docs-only change. The proposed replacement after real implementation is: + +> Each `.spdx.json` is a build-derived Cargo/npm SBOM under profile `issue-cargo-npm-build-v1`. It records packages observed in that binary's release build, the actual private JS bundle/module evidence, declared Cargo/npm licences with explained `NOASSERTION`, and logical runtime requirements from the final executable. Its root SHA-256 binds it to the binary. It distinguishes build inputs/tools from positively observed retained modules; exact final functions or transformed-fragment attribution are not claimed. Native implementation, Rust sysroot/compiler-runtime, system-library, SDK and tool/image-package closure and their full licence coverage are explicit limits recorded inside every SBOM. Verified provenance/SPDX authenticates this stated scope; it does not establish complete composition, vulnerability absence or installed-runtime identity. Strict native/platform closure is a tracked future enhancement under #1235's follow-up work, not a prerequisite hidden inside this profile. + +Existing online/offline signer/source/tag pins, bundle and immutable-asset verification and exact shipped-SPDX/predicate comparison remain necessary and unchanged. Consumers must inspect and enforce the scope/profile they require; the SPDX predicate URI alone does not prove a particular coverage level. No future operator CLI/consumer implementation is authorized by this wording. + +## Draft Gate 2 spec amendment for approval + +The following is proposed amendment text to the Phase 3 Gate 2 spec/plan and the phased interpretation of the retained R-SBOM/R-COHORT requirements. It is not an edit to the carried #1262 sentences and does not claim approval of a changed contract: + +> **Issue-scoped release SBOM milestone.** To align the next milestone with #1235's requirement for release-build Cargo/npm coverage, every one of the seven selected binaries MUST have an SPDX 2.3 build-derived Cargo/npm SBOM admitted under the explicitly identified issue-scoped profile. It MUST derive compiled Cargo membership from the successful actual build stream, with unfiltered identity metadata and reconciled target/host/unit context; derive npm membership from that execution's private bundles, contributions and module evidence; report the artifact's logical runtime requirements; and retain declared Cargo/npm licence evidence with explained `NOASSERTION`. The release artifact root MUST retain its public filename, exact release-tag version and executable SHA-256; Cargo component versions and source commit MUST remain independently identified. +> +> Each SBOM MUST record the common coverage profile, conservative membership limits, selected tool/builder identity context and explicit lack of strict native/platform/tool/sysroot/SDK material and corresponding licence closure. This scoped document MUST NOT be described as complete composition, hermeticity, controlled qualification or exact retained machine-code attribution. Missing or inconsistent in-scope evidence for any member MUST block the entire seven-binary cohort. There MUST be no per-target activation, silent subset, downgrade or strict-failure fallback. Existing no-configuration-in-the-binary, source/signer, immutable-publication, predicate/root equality, parser/size bounds and Fastly ordinary equality requirements remain in force. +> +> Strict native/platform composition closure remains a tracked future enhancement, with its unresolved evidence and acquisition questions retained in the full-cohort assessment. Its qualified types and complete-production generation remain closed. Accepting this milestone does not declare those cells resolved, delete their tests or authorize a new resource. A future stronger profile requires separate reviewed evidence and explicit policy, without renaming the published SBOM companion or weakening historical strict policy. + +This amendment requires an explicit user/maintainer decision on the coverage reduction. Approval to discuss it is not approval to implement, publish or change existing strict admission. It settles neither legal licence sufficiency nor future native/platform resources. + +## Gate 3 consequence: one cohort and explicit assurance + +If this amendment is approved, the revised Gate 3 plan must keep **all seven rows together** under neutral v2 cohort `trusted-server-binaries`, each requiring the **same issue-scoped SBOM profile**. Fastly additionally requires the existing matched ordinary A/B receipt; native reproduction remains measured, not a release gate. The existing `provenance-sbom` / `provenance-sbom-repeatable` labels alone must not conceal the reduced SBOM scope: a reviewed explicit coverage discriminator/policy identity must distinguish this profile from strict complete composition. Its field placement is for the revised v2 plan, not a schema change authorized now. + +For that amended profile, “repeatable” still means the validated matched `ordinary-release-fastly-byte-equality-v1` receipt under Phase 2's narrower conditions. It does not mean controlled recipe/native/platform qualification. Do not broaden false-qualification acceptance globally, reuse a candidate build-evidence/build-recipe document, or make an empty/placeholder companion admissible. The later plan must define real issue-profile owning evidence for every required companion slot before publication. + +Frozen v1 policy/schema/fixtures and `phase1-fastly-cli` remain unchanged and do not automatically accept the issue-scoped profile. The proposed **53-file / 52-checksum destination**, existing seven filenames, companion names, exact retained ordinary receipt and cycle-free signing order remain the planning contract; this option creates or publishes none of those future companions. A scope amendment does not itself prove those writers or all 53 real files exist. Any need to alter that destination must return as a separate decision, not silently shrink it. + +Gate 3 Steps 2–5 stay held **now**, even while this option is reviewed. If the scope is accepted, bring back a revised concrete Gate 2/Gate 3 plan for approval before capture, v2 metadata/schema/admission/assembly or generator work. Consumer code remains Phase 4; publication integration, actual-workflow contract restoration and fork trust remain separately gated. There is no per-target early activation and no rehearsal authorization in this document. + +## Engineering estimate on the same basis + +Engineering person-days include design of the eventual adapter, implementation, focused verification and review **if later approved**. These are judgment ranges, not measured delivery bounds, staffing promises or elapsed schedules. They exclude approval waiting, live publication/consumer integration and later v2 index work. No strict N0/L0/T0/I0 acquisition/closure foundation is charged to this option; it uses existing fetched Cargo/npm materials and existing hosted execution. If a concrete plan requires new resources or observer adaptation, stop and re-estimate rather than treating this range as permission. + +| Shared foundation, counted once | Person-days | +| --------------------------------------------------------------------------------------- | ----------: | +| Actual Cargo stream, catalog, unit/feature and package/source joins | 4–6 | +| Ordinary private-bundle/npm contributions, actual-build ownership and module membership | 6–10 | +| Common final-byte logical runtime projection | 2–3 | +| Cargo/npm declared-licence capture and explained `NOASSERTION` | 1–2 | +| Scoped SPDX rendering/admission, limits, version mapping and existing identity context | 3–5 | +| Shared negative tests, whole-cohort handoff checks and focused review | 2–3 | +| **Shared subtotal** | **18–29** | + +| Target integration beyond shared work | Person-days | +| --------------------------------------------------------------- | ----------: | +| Fastly canonical/reproduction capture and RW | 1–2 | +| GNU x64 CLI, including actual container stream/material handoff | 2–3 | +| Apple ARM CLI / RM | 1–2 | +| Apple Intel CLI / RM after shared Apple work | 0.5–1 | +| GNU x64 Axum / RG after GNU work | 0.5–1 | +| Static musl Axum / ordinary RS support | 1.5–3 | +| GNU ARM Axum / ordinary RA and actual container evidence | 2–3 | +| **Target increments** | **8.5–15** | +| **Total issue-scoped engineering estimate** | **26.5–44** | + +The remaining cost is real ordinary capture/admission work: the source port does not enable complete generation, native jobs lack streams, JS capture is opt-in/Fastly-specific, npm licence fields are absent and RA/RS require ordinary representations. This estimate does not include resolving the strict native/platform gaps later. No target-specific activation or new builder is assumed to achieve it. + +## CI time impact per job + +Baselines below are **observed rehearsal `.16` at `5fa384336`**, as recorded in the [assessment](binary-attestations-phase3-full-cohort-feasibility.md); they are not current-head benchmarks. Added times are **unmeasured planning allowances** for local stream/catalog collection, source/private-output hashing, contribution recording, runtime projection, rendering and handoff. No trace-based closure, SDK tree hashing, new package acquisition, second npm build or image replacement is included. Do not credit possible savings from replacing the currently separately compiled converter/generator without measurement. + +| Job | Observed baseline | Proposed added allowance | Illustrative total | +| --------------------- | ----------------: | -----------------------: | -----------------: | +| Fastly canonical A | 5:53 | 1–4 min | 6:53–9:53 | +| Fastly reproduction B | 4:29 | 1–4 min | 5:29–8:29 | +| GNU x64 CLI | 8:14 | 1–4 min | 9:14–12:14 | +| Apple ARM CLI | 13:03 | 1–4 min | 14:03–17:03 | +| Apple Intel CLI | 31:09 | 2–6 min | 33:09–37:09 | +| GNU x64 Axum | 5:24 | 1–4 min | 6:24–9:24 | +| Static musl Axum | 4:47 | 1–4 min | 5:47–8:47 | +| GNU ARM Axum | 4:48 | 1–4 min | 5:48–8:48 | +| Fastly comparison | 1:26 | 0.25–1 min | 1:41–2:26 | + +These are eight producing jobs plus comparison, not nine different binaries. Added producer/comparison runner time totals **9.25–35 minutes**. Existing sequential A→B→comparison adds **2.25–9 minutes** under these assumptions; native jobs overlap, so neither subtotal is a release wall-time forecast. Intel's larger allowance is a conservative estimate based on its slower baseline, not a measured platform multiplier. + +For the unchanged signing and publication jobs, allow a provisional **0–1 extra minute each** for larger admitted SPDX/evidence payloads and existing verification; no extra signing/publication calls are proposed here. That yields **9.25–37 additional aggregate runner-minutes** including both allowances, excluding service latency variance. Validation/check jobs need focused fixtures but no proposed new build; their time impact is unmeasured and must be reported by the implementation plan. No claim is made that the allowances bound remote API latency. + +Existing producer jobs have 90-minute limits; Fastly comparison alone has ten minutes; signing and release have fifteen minutes each. The illustrative totals suggest headroom but prove no future fit. The eventual implementation must measure each job, retained evidence size and existing predicate bounds, report regressions and fail within the approved limits. Exceeding them requires a new reviewed decision, not an automatic timeout/resource increase. + +## Future enhancement and acceptance evidence + +Track **strict native/platform composition and licence closure** as a separate future enhancement under #1235's remaining phase work, linked to the existing 21-cell assessment and its native/tool/licence cells. This is a planning record, not a newly filed issue. It includes Rust std/compiler-builtins/crt/runtime material, bundled C/assembly/static archives, actual link selections, tool/helper/library/resource closure, SDK originals and authenticated derived image/package acquisition. Preserve the strict tests and unresolved evidence; do not tick those cells as closed after the narrower milestone. + +A later implementation plan must demonstrate all seven actual builds, their per-artifact C/J/runtime/declared-licence evidence and mandatory scope text, plus wrong-source/target/unit/lock/module/digest rejection, generated-origin failures, absent/unsupported declarations, missing scope markers, size/parser failures and whole-cohort rejection. Verify the distinction between prepared inputs, positively observed retained modules and unknown fragment retention. Prove that strict complete/qualified consumers still reject issue-profile evidence and that exact published SPDX equals the verified predicate. Such evidence and any live rehearsal require their later approvals; none is claimed here. + +## Review log + +- Round 1 (2026-10-28, fresh independent source/contract review): no blocking findings. Checked actual Cargo/private JS capture gaps, conservative membership and licence claims, runtime extensions, selected Xcode/SDK observations, all-seven rejection, strict-profile separation and authority/observer boundaries against repository source. The review did not independently retrieve the live issue; the author separately read #1235. No tests, capture, resources or measurements were performed. + +- Round 2 (2026-10-28, a second fresh independent estimate/source review): no blocking findings. Recomputed shared/target engineering totals, per-job baseline conversions, aggregate runner time and sequential Fastly impact; checked tool pins, actual Xcode/SDK limitations and all-seven scope against the workflows and assessment. Unmeasured overhead remains an estimate. No tests, acquisition or measurements were performed. + +These reviews assess the proposal, not adoption of the scope amendment. The user and maintainers still decide; no implementation or later gate is opened. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md index e6f30566c6..8eec1a72db 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md @@ -2,6 +2,8 @@ **Status:** Step 1 approved on 2026-10-28 for frozen-v1 regression checks, the assigned source port and restored assertions only. The Gate 3 design is approved with a new neutral v2 cohort ID, `trusted-server-binaries`. No v2 schema/policy/cohort/descriptor/admission/assembly, workflow, release script, signing, production selector or rehearsal tag is authorized. Consumer implementation remains Phase 4; live signing/publication and fork trust remain Gates 4/6. +**New option for decision:** The user has not approved target-specific activation. The [issue-scoped all-seven build-derived Cargo/npm SBOM option](binary-attestations-phase3-issue-scoped-sbom-option.md) proposes an explicit narrower coverage amendment, with per-SBOM/guide limits and a separate estimate. It is not adopted; strict Gate 2 still governs and Gate 3 Steps 2–5 remain held. + **Tracking:** Part of #1235; draft #1463. Read with the [approved design](binary-attestations-phase3-seven-artifact-index.md), [Gate 2 coverage/rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) and [phase assignment](binary-attestations-phases-2-4.md). ## Frozen and new identities From d885a965a6f4a488c75751c77accd7a3a41e1102 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 29 Oct 2026 10:51:45 +0530 Subject: [PATCH 8/8] Record the pending maintainer decision and freeze Phase 3 --- ...testations-phase3-issue-scoped-sbom-option.md | 16 +++++++++------- ...phase3-seven-artifact-index-implementation.md | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md b/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md index 72dbd54c58..b3f27c3795 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md +++ b/docs/superpowers/plans/binary-attestations-phase3-issue-scoped-sbom-option.md @@ -1,20 +1,22 @@ # Gate 2 option: build-derived Cargo/npm SBOMs for all seven binaries -**Status:** Proposed scope amendment for the user and maintainers, requested on 2026-10-28. Docs only; not approved or implemented. The approved strict Gate 2 remains the governing contract until this amendment is accepted. The target-specific rollout recommendation has not been accepted. Gate 3 Steps 2–5 remain held. Draft #1463 stays draft, without reviewers. +**Status:** Endorsed by the user in principle; awaiting maintainer decision; not adopted; no implementation. The proposed all-seven `issue-cargo-npm-build-v1` target would replace strict Gate 2 only after maintainer agreement. Strict closure remains a tracked future enhancement; this endorsement does not amend the governing contract or authorize work. Phase 3 is frozen at completed Step 1; Gate 3 Steps 2–5 remain held. Draft #1463 stays draft, without reviewers. **Issue:** [#1235](https://github.com/IABTechLab/trusted-server/issues/1235). **Source baseline:** Phase 3 `4741ab712365efa9f578a9b461f9a26a48bb9c5d`; frozen port source `122b78664b1235c717e9208fd200f9a9b0c335df`. No implementation, resources, acquisition, workflow/signing changes or rehearsal tags follow from this proposal. -## Decision and recommended claim +## Maintainer decision and recommended claim + +The user considers this a precision upgrade over Phase 1’s labelled conservative inventory, whose publication and predicate verification already satisfy the SBOM verification item in #1235’s Done-when. Maintainers must decide whether this upgrade belongs inside #1235 or a follow-up; no issue-closure or scope decision is claimed here. Adopt, if approved, an **issue-scoped build-derived Cargo/npm SBOM** for every existing release binary, together with artifact-bound logical runtime requirements. Keep all seven assets in one admission/publication cohort. This aligns the next SBOM milestone with #1235's release-build Cargo/npm requirement; it explicitly amends the broader strict-composition scope rather than claiming its native/platform gaps are solved. Recommended public document name: **Build-derived Cargo/npm SBOM**. Recommended design title after approval: **Gate 2: build-derived Cargo/npm SBOMs and runtime requirements**. Use **admitted under the issue-scoped build profile**, never “complete SBOM,” “complete composition,” “fully covered,” or “qualified build.” Keep SPDX 2.3, `https://spdx.dev/Document/v2.3`, `.spdx.json`, all bundle names, source pins and signer identity unchanged. Renaming or superseding the existing `complete-spdx` planning documents would be a later documentation migration; this proposal renames no existing file or release asset. -| Direction | Coverage and cohort | Decision status | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| **Issue-scoped all-seven — recommended for this decision** | Actual Cargo/npm build evidence, declared licences and logical runtime requirements; explicit native/platform limits | Proposed amendment in this document | -| Strict all-seven | Also requires retained native/tool/sysroot/SDK input closure and corresponding licence evidence | Previously approved Gate 2; expensive unresolved cells remain | -| Target-specific strict rollout | Changes when individual targets could activate | Not approved; not used by this option | +| Direction | Coverage and cohort | Decision status | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Issue-scoped all-seven — recommended for this decision** | Actual Cargo/npm build evidence, declared licences and logical runtime requirements; explicit native/platform limits | Endorsed in principle; awaiting maintainer decision; not adopted | +| Strict all-seven | Also requires retained native/tool/sysroot/SDK input closure and corresponding licence evidence | Previously approved Gate 2; expensive unresolved cells remain | +| Target-specific strict rollout | Changes when individual targets could activate | Not approved; not used by this option | The previous [80–136 person-day estimate](binary-attestations-phase3-full-cohort-feasibility.md) prices unresolved strict native/platform/licence closure. It explicitly excludes Cargo/JS/runtime capture and generator integration. It is not the cost of satisfying the issue-scoped Cargo/npm requirement, and cannot be reduced to this option by subtracting an arbitrary percentage. diff --git a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md index 8eec1a72db..3bc0f46805 100644 --- a/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md +++ b/docs/superpowers/plans/binary-attestations-phase3-seven-artifact-index-implementation.md @@ -2,7 +2,7 @@ **Status:** Step 1 approved on 2026-10-28 for frozen-v1 regression checks, the assigned source port and restored assertions only. The Gate 3 design is approved with a new neutral v2 cohort ID, `trusted-server-binaries`. No v2 schema/policy/cohort/descriptor/admission/assembly, workflow, release script, signing, production selector or rehearsal tag is authorized. Consumer implementation remains Phase 4; live signing/publication and fork trust remain Gates 4/6. -**New option for decision:** The user has not approved target-specific activation. The [issue-scoped all-seven build-derived Cargo/npm SBOM option](binary-attestations-phase3-issue-scoped-sbom-option.md) proposes an explicit narrower coverage amendment, with per-SBOM/guide limits and a separate estimate. It is not adopted; strict Gate 2 still governs and Gate 3 Steps 2–5 remain held. +**Maintainer decision pending:** The user endorses the [issue-scoped all-seven Build-derived Cargo/npm SBOM option](binary-attestations-phase3-issue-scoped-sbom-option.md), profile `issue-cargo-npm-build-v1`, in principle as the proposed replacement target for strict Gate 2. It is not adopted and authorizes no implementation. Maintainers decide whether the precision upgrade belongs in #1235 or a follow-up; strict closure remains a tracked future enhancement. Phase 3 is frozen at completed Step 1. Gate 3 Steps 2–5, and every later production/publication/consumer gate, remain held. Draft #1463 stays draft, with no reviewers. **Tracking:** Part of #1235; draft #1463. Read with the [approved design](binary-attestations-phase3-seven-artifact-index.md), [Gate 2 coverage/rollout decision](binary-attestations-phase3-complete-spdx-implementation.md) and [phase assignment](binary-attestations-phases-2-4.md).