From 91c190710a821fad73835a5f32c3628ffff20636 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 10:11:42 +0530 Subject: [PATCH 001/153] Design release binary attestation --- ...6-10-09-1235-release-attestation-design.md | 637 ++++++++++++++++++ 1 file changed, 637 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md new file mode 100644 index 0000000000..b464457f1f --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -0,0 +1,637 @@ +# Release binary attestation and publisher attestation foundations + +**Issue:** [#1235 — Publish attested release binaries with build provenance and SBOMs](https://github.com/IABTechLab/trusted-server/issues/1235) + +**Date:** 2026-10-09 + +**Status:** Draft for maintainer review. Implementation requires approval and merge of this specification in its own PR. + +**Source baseline:** `da31a215e62a8201610de367ee06d572708952df`. + +**Related work:** [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#167](https://github.com/IABTechLab/trusted-server/pull/167), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), and [#1084](https://github.com/IABTechLab/trusted-server/pull/1084). + +## Problem + +Publishers and vendors need to verify that a distributed Trusted Server binary was built from the reviewed repository source. Current CI builds binaries for validation but publishes no attested release distribution. Operators build their own wasm, and `ts deploy` delegates to deployment paths that can build again before uploading. + +A signature on a release artifact is useful only when the deployment path preserves the artifact identity being verified. Fastly packaging also matters: `compute publish` builds before deployment, and `compute build` can annotate the wasm after Cargo produces it. Attesting one file and uploading a transformed file breaks the connection to the original attestation. + +Project attestation will eventually include publisher identity, configuration, deployment, runtime claims, and possibly vendor endorsements. Binary provenance must provide a stable subject for those claims without treating them as equivalent or choosing their future wire format prematurely. + +This design establishes a canonical release artifact per version, target, and approved build variant; provenance and SBOM evidence for those bytes; and a deployment path that consumes verified bytes. It defines the minimal interfaces that later publisher attestation can reference. + +## Goals + +1. Publish the first Fastly wasm and Linux/macOS `ts` executables through an immutable GitHub Release. +2. Generate and verify SLSA build provenance and an artifact-specific SBOM attestation for every executable artifact. +3. Require fresh locked Rust and JS builds with an explicit compile-time input inventory. +4. Prevent publisher configuration, secrets, resource bindings, and publisher-specific permission policy from affecting release binary bytes. +5. Deploy an attested Fastly artifact without compiling or modifying its wasm, for both production and staging. +6. Provide exact online and offline verification procedures with a constrained signer, source ref, and source revision. +7. Make immutable build facts available to future #161 work, while keeping the final binary digest external. +8. Keep artifact identity, publisher identity, configuration identity, deployment observations, and runtime evidence distinct. +9. Preserve existing logical-store mapping, staged configuration isolation, health checks, and rollback behavior. + +## Non-goals + +- Runtime attestation endpoints, per-request claims, remote execution proof, and freshness/replay protocols. These belong to #161. +- Publisher enrollment, domain ownership verification, a publisher trust registry, or selection of DSSE/JWT for publisher claims. +- Signing or attesting `trusted-server.toml`, computing a new public configuration hash, or exposing resolved settings. +- Resolving permission-policy distribution or authorization semantics. The permission specification owns these decisions; this specification supplies a compatibility requirement. +- Vendor endorsement protocols or CODEOWNERS changes. +- Signatures or the final artifact checksum embedded in a wasm custom section. +- Reproducible independent builds as a prerequisite for the first release. +- Cloudflare, Spin, Axum, containers, Windows, Linux ARM64, universal macOS binaries, or standalone tsjs release distributions. +- Attesting the publisher-selected external Prebid.js bundle. +- Making all CLI commands self-contained without their documented external tools or repository resources. +- Apple Developer ID signing/notarization or automatic installer/self-update infrastructure. + +## Decisions + +| Question | Decision | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| First executable artifacts | Fastly `wasm32-wasip1`; CLI `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, and `x86_64-apple-darwin` | +| Fastly build variant | Existing production defaults; `reusable-sandbox` and test features are off | +| Deployment strategy | Verify and deploy release artifacts first; independent reproducible builds follow separately | +| Integrated operator flow | Add an artifact deployment path to `ts deploy` through an additive EdgeZero interface; retain a documented native Fastly packing route during rollout | +| SBOM | SPDX 2.3 JSON generated by pinned Syft plus a repository-owned composition step using the actual Cargo build graph and JS bundle evidence | +| SBOM distribution | One SBOM per executable, published as a release asset and embedded as that executable's SBOM attestation predicate | +| Release trigger | Protected `v*` tag push, followed by strict stable/prerelease SemVer validation; no arbitrary-ref manual publishing | +| Workflow boundary | `.github/workflows/release.yml` calls `.github/workflows/release-build.yml`; the reusable workflow builds and signs | +| SLSA claim | Initially Build Level 2; using a reusable workflow does not by itself justify a Level 3 claim | +| Signing mechanism | GitHub `actions/attest` v4, pinned by full commit SHA; GitHub OIDC and public-good Sigstore | +| Verification implementation | Reuse the GitHub CLI's verifier through a checked subprocess interface; do not implement a second Sigstore verifier in Rust | +| Artifact identity | SHA-256 of the final distributed file, with exact target and build variant recorded separately | +| Runtime build facts | Release version, package version, source commit, target, and build variant; no self-computed wasm checksum | +| Publisher groundwork | A verified artifact input, structured deployment observations, and documented identity boundaries; no publisher claim implementation | + +## Current behavior and affected surfaces + +The baseline has two repository-owned build scripts: core and JS. EdgeZero is pinned to `683202c66948146ec360f490126f1d60f920288b`; its CLI build script generates adapter registrations from its manifest and Cargo features. It does not embed publisher settings. Trusted Server does not currently use EdgeZero's manifest-embedding `app!` macro. + +| Surface | Baseline behavior | Design consequence | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-cli/src/run.rs` | Delegates build, deploy, and typed config push to EdgeZero | Artifact deployment must extend the owning interface rather than wrap an unconditional build | +| `edgezero.toml` | Fastly production deploy selects `fastly compute publish` | Verified artifact deployment bypasses source-build manifest commands | +| EdgeZero Fastly staging | Builds, clones/updates a service version, redirects its configuration selector, and stages it | Verification must precede the first provider write; staging must accept a prebuilt package | +| `crates/trusted-server-core/src/config_payload.rs` | Verifies a blob envelope, resolves secrets, and returns validated `Settings` | Existing configuration separation is retained | +| `crates/trusted-server-core/build.rs` | Extracts the default logical config-store ID and hashes core inputs into `TEMPLATE_BUILD_DIGEST` | Release builds use the repository manifest unchanged; the template digest is not artifact identity | +| `crates/trusted-server-js/build.rs` | Builds and embeds discovered `tsjs-*.js`, with several fallback paths | A strict release mode must reject fallback and incomplete module inventories | +| `crates/trusted-server-js/lib/build-all.mjs` | Vite/Rollup IIFEs, esbuild minification, cleaned `dist` | Capture package contribution evidence from the actual bundle build | +| `crates/trusted-server-cli/src/prebid_bundle.rs` | Locates JS resources in a repository and has a compiled checkout-path fallback | CLI distribution must document source-resource dependencies and record this compile-time input | +| `.github/workflows/test.yml` | Builds/tests adapters and native CLI without release publication | Add a dedicated release workflow; do not turn ordinary PR CI into a signing authority | +| `crates/trusted-server-core/src/request_signing/` | Deployment Ed25519 keys, JWKS discovery, request signatures | These keys and signatures remain separate from release provenance | + +## Design + +### 1. Trust claims and assurance boundary + +| Claim | Subject and authority | What it does not establish | +| ------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| Build provenance | Exact artifact digest; trusted GitHub workflow identity | Publisher identity, approved configuration, or live execution | +| Release membership | Tag, source commit, and release assets; GitHub immutable release attestation | Build correctness or acceptable software behavior | +| Publisher identity | Publisher/domain/key relationship; a future externally trusted identity policy | Which artifact is deployed | +| Deployment binding | Artifact, service/version, environment, and observed deployment result | Independent proof of which bytes execute at an edge location | +| Runtime claims | Loaded instance/configuration/request snapshot; future #161 policy | Truth against an operator controlling both code and signing keys without additional evidence | +| Vendor endorsement | Defined code/configuration/policy scope; vendor authority | Approval of unrelated scope or publisher authorization | + +A publisher can run altered code that reports an official artifact digest and signs that statement with a legitimate deployment key. The official binary provenance and that publisher signature can both verify. This design therefore promises provenance of distributed bytes and preservation of submitted bytes, not remote execution attestation. + +Consumers continue to trust the reviewed source, the chosen build workflow, GitHub's identity assertions, and the applicable Sigstore trust roots. Provenance does not establish absence of vulnerabilities or correctness of an SBOM. Release integrity does not prevent an authorized future release from containing different software. + +```mermaid +flowchart LR + source[Reviewed tag and locked inputs] --> build[Release build] + build --> binary[Canonical artifact bytes] + build --> composition[Build composition evidence] + binary --> attest[Provenance and SBOM attestations] + composition --> attest + binary --> release[Immutable release] + attest --> release + release --> verify[Verify artifact and evidence] + verify --> package[Package without rebuilding] + bindings[Publisher platform bindings] --> package + package --> deploy[Submit verified module] + config[Runtime publisher configuration] --> runtime[Running deployment] + deploy --> runtime + deploy --> receipt[Deployment observations] + receipt -. future publisher claim .-> future[Publisher attestation] + runtime -. future loaded snapshot .-> future +``` + +### 2. No configuration in release binaries + +**Requirement: no configuration may be compiled into the release binary. Anything that varies by deployment must enter through runtime app configuration or platform stores/bindings.** + +This prohibits publisher domains, service/account IDs, physical store names, customer-specific integration selection, secrets, configuration revisions, and deployment-specific permission policy as build inputs. A canonical artifact includes the repository's registered integrations; configuration selects their runtime behavior. Operators do not produce reduced publisher-specific binaries and call them the canonical release. + +The repository's logical store identifiers, product defaults, and the fictional CLI configuration scaffold are source-defined product inputs, not an operator's active configuration. They remain identical for all users of a release artifact. The CLI scaffold is used to create a configuration file, never as an embedded deployment configuration fallback. + +The repository copy of `edgezero.toml` is immutable within a release build. Physical resource names and version-linked selectors are configured at deployment time. An operator modifying a local manifest must still deploy the downloaded release wasm; the manifest is not supplied to Cargo for that artifact. + +The proposed embedded permission policy in #1045/#1084 cannot be adopted as publisher-variable build data. A fixed enforcement implementation may be source code, but variable policy must preserve the separation above. A conflicting permission change blocks publication until its owning specification resolves the conflict. This design does not choose a policy store, policy signature, or policy authority. + +### 3. Complete compile-time input contract + +Every release records the following input classes in build evidence. New inputs must be added to this inventory in the implementation PR introducing them. An attestation is not a claim that generic GitHub provenance automatically enumerates every transitive input; the supplementary build evidence supplies the details below. + +| Input | Scope and control | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Git source snapshot | Exact peeled tag commit and tracked bytes of workspace/crate manifests, Rust/TS/JS source, embedded assets, build scripts, and release scripts | +| Cargo resolution | Root `Cargo.lock`, registry checksums, exact git revisions, resolved package/features, target and host dependency roles | +| npm resolution | JS `package.json` and `package-lock.json`, pinned Node and bundled npm version, installed package versions and integrity metadata | +| Toolchain configuration | `rust-toolchain.toml`, `.tool-versions`, `.cargo/config.toml`, Rust compiler/LLVM/sysroot identity, Node/npm identity | +| Build recipe | Package and binary target, target triple, explicit feature policy, release profile including current `debug = 1`, linker/native compiler settings and any byte transformations | +| Repository EdgeZero manifest | Entire tracked `edgezero.toml`, default logical configuration ID derived by core, and the manifest digest | +| Core template build digest | All non-editor regular files under `core/src`, core `build.rs`/`Cargo.toml`, and root `Cargo.toml`/`Cargo.lock`/`edgezero.toml`; normalized relative paths and original bytes | +| Embedded/generated JS | Fresh core and discovered integration IIFEs, exact module IDs/digests, and the generated Rust module table copied into `OUT_DIR` | +| Other embedded assets | Core GPT bootstrap, CLI browser collector/consent scripts, and the CLI's tracked `trusted-server.example.toml` scaffold | +| OpenRTB bindings | Checked-in generated bindings and their tracked sources; normal releases do not regenerate protobuf or invoke an ambient `protoc` | +| Dependency-generated code | Build-script and proc-macro outputs, including EdgeZero CLI adapter registrations derived from its pinned manifest and enabled features | +| Release build facts | Validated release version, source commit, target and variant supplied by the release recipe; package version comes from the unchanged Cargo manifest | +| Native build inputs | Runner image version, SDK/Xcode where applicable, linker, compiler/assembler, build tools, vendored native library metadata, and linked external libraries | +| Paths and environment | Checkout/build paths, `CARGO_MANIFEST_DIR`, `OUT_DIR`, reviewed compile-trace wrapper, sanitized compiler/native-tool settings, explicit Vite mode, and permitted release environment values | + +`TEMPLATE_BUILD_DIGEST` hashes the complete raw manifest and can change after a comment-only manifest edit. It also includes ordinary untracked files under core source. Release builds therefore use a fresh checkout containing only the expected source snapshot and release outputs outside `core/src`. The digest remains a cache compatibility input; it does not cover the complete binary or compiler and must not be advertised as provenance. + +The CLI currently embeds `CARGO_MANIFEST_DIR` as a resource-discovery fallback, and debug metadata can contain paths. These are recorded build inputs. This first release makes no byte-identical-rebuild claim. Removing path-dependent data, normalizing debug information, stabilizing native SDK inputs, and testing independent rebuilds belong to later reproducibility work. + +The release environment removes operator `TRUSTED_SERVER__*` and `EDGEZERO__*` values, `TSJS_SKIP_BUILD`, unapproved `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`, npm configuration overrides, and Vite environment overrides. Vite release execution explicitly disables environment-file loading and uses the approved production mode. Release scripts validate permitted native compiler settings rather than inheriting operator values. Platform authentication is unavailable to build jobs. + +No wall-clock build timestamp, workflow run ID, publisher identity, or final artifact checksum is embedded. Operational timestamps and run identifiers remain external evidence. `SOURCE_DATE_EPOCH`, if used by a component, is derived from the source commit and recorded. + +### 4. Artifact matrix and distribution + +| Asset name | Cargo package and target | Hosted runner | Compatibility | +| ----------------------------- | ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------- | +| `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly`, `wasm32-wasip1` | `ubuntu-24.04` | Fastly production default feature set | +| `ts-x86_64-unknown-linux-gnu` | `trusted-server-cli`, `x86_64-unknown-linux-gnu` | `ubuntu-24.04` | Initial Linux baseline: Ubuntu 24.04; record actual dynamic library requirements | +| `ts-aarch64-apple-darwin` | `trusted-server-cli`, `aarch64-apple-darwin` | `macos-15` | macOS 15 ARM64; explicit deployment target 15.0 | +| `ts-x86_64-apple-darwin` | `trusted-server-cli`, `x86_64-apple-darwin` | `macos-15-intel` | macOS 15 Intel; explicit deployment target 15.0 | + +Runner labels fix an OS generation, not an immutable VM image. Capture the actual image/toolchain versions and perform executable startup checks on each selected runner. Changes to compatibility require a reviewed recipe change. Linux portability to older glibc, ARM Linux, and older macOS is not implied. + +Use explicit Cargo packages/targets with `--release --locked`. Fastly does not enable `reusable-sandbox`; production build commands do not use `--all-features`. CLI uses its existing default feature graph, including EdgeZero's default adapter registrations. Do not remove dependencies merely because they appear unrelated at the workspace level. + +Ship raw executables to avoid an archive/extracted-executable identity ambiguity. Downloads may need their executable permission restored after verification. Any linker signing, stripping, or other transformation happens before checksums and attestation. No operation may modify an attested executable afterward. + +Each executable has named companions: + +- `.spdx.json` — its artifact-specific SBOM. +- `.provenance.sigstore.json` — the generated build-provenance bundle. +- `.sbom.sigstore.json` — the generated SBOM attestation bundle. +- `.build-evidence.json` — selected build inputs, composition roles, and embedded module inventory. + +Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, and `SHA256SUMS`. The checksum file covers every other published asset and never itself. The release index excludes its own digest and its own attestation bundle; its signed identity is external, so there is no checksum cycle. + +The release index is a versioned distribution index, not a publisher-attestation schema. Version 1 records repository, tag, source commit, recipe revision, and an exact artifact list. Each artifact entry records name, byte length, SHA-256, package, target, variant, compatibility baseline, and companion asset names/digests. Consumers reject duplicate names, unknown required schema versions, traversal paths, and conflicting identities. + +Each artifact's provenance covers its binary, SBOM file, and build-evidence file as explicit subjects. The separate SBOM invocation covers the binary as subject and the SPDX document as predicate. The release index receives its own provenance attestation after the matrix artifacts and bundles are collected. Index assembly verifies that all entries came from the expected source/ref and complete matrix. + +`ts --version` identifies the release version and source revision. The existing Cargo package version is retained separately; the release process does not edit `Cargo.toml` to make an existing workspace version equal the tag. + +CLI help and `ts config init` must work outside a checkout. Repository-dependent developer commands and `ts prebid client` keep documented prerequisites; missing source resources return a clear diagnostic rather than implying that the release binary includes those resources. The release does not introduce an installer for them. + +### 5. Strict JS and clean build execution + +Introduce a tracked `TSJS_RELEASE_BUILD=1` mode for the Cargo JS build script. It is a release policy switch, not a mechanism to embed publisher settings. Preserve the documented local fallback mode outside release execution. + +Strict mode: + +1. Rejects the presence of `TSJS_SKIP_BUILD`, including an empty value or `0`. +2. Requires the JS manifest, lockfile, pinned Node/npm, and build entry points. +3. Runs `npm ci` with build dependencies included even if `node_modules` already exists; any invocation or installation failure stops the build. +4. Deletes old JS output and builds bundles during the Cargo build that embeds them. +5. Requires exactly core plus the sorted integration entry points discovered from tracked source. Missing, empty, duplicate, or unexpected `tsjs-*.js` output is an error. +6. Produces bundle composition evidence during that build and checks each bundle's recorded digest against the bytes copied into `OUT_DIR`. +7. Checks the exit status of explicitly requested JS tests. Release validation also runs JS tests as an independent blocking step. +8. Tracks the strict flag, skip/test flags, selected tools, recipe inputs, and relevant environment changes so an incremental local invocation cannot silently keep a previous permissive result. + +Release jobs have fresh checkouts and isolated Cargo target directories. Do not restore compiled Cargo output, `dist`, `OUT_DIR`, or `node_modules` from caches. Verified Cargo/npm download caches may be used. No parallel Cargo process mutates a shared crate `dist` directory; each matrix job owns its checkout. + +Capture expected source identities before building and ensure tracked inputs have not changed afterward. Generated output belongs in dedicated output paths. Release scripts are responsible for validating source-only directories, not just checking that `git status` reports no tracked modifications. + +### 6. Artifact-specific SBOM generation + +The generator choice is **pinned Syft with a repository-owned build composition adapter**, emitting SPDX 2.3 JSON. The adapter is release tooling, not a new runtime dependency or a general attestation framework. It reconciles Syft package/license metadata with the packages and files observed in this artifact's build. + +The implementation pins Syft's exact version and verified download digest in a reviewed release-tools manifest. The release records that pin and the composition adapter's source revision. Generator upgrades require fixture and schema validation; a floating installer or `latest` version is not permitted. + +#### Cargo composition + +Run the selected release build with Cargo JSON messages and fresh output. Preserve `compiler-artifact` and `build-script-executed` evidence, package IDs, features, target kinds, and native link declarations. Correlate these with locked Cargo metadata for both the artifact target and build host; host-built proc macros and build dependencies must not disappear because metadata was filtered only to the wasm target. + +Cargo JSON messages establish compiled membership, but do not themselves expose compile-unit dependency edges or a compilation target triple. Capture actual compiler invocations through a reviewed, allowlisted `RUSTC_WRAPPER` that forwards the unchanged invocation and exit status while recording `--target`, crate type, feature configuration, output identity and `--extern` references. The wrapper belongs to the pinned recipe and introduces no compiler flag transformations. Compiler probes are distinguished from real compilation. Join these records to Cargo package/artifact metadata to preserve each host/target compilation unit and its feature set; resolve dependency roles from the observed edges rather than assuming a workspace metadata graph is the unit graph. Ambiguous unit or source mappings fail generation. + +Classify normal target dependencies, proc macros, build scripts/build dependencies, and tooling separately. Packages with more than one role retain each role. Exclude dev-only packages not used by the selected release build. Include the Rust standard library/sysroot identity and vendored/native components where the build declares them, rather than treating Cargo registry packages as the entire binary composition. + +This is a conservative build-derived component inventory: selected compiled components may include code eliminated by the linker. The SBOM must not claim exact function-level reachability. A complete workspace lockfile is retained as build material but is not labeled as the selected artifact's runtime dependency list. + +#### JS composition + +Add a release-only Rollup output hook to `build-all.mjs` that records module contributions for each IIFE. Record contributing package roots, versions, lockfile integrity metadata, bundle filenames/digests, and repository source modules using normalized relative paths. The inventory conservatively records contributions surviving Rollup rendering; Vite's later esbuild minification/tree shaking can remove additional code, so Rollup's module records do not establish exact final-code reachability. Exclude type-only imports and modules eliminated by Rollup. Compute the bundle digest from the final written file after all transformations. Virtual/plugin-generated modules and helpers introduced by later transforms identify their generating tool or repository source rather than disappear from the evidence. + +Preserve the installed npm dependency inventory separately as build material, including Vite, Rollup, esbuild, and relevant native packages. A dependency's npm `devDependency` classification does not mean it is irrelevant to production building. Package ownership mapping failures for contributing third-party modules fail the release; they do not silently create an empty JS component list. + +The Prebid shim is embedded. The external Prebid.js bundle is not. Although the JS project's lockfile includes `prebid.js`, a type-only import in the shim must not make the SBOM claim that full Prebid.js is inside the wasm. Future bundled third-party code must appear even if it is installed under a development dependency declaration. + +#### Composition and validation + +Syft catalogs the installed/build input material to supply package identities, checksums, and supported native/license metadata. The composition adapter enriches Rust licenses from Cargo manifests and available license files rather than assuming Syft's Rust catalogers provide them. It selects and relates the metadata using the Cargo and bundle observations above, adds first-party and sysroot/native records, and emits one SPDX document rooted at the final artifact's digest. Joins include Cargo source identity/revision and npm installation/lockfile location; name and version alone cannot distinguish every package source. + +Use explicit SPDX relationships to distinguish components used in the artifact from build dependencies/tools; preserve evidence roles in the companion build-evidence file where SPDX cannot express the full build selection. Dynamic platform libraries are declared external requirements, not falsely represented as vendored bytes. Missing license information uses `NOASSERTION`, not an invented license. Native inputs requiring manual declarations are captured in the reviewed recipe and validated against build evidence. + +Validate SPDX schema, unique component IDs, package sources, relationship endpoints, exact root artifact digest, and coverage against the observed graphs. Reject a document larger than the attestation action's supported predicate size rather than publishing an unattested fallback. + +A fixture proof is an implementation prerequisite: target-specific Cargo selection, host build dependencies, a real third-party JS contribution, the Prebid type-only case, and native component declarations must all be represented correctly. If the chosen generator cannot meet that contract, update this approved specification in the implementation PR before changing format or generator. The generator choice is settled; its adequacy is verified rather than assumed. + +### 7. Release workflow and publication authority + +`.github/workflows/release.yml` handles protected tag pushes and release publication. It accepts only validated SemVer `v` tags, resolves the peeled commit, requires it to belong to reviewed `main` history, and requires successful project checks for that exact commit. An annotated tag's object ID is not substituted for the source commit. + +Eligibility uses reviewed workflow-file identities, not arbitrary successful check names. Require completed `success` runs of `test.yml` and `format.yml` for the exact peeled commit in this repository, with `push` to `main` as the accepted event/ref. Clippy is part of `format.yml` at the baseline. Enumerate the required non-optional jobs in the recipe; skipped, cancelled, unrelated, fork, PR-only, stale, or still-running results do not satisfy the gate. The recipe must account for every AGENTS.md CI requirement. Gates whose workflow is path-filtered run as explicit blocking release-validation jobs when there is no eligible exact-commit result. A tag on an older reviewed commit is eligible only if that commit's required checks can still be established or rerun through the trusted validation path. + +The workflow invokes local `.github/workflows/release-build.yml` at the same source revision. The reusable workflow owns the fixed artifact matrix, recipes, build-evidence generation, and attestation calls, including its own index collection/signing job. The caller publication job does not sign the index. The reusable workflow does not accept arbitrary shell commands, script paths, artifact paths, repository overrides, or caller-generated provenance predicates. + +The initial supported assurance is SLSA Build Level 2. Level 3 requires a separately reviewed isolation design in which build execution cannot subvert provenance generation; moving steps into a reusable workflow alone does not satisfy that requirement. The early reusable boundary keeps that upgrade separate from runtime and publisher interfaces. + +All external actions are pinned by full commit SHA with a readable version comment. Binary tools are pinned and downloaded with verified identity/checksum. Build runners are GitHub-hosted. PR workflows, forks, and arbitrary manual-dispatch refs cannot publish official artifacts. + +| Job responsibility | Permissions | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Eligibility and validation | `contents: read`; `checks: read` and `actions: read` only where needed to verify exact-commit CI results | +| Settings capability preflights | `contents: read` for `GITHUB_TOKEN`; isolated repository administration-read credential described below | +| Build and attest | `contents: read`, `id-token: write`, `attestations: write`, `artifact-metadata: write` | +| Collect, validate, and attest index | Same attestation permissions; only authenticated artifacts from the current release run | +| Draft preparation and final publish | Separate jobs with `contents: write`; no OIDC signing, settings credential or deployment credentials | + +Declare read-only defaults and grant writes per job. Reusable-workflow callers grant only the permissions required by the called jobs. No platform credentials, operator configuration, or publisher secrets enter the release workflow. + +For each artifact, invoke SHA-pinned `actions/attest` twice: once for SLSA provenance with explicit binary/SBOM/build-evidence subjects, and once with the binary subject plus `sbom-path`. Supplying `sbom-path` selects SBOM mode; it does not also generate the separate provenance claim. Keep the returned Sigstore bundles as named release assets. + +Before publication, the collection job verifies all provenance/SBOM bundles with the expected reusable signer, source ref and commit, and hosted-runner policy. It verifies that each signed SPDX predicate corresponds to the published SBOM's parsed document, and that all artifact/build-evidence/SBOM subject digests match. It then creates and attests the release index. + +The draft preparation job creates a draft, uploads the complete expected asset set and checksums, re-downloads the draft assets, and validates their digests/evidence. A repeat settings preflight depends on successful draft preparation, and a separate final publication job depends on that preflight. Only then does it publish. Immutable releases must already be enabled; the initial settings preflight checks the repository capability before builds begin and the publication job confirms the resulting release is immutable afterward. If the setting cannot be established, the workflow stops without publishing. + +The immutable-release settings API requires repository administration-read access, which the ordinary `GITHUB_TOKEN` permission set above does not provide. Configure a repository-scoped read-only settings credential as `RELEASE_SETTINGS_READ_TOKEN`, with administration-read and no write permissions. It is available only to isolated settings preflight jobs that execute reviewed workflow logic without checkout/build scripts; build, signing and publication jobs never receive it. The initial preflight and a repeat preflight immediately before publication require a successful settings response with `enabled: true`; missing credentials, denied access, disabled state and indeterminate responses fail closed. Record the read result without recording the token. This workflow does not enable or disable immutability itself. A post-publication immutable-state check detects a settings change racing the last preflight and reports it as a release incident, not a successful immutable publication. + +Use a tag-scoped concurrency group with no cancellation of a publishing run. Re-running an already published tag refuses publication. A partial draft may be resumed only when its tag/source and every existing asset digest match the verified intended set; conflicting bytes require explicit draft recovery, never silent replacement or movement of a published tag. All mandatory matrix failures leave an unpublished draft or no release. An API uncertainty after publication is reconciled by reading release state, not by creating another release. + +Administrative prerequisites are part of rollout: immutable releases enabled; active `v*` tag rules limiting creation/update/deletion and bypass actors; approved workflow changes; and maintainers responsible for release authority under project governance. These settings are verified evidence, not effects implied by committing workflow YAML. + +### 8. Online verification policy and commands + +The trusted source repository is `IABTechLab/trusted-server`. The signer is `IABTechLab/trusted-server/.github/workflows/release-build.yml`, because the reusable workflow contains the attestation action. The source ref is the exact selected `refs/tags/` and the source digest is its reviewed peeled commit. The expected GitHub OIDC issuer is `https://token.actions.githubusercontent.com`. + +The CLI's release policy fixes repository, signer, issuer, supported targets/variants, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. + +The release index is authenticated before its target/variant/digest fields are trusted. Online consumers validate immutable release membership, expected tag/source identity, index provenance, artifact provenance, and the artifact's SPDX attestation. `SHA256SUMS` is a convenience check only until authenticated through the release or independently signed evidence. + +The following uses a fictional future release tag. The reviewed commit is obtained from the verified immutable release record and the consumer's release approval policy; a value read from an unauthenticated JSON index is insufficient. + +```sh +RELEASE_TAG=v1.2.0 +REVIEWED_COMMIT='' + +gh release download "$RELEASE_TAG" \ + -R IABTechLab/trusted-server \ + --pattern 'trusted-server-fastly.wasm*' \ + --pattern 'release-index*' \ + --pattern SHA256SUMS + +gh release verify "$RELEASE_TAG" -R IABTechLab/trusted-server +gh release verify-asset "$RELEASE_TAG" release-index.json \ + -R IABTechLab/trusted-server + +gh attestation verify release-index.json \ + -R IABTechLab/trusted-server \ + --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$REVIEWED_COMMIT" \ + --deny-self-hosted-runners + +gh attestation verify trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server \ + --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$REVIEWED_COMMIT" \ + --deny-self-hosted-runners + +gh attestation verify trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server \ + --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$REVIEWED_COMMIT" \ + --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 # allow-domain: spdx.dev + +gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server +gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm.spdx.json \ + -R IABTechLab/trusted-server +``` + +The same checks apply to each named CLI executable before installing or executing it. The integrated verifier also checks index fields and verifies every referenced companion before consuming it. A successful command checking one predicate is not treated as successful verification of all required evidence. + +`gh --format json` results are interpreted only after a successful exit. Bind source and signer constraints through certificate-backed verifier flags; do not elevate arbitrary predicate fields into independently trusted identity. Validate subject digests and expected predicate/document relationships explicitly. + +The implementation pins a supported GitHub CLI version in the release tools manifest and documents that version as the minimum tested verifier. Missing commands, incompatible JSON output, failed network access, or partial results fail closed; they do not fall back to checksums alone. + +### 9. Offline verification + +An online preparation step saves the release assets, their bundles, and a trusted-root snapshot acquired through GitHub CLI's trusted-root mechanism. Trust roots and the consumer's approved release tag/commit policy must be transported securely; roots included by an arbitrary artifact supplier are not authoritative. + +For example: + +```sh +gh attestation trusted-root > trusted_root.jsonl +``` + +The published bundles avoid dependence on the GitHub attestation API during offline verification. `gh attestation download` is an alternative online export, not an offline retrieval step. + +```sh +RELEASE_TAG=v1.2.0 +REVIEWED_COMMIT='' + +gh attestation verify release-index.json \ + -R IABTechLab/trusted-server \ + --bundle release-index.provenance.sigstore.json \ + --custom-trusted-root trusted_root.jsonl \ + --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$REVIEWED_COMMIT" \ + --deny-self-hosted-runners + +gh attestation verify trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server \ + --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ + --custom-trusted-root trusted_root.jsonl \ + --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$REVIEWED_COMMIT" \ + --deny-self-hosted-runners + +gh attestation verify trusted-server-fastly.wasm \ + -R IABTechLab/trusted-server \ + --bundle trusted-server-fastly.wasm.sbom.sigstore.json \ + --custom-trusted-root trusted_root.jsonl \ + --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$REVIEWED_COMMIT" \ + --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 # allow-domain: spdx.dev +``` + +Offline index verification and digest checks bind the artifact/companions to the signed distribution index. The offline profile does not claim a fresh GitHub release-state lookup or fresh revocation information. Online `gh release verify-asset` has no assumed offline replacement in this design. Consumers requiring immutable-release membership while disconnected retain an independently verified release record through their evidence-transfer process; the signed index alone is identified as build/distribution evidence. + +Offline artifact verification also does not make platform deployment offline: uploading still requires provider access. The automated offline verifier is tested with network access denied to establish that no hidden attestation/root lookup is required. + +### 10. Verified Fastly artifact deployment + +#### Operator interface + +Add release-artifact selection to the existing `ts deploy` command with a Trusted Server argument wrapper that flattens the existing EdgeZero deployment arguments and adds the release/evidence/receipt flags. Source deployment still delegates to the existing EdgeZero path; release deployment calls the new typed prebuilt-package path after Trusted Server verification. The proposed public flow is: + +```sh +ts deploy --adapter fastly --service-id '' \ + --release v1.2.0 --receipt deployment-receipt.json + +ts deploy --adapter fastly --service-id '' \ + --release v1.2.0 --staging --receipt staged-deployment-receipt.json + +ts deploy --adapter fastly --service-id '' \ + --release v1.2.0 --artifact ./trusted-server-fastly.wasm \ + --offline --evidence-dir ./release-evidence \ + --expected-source-commit '' \ + --trusted-root ./trusted_root.jsonl --receipt deployment-receipt.json +``` + +These are new interfaces to implement, not commands available at the baseline. + +`--release` enables the fixed official-release policy. `--artifact` chooses local bytes but requires that policy and the selected release. `--offline` requires the local artifact, evidence directory, securely supplied root, and approved source commit. `--evidence-dir` contains the authenticated index and all required companions. Online deployment resolves and verifies the immutable release record; automation may also supply `--expected-source-commit` to pin its approved revision. + +Reject unsupported adapters, missing evidence, unsupported targets/variants, conflicting deployment inputs, and artifact-related flags without `--release`. Arbitrary passthrough flags that override package, directory, source-build behavior, verification, service/version selection, or provider safety are forbidden on this route. Artifact deployment bypasses manifest build/deploy overrides; it does not run `fastly compute publish` or an operator-supplied shell command. + +Existing source-build commands remain available for development and custom distributions, with a clear diagnostic that the resulting bytes have not been established as the canonical release. The supported attested production path uses release artifacts. A local build is not equivalent merely because its source commit or compiled version string matches. + +#### Interface ownership + +Trusted Server owns official release acquisition/verification and the `VerifiedArtifact` value. Its fields are private; callers cannot construct one by supplying a path and an asserted checksum. It owns retained bytes, SHA-256, validated release/source identity, target/variant, verification policy version, and evidence references. Provider code cannot replace its bytes through passthrough arguments. + +EdgeZero owns an additive typed artifact deployment request/result and platform lifecycle. Its request accepts an explicitly prepared package and expected digest with adapter/service/staging parameters; its result reports exact observed service/version and activation state. This is additive to source deployment, not a rewrite of all `AdapterAction` execution. EdgeZero treats the Trusted Server verifier as its trusted caller; it is not assigned responsibility for recognizing IAB release policy. Official release flags, evidence acquisition and signer constraints remain in Trusted Server's argument wrapper, not in generic EdgeZero `DeployArgs`. + +The implementation first lands the upstream capability and pins a reviewed EdgeZero revision. It must not duplicate staging cloning/selector logic in Trusted Server to work around an unconditional upstream build. If upstream support is delayed, the documented native route below permits use of released bytes, but integrated `ts deploy --release` acceptance remains incomplete. + +#### Ordering and byte preservation + +1. Parse all arguments and validate provider/manifest selection without writes. +2. Acquire the release index, binary, SBOM, build evidence and bundles into a private working directory. +3. Verify identities, required predicates, release membership when online, target/variant, sizes, and all referenced digests. +4. Retain verified bytes in that directory; do not later reopen an arbitrary operator path as the upload source. +5. Pack those bytes with the selected publisher-owned Fastly manifest using `compute pack --wasm-binary` or an equivalent byte-preserving package implementation. +6. Inspect the completed archive safely and require exactly one expected regular `bin/main.wasm` member with the verified digest. Reject symlinks, traversal, duplicate members, unexpected executable payloads, and manifest/package mismatches. +7. Hash the complete selected archive, retain it privately, and recheck it immediately before provider upload. +8. Only now perform provider writes using the explicit package. Capture the exact provider service/version result. +9. For staging, preserve current version comments and isolated config selectors before staging/health checks. For production, retain the existing activation and health-check policy. +10. Return a structured outcome and write the requested receipt atomically. Rollback uses the previously observed version, not a guessed current active version. + +No build, wasm annotation, stripping, signing, or post-build hook runs between artifact verification and upload. A Fastly provider package hash is not assumed to equal raw wasm SHA-256. Packaging verification establishes which module was submitted; it does not establish execution at every edge node. + +Failure before step 8 makes no provider writes, configuration pushes, selector changes, or service provisioning. Failure after provider mutation reports the exact known outcome and preserves enough evidence for recovery; receipt-writing failure must not be presented as proof that no deployment occurred. + +#### Native Fastly route during rollout + +The pinned Fastly 15.1.0 commands support: + +```sh +fastly compute pack --wasm-binary ./trusted-server-fastly.wasm +fastly compute deploy --package '' \ + --service-id '' +``` + +Use the verification procedures first, then inspect/hash the packed wasm and selected package before the second command. Do not select an archive merely by a guessed name or assume the pack command's success proves its contents. Do not substitute `compute publish --package`; at the pinned version it still builds. `compute build --metadata-disable` is also not a byte-preserving packaging route. + +This native route does not automatically provide the integrated verifier, staging guarantees, or structured receipts. Its documentation makes those limits explicit. + +### 11. Build facts and publisher attestation groundwork + +#### Immutable build facts + +Add a small `BuildInfo` surface in core containing release version, Cargo package version, source commit, target triple, and build variant. Release recipes supply validated `TS_RELEASE_VERSION`, `TS_SOURCE_COMMIT`, `TS_BUILD_TARGET`, and `TS_BUILD_VARIANT`; build scripts track them. Missing/malformed values fail an official release. Ordinary local builds identify missing release metadata as unverified/local; they do not masquerade as an official release. + +`BuildInfo` is platform-independent static data. It imports no GitHub/Sigstore client and no publisher identity/key types. Future #161 code may report these fields, but their presence does not prove the identity of the responding binary. The embedded JS module table supplies available module IDs/digests separately from the runtime-enabled integration set. + +A wasm cannot hash its own deployed bytes through the current platform abstractions. Embedding its final whole-file checksum would create a circular digest dependency. The final digest stays in external attestations/index/deployment records. The Fastly service version remains a distinct platform identifier; existing `X-TS-Version` must not be relabeled as the release artifact identity. + +#### Deployment observations + +A requested receipt is a versioned local JSON observation record, not a publisher signature or a platform attestation. Version 1 contains: + +- Release repository/tag/source commit, artifact target/variant, and raw module SHA-256. +- Verification policy version, online/offline profile, and evidence bundle digests. +- Package SHA-256 and the observed package member digest. +- CLI and platform tool versions. +- Adapter, observed service/version, staging/production mode, activation/health outcome, and observation timestamp. +- Previous deployment version when actually captured. + +It contains no tokens, resolved secrets, complete publisher manifest, configuration document, or claimed publisher authorization. It does not assert a config digest unless the deployment operation actually observed and verified one; config push is a separate operation. Exact service/version comes from deployment responses, not a racy post-deploy lookup of whatever version is now active. + +Store receipts with restricted local permissions and explicit caller-selected paths. A future publisher signer can reference the record's defined bytes/digest, but #161 must define its authority, validation and freshness policy. A local receipt alone is not independent evidence against the operator producing it. + +#### Future configuration and identity boundaries + +Preserve the following distinctions when #161 adds its implementation: + +| Identity | Defined meaning | Future ownership | +| --------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- | +| Artifact digest | SHA-256 of the exact distributed executable | Release/deployment tooling | +| Raw configuration-document digest | Exact serialized bytes, where a protocol explicitly selects those bytes | Config-attestation design | +| Canonical unresolved-data digest | Existing versioned EdgeZero envelope data hash, including secret references | Config loader plus future snapshot metadata | +| Effective settings identity | A future explicit projection of defaults, policy, selections and secret-version treatment | #161 and permission design | +| Publisher identity | Externally authorized publisher/domain/key relationship | Publisher trust design | +| Public claim digest | Exact payload bytes under a chosen signed claim protocol | #161 | + +The existing configuration envelope verifies integrity, not publisher authorization, expiry, rollback protection, or domain ownership. Its `generated_at` value is outside the canonical data digest. Do not assign it stronger semantics merely because it accompanies a hash. + +The current loader verifies the envelope and then discards envelope metadata while returning resolved `Settings`. Future #161 work should carry the verified unresolved identity alongside the actually loaded settings snapshot. This specification documents that seam but does not change settings-loading APIs or introduce a public configuration digest now. + +Fastly reusable instances retain settings for their lifetime, and Axum retains them from startup. Runtime claims must describe the snapshot used for the relevant operation, not a freshly fetched store value that the instance has not adopted. Staging selectors, physical bindings, and future permission policy are also runtime/deployment inputs, not artifact inputs. + +Do not reuse `template_fingerprint` as public configuration identity. It hashes build/cache inputs, embedded JS, and resolved settings. `Redacted` redacts formatting but serializes transparently, so its digest input includes secret values. Public secret-bearing digests can enable guessing or correlation and conflate config changes with secret rotation. + +Existing request signatures cover version, key ID, host, scheme, request ID, and timestamp. They do not bind the artifact, configuration, attestation document, or request body. A future attestation reference must be explicitly included in a versioned signed payload; adding an unsigned extension is insufficient. Future publisher/runtime signing should define key purpose and domain separation rather than making request-signing keys an implicit release or publisher authority. + +The integration registry can provide an enabled capability inventory. It is not vendor endorsement or a permission sandbox. A vendor's later endorsement must name a precise code/configuration/policy scope and be verified under the vendor's authority. + +No generic attestation framework, arbitrary predicate registry, publisher endpoint, or empty publisher implementation is added for these future requirements. The release/deployment interfaces above provide the useful groundwork without committing to those protocols. + +### 12. Other adapter and asset boundaries + +| Surface | Follow-up artifact definition | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| Cloudflare | Final worker-build JS/WASM asset set and Wrangler bundling semantics, with build hooks bypassed during verified deployment | +| Spin | Deployable component and any package/OCI transformations, variables/bindings, and a validated upload path | +| Axum | Native executable or container distribution and an explicit deployment mechanism | +| Standalone tsjs | Individual bundle subjects, module inventory and composition evidence; runtime concatenation remains separately identifiable | +| External Prebid.js | Publisher-selected bundle digest/SRI and future configuration/deployment binding | + +Each future artifact gets its own digest, target/variant recipe, SBOM and byte-preserving deployment contract. It does not inherit assurance solely because another adapter from the same source release was attested. Embedded tsjs is covered by its containing binary's identity; enabled/deferred module selection remains runtime behavior. + +## Test plan + +### Build and configuration independence + +- Fresh strict-mode build succeeds with pinned tools and locks. +- Missing npm/Node, failed `npm ci`, failed bundling, absent manifest/lockfile, and any `TSJS_SKIP_BUILD` presence reject the release even with populated old `dist`/`node_modules`. +- Failed explicitly requested JS tests stop the build. +- Missing core/integration output, empty or unexpected output, and bundle/evidence digest mismatches reject release generation. +- Changing strict-mode environment after a permissive build cannot reuse the permissive Cargo result. +- Operator configuration presence/content and physical resource mappings do not change canonical bytes under the same controlled recipe; prohibited operator build variables reject release preflight. +- Editing repository `edgezero.toml` changes the recorded input/template digest and is rejected when it conflicts with the selected source snapshot. +- Build metadata rejects malformed/missing release facts and distinguishes local builds, package version, release version, and service version. +- First-artifact feature selection matches current production defaults and does not enable reusable sandbox or test utilities. + +### SBOM composition + +- Validate SPDX 2.3 schema and the root binary SHA-256 for all matrix artifacts. +- Target-specific normal dependencies and host-built proc macros/build dependencies retain correct roles and per-unit features, including a package compiled in both contexts. Compiler-trace mappings and exact source joins are fixture-tested. +- Dev-only and unrelated workspace packages do not become artifact runtime components. +- Native/vendor/sysroot components and external library requirements are represented according to observed build declarations. +- Contributing third-party JS appears with package identity and integrity metadata. Later minifier elimination is represented conservatively and does not produce an exact final-code reachability claim. +- Prebid's type-only shim import does not imply full external Prebid.js is embedded. +- Tool-generated/virtual JS and packages declared as npm development dependencies retain their correct roles. +- Unknown contributing package ownership, missing graph coverage, broken SPDX relationships, and oversized predicates reject publication. +- Published SBOM data matches the verified SPDX predicate and separately attested SBOM-file digest. + +### Verification and publication + +- Verify both provenance and SPDX predicates for every binary with fixed signer/ref/commit constraints. +- Reject altered binaries, bundles, index entries and companions; wrong repository/signer/issuer/ref/commit; unsupported target/variant; and self-hosted-runner provenance. +- A provenance-only, SBOM-only, or checksum-only result cannot produce a `VerifiedArtifact`. +- Exercise missing/incompatible GitHub CLI, network failure, malformed JSON and partially successful command sequences. +- Verify saved bundles/roots with networking disabled; untrusted or absent roots fail. +- Validate index schema/paths, duplicate assets, expected matrix completeness, and companion provenance subjects. +- Fail CI eligibility when successful checks are absent for the exact source commit, not merely for current `main`. +- Exercise failed matrix jobs, partial draft recovery, conflicting drafts, concurrent runs, already published tags, and uncertain publication responses. +- Use a disposable public test repository to prove draft upload, immutable publication, release attestation and `gh release verify-asset` for all assets. Test denied/missing administration-read credentials and disabled immutability before any publication. Publishing an official test tag requires maintainer release authorization; unprivileged CI does not use production release authority. + +### Deployment and recovery + +- Verification precedes all provider writes, including staged version cloning and selector updates. +- Use a fake provider/recorded command runner to prove release deployment never invokes source build, `compute publish`, wasm annotation or manifest shell overrides. +- Inspect real pinned Fastly packed fixtures and require exact verified module bytes; reject archive traversal, symlinks, duplicate wasm members and altered archives. +- Replacing the original operator artifact path after verification cannot substitute upload bytes; changing retained/package bytes is detected before upload. +- Production and staging submit the explicitly verified package and return exact service/version results. +- Staged config selectors and physical resource mappings keep current isolation behavior. +- Health-check failure, partial activation, rollback and receipt-write failure report observed state accurately. +- Readiness of `/health` or a reported version is never asserted to be cryptographic artifact verification. +- Native CLI startup/help/config-init work outside the source tree; repository-dependent commands retain explicit prerequisites. + +### Required repository checks + +Implementation verification uses the target-matched tests/lints for changed core, JS, CLI and adapter behavior, plus upstream EdgeZero tests for the additive deploy interface. Before implementation PR handoff, run the complete AGENTS.md CI gate list, including reusable Fastly tests, parity tests, CLI host tests, native template-build-digest tests, JS build/tests/format, and documentation/Markdown formatting. + +The specification-only PR needs Markdown formatting, relative-reference validation, and design review. It does not claim runtime tests establish behavior that has not been implemented. + +## Rollout + +1. **Approve the contract.** Land this specification in its own PR. Record maintainer approval; implementation PRs link it. Confirm the permission work accepts the configuration separation requirement. +2. **Prove composition and upstream capability.** Validate the selected SBOM pipeline fixtures and land EdgeZero's additive prebuilt production/staging request/result. Pin the reviewed upstream revision before adopting it. +3. **Implement release build support.** Add strict JS mode, composition evidence, build facts, recipes/tools pins, index generation, provenance/SBOM signing and publication workflows. Reconcile obsolete CI comments claiming configuration is baked into wasm. +4. **Implement verified deployment.** Add the fixed release policy, online/offline evidence handling, retained artifact/package verification, provider sequencing and optional local receipts. Document the native Fastly route as a transitional/manual path. +5. **Prepare release authority.** Verify immutable releases and tag rulesets, provision the isolated repository-scoped read-only settings credential, audit bypass permissions and action/tool pins, and prove publication in a disposable public repository. Do not backfill historical tags with claims that their old operator builds were attested. +6. **Publish the first eligible new tag.** Complete the full artifact matrix, verify immutable membership and all predicates, and exercise an explicitly authorized test deployment through the supported artifact route. +7. **Move operator guidance to release bytes.** Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Explain supported targets, installation prerequisites, identity policy, offline limits, staging and rollback, and the distinction between custom source builds and canonical artifacts. +8. **Continue separately.** #161 designs publisher trust, loaded-config metadata, claim signing/freshness and runtime transport. Later work can add adapters, independent reproducible builds and stronger builder isolation without changing the artifact/deployment identity boundary. + +A delayed EdgeZero change does not justify rebuilding inside the verified deployment path. Releases may be distributed with the manual packing instructions during rollout, but #1235's integrated deployment acceptance is not marked complete until the declared route is available and verified. + +Published immutable releases are never repaired by overwriting assets or moving tags. A defective release is documented and superseded by a new version; maintainers publish any consumer-facing withdrawal/advisory policy separately. Existing historical attestations remain verifiable evidence of their original claims, not a guarantee of ongoing recommendation. + +## Alternatives considered + +| Approach | Benefit | Reason for the selected order | +| --------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected first; avoids independent-build determinism as an adoption prerequisite | +| Require local reproducible rebuilds | Operators can independently rebuild and compare | Valuable follow-up; native paths/SDKs, JS/environment and transformations need additional controlled experiments | +| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | +| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | +| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | +| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | + +## Acceptance criteria + +- [ ] This draft is approved and merged independently; implementation links the approved version and updates it for material design changes. +- [ ] The no-configuration requirement and compile-time inventory are enforced for every initial artifact. +- [ ] Protected eligible `v*` tags publish the complete immutable artifact/SBOM/bundle/index/checksum distribution. +- [ ] All executable artifacts pass provenance, SBOM and immutable-release asset verification with the documented policy. +- [ ] Strict JS release builds reject missing tools, installation failure, skip flags and stale/incomplete output. +- [ ] SBOM fixtures prove target/build-host selection and embedded JS scope, including the external Prebid exclusion. +- [ ] Both production and staging artifact deployment preserve verified wasm bytes and perform no provider writes on verification failure. +- [ ] Offline evidence verification works without network access and documents its release-state/freshness limits. +- [ ] Build facts distinguish release/package/source/target/variant from service version and final binary checksum. +- [ ] Deployment observations preserve exact known results without claiming publisher authorization or remote execution proof. +- [ ] Immutable-release settings, active tag rules and release authority are verified and documented. +- [ ] CLI/operator verification guidance and changelog are updated; historical source builds are not relabeled as attested. +- [ ] Publisher/configuration/runtime/vendor protocols remain separate, with the necessary handoff contracts documented for #161. + +## Supersession and references + +Upon approval, this specification replaces the binary-attestation, canonical build, release publication and artifact-deployment sections of [the January attestation proposal](2026-01-15-attestation-design.md). Its runtime/configuration examples remain historical reference for #161, not approved implementation contracts. In particular, the embedded-config baseline, embedded final-binary digest, and assumption that signatures independently prove remote execution are not adopted. + +Authoritative implementation references: + +- [GitHub artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations). +- [Reusable workflow verification and SLSA guidance](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/increase-security-rating). +- [GitHub immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). +- [GitHub release verification](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/verify-release-integrity). +- [Immutable-release settings API](https://docs.github.com/en/rest/repos/repos#check-if-immutable-releases-are-enabled-for-a-repository). +- [GitHub CLI attestation verification](https://cli.github.com/manual/gh_attestation_verify), [bundle download](https://cli.github.com/manual/gh_attestation_download), and [trusted roots](https://cli.github.com/manual/gh_attestation_trusted-root). +- [Attestation action modes and outputs](https://github.com/actions/attest). +- [Syft package catalogers](https://oss.anchore.com/docs/guides/sbom/catalogers/) and [Rust capabilities](https://oss.anchore.com/docs/capabilities/rust/). +- [Rollup output hooks](https://rollupjs.org/plugin-development/). +- [Cargo build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html) and [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html). +- [GitHub-hosted runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +- Pinned Fastly v15.1.0 [publish](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/publish.go#L119), [wasm annotation](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/build.go#L208), and [precompiled wasm packing](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go#L87). + +Tool/action versions and hosted-runner availability are validated when implementation pins are selected. A later incompatible tool/API change requires a reviewed recipe or specification update, not a silent change to verification policy. From c58aa775f72f89a698cfaf6fc4294d30ea0ef36b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 11:12:17 +0530 Subject: [PATCH 002/153] Tighten release attestation verification and deployment contracts --- ...6-10-09-1235-release-attestation-design.md | 120 ++++++++++++++---- 1 file changed, 92 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index b464457f1f..305ac94dbf 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -30,7 +30,7 @@ This design establishes a canonical release artifact per version, target, and ap 6. Provide exact online and offline verification procedures with a constrained signer, source ref, and source revision. 7. Make immutable build facts available to future #161 work, while keeping the final binary digest external. 8. Keep artifact identity, publisher identity, configuration identity, deployment observations, and runtime evidence distinct. -9. Preserve existing logical-store mapping, staged configuration isolation, health checks, and rollback behavior. +9. Preserve existing logical-store mapping and staged configuration isolation, with exact deployment outcomes usable by the separate health-check and rollback lifecycle commands. ## Non-goals @@ -99,6 +99,10 @@ A publisher can run altered code that reports an official artifact digest and si Consumers continue to trust the reviewed source, the chosen build workflow, GitHub's identity assertions, and the applicable Sigstore trust roots. Provenance does not establish absence of vulnerabilities or correctness of an SBOM. Release integrity does not prevent an authorized future release from containing different software. +The release evidence uses established supply-chain standards: SLSA build provenance through GitHub/Sigstore and SPDX 2.3 for the SBOM. The release index, build-composition adapter, CLI policy, and local deployment receipt are project-specific contracts. Their use does not establish certification or a standardized publisher-attestation protocol. SLSA Build Level 2 is an implementation target to validate against the platform and producer requirements, not a rating achieved by this document. + +The separation of evidence, authority, verification policy, and a consumer's authorization decision is also consistent with the IETF RATS architecture. RATS is an architectural reference for future runtime work; this design implements neither a RATS protocol nor hardware-backed remote attestation. + ```mermaid flowchart LR source[Reviewed tag and locked inputs] --> build[Release build] @@ -156,7 +160,9 @@ Every release records the following input classes in build evidence. New inputs The CLI currently embeds `CARGO_MANIFEST_DIR` as a resource-discovery fallback, and debug metadata can contain paths. These are recorded build inputs. This first release makes no byte-identical-rebuild claim. Removing path-dependent data, normalizing debug information, stabilizing native SDK inputs, and testing independent rebuilds belong to later reproducibility work. -The release environment removes operator `TRUSTED_SERVER__*` and `EDGEZERO__*` values, `TSJS_SKIP_BUILD`, unapproved `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`, npm configuration overrides, and Vite environment overrides. Vite release execution explicitly disables environment-file loading and uses the approved production mode. Release scripts validate permitted native compiler settings rather than inheriting operator values. Platform authentication is unavailable to build jobs. +Release preflight rejects any presence of `TSJS_SKIP_BUILD` before environment sanitization; silently removing it would hide a prohibited release request. The build script independently enforces the same rule. Reject other prohibited operator build inputs, then construct the permitted build environment rather than inheriting arbitrary compiler/tool overrides. Remove operator `TRUSTED_SERVER__*` and `EDGEZERO__*` values from the actual build environment. + +The recipe sets or rejects compiler/tool overrides including `RUSTC`, `RUSTDOC`, `RUSTC_WRAPPER`, `RUSTC_WORKSPACE_WRAPPER`, `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `CARGO_BUILD_RUSTFLAGS`, target-specific Cargo rustflags/linkers, Cargo profile overrides, `NODE_OPTIONS`, and npm/Vite configuration overrides. The only compiler wrapper is the reviewed composition recorder. Control Cargo home/configuration layers and prohibit unreviewed command-line configuration overrides; record the approved settings. Vite release execution explicitly disables environment-file loading and uses the approved production mode. Release scripts validate permitted native compiler settings rather than inheriting operator values. Platform authentication is unavailable to build jobs. This is a controlled release recipe, not a claim of a fully hermetic build. No wall-clock build timestamp, workflow run ID, publisher identity, or final artifact checksum is embedded. Operational timestamps and run identifiers remain external evidence. `SOURCE_DATE_EPOCH`, if used by a component, is derived from the source commit and recorded. @@ -169,7 +175,7 @@ No wall-clock build timestamp, workflow run ID, publisher identity, or final art | `ts-aarch64-apple-darwin` | `trusted-server-cli`, `aarch64-apple-darwin` | `macos-15` | macOS 15 ARM64; explicit deployment target 15.0 | | `ts-x86_64-apple-darwin` | `trusted-server-cli`, `x86_64-apple-darwin` | `macos-15-intel` | macOS 15 Intel; explicit deployment target 15.0 | -Runner labels fix an OS generation, not an immutable VM image. Capture the actual image/toolchain versions and perform executable startup checks on each selected runner. Changes to compatibility require a reviewed recipe change. Linux portability to older glibc, ARM Linux, and older macOS is not implied. +Runner labels fix an OS generation, not an immutable VM image. Capture the actual image/toolchain versions and perform executable startup checks on each selected runner. Inspect native linkage and loader paths on Linux/macOS. Validate the Linux CLI's version/help/config-init commands outside a checkout in a clean Ubuntu 24.04 runtime with only its documented runtime dependencies installed; a provisioned build runner alone does not establish that compatibility. Reject dependencies on absolute runner-only paths or undeclared libraries. Changes to compatibility require a reviewed recipe change. Linux portability to older glibc, ARM Linux, and older macOS is not implied. Use explicit Cargo packages/targets with `--release --locked`. Fastly does not enable `reusable-sandbox`; production build commands do not use `--all-features`. CLI uses its existing default feature graph, including EdgeZero's default adapter registrations. Do not remove dependencies merely because they appear unrelated at the workspace level. @@ -207,7 +213,9 @@ Strict mode: 7. Checks the exit status of explicitly requested JS tests. Release validation also runs JS tests as an independent blocking step. 8. Tracks the strict flag, skip/test flags, selected tools, recipe inputs, and relevant environment changes so an incremental local invocation cannot silently keep a previous permissive result. -Release jobs have fresh checkouts and isolated Cargo target directories. Do not restore compiled Cargo output, `dist`, `OUT_DIR`, or `node_modules` from caches. Verified Cargo/npm download caches may be used. No parallel Cargo process mutates a shared crate `dist` directory; each matrix job owns its checkout. +Release jobs have fresh checkouts and isolated Cargo target directories. Do not restore compiled Cargo output, `dist`, `OUT_DIR`, or `node_modules` from caches. Verified Cargo/npm download caches may be used only with archive bytes revalidated against locked checksums/integrity before fresh extraction; restored extracted source trees are not assumed to match a lockfile. Pinned git dependencies are freshly checked out and matched to their locked revisions. No parallel Cargo process mutates a shared crate `dist` directory; each matrix job owns its checkout. + +Record npm lifecycle execution and generated native/tool inputs separately from the package archive integrity. `npm ci` authenticates selected archive content under the lockfile, but permitted install scripts execute code and may generate or modify installed files; it does not make every resulting file byte-identical to the archive. Review and record the required lifecycle behavior, including esbuild's native tool setup, rather than disabling all scripts and silently breaking the selected bundler. Capture expected source identities before building and ensure tracked inputs have not changed afterward. Generated output belongs in dedicated output paths. Release scripts are responsible for validating source-only directories, not just checking that `git status` reports no tracked modifications. @@ -283,7 +291,7 @@ Administrative prerequisites are part of rollout: immutable releases enabled; ac The trusted source repository is `IABTechLab/trusted-server`. The signer is `IABTechLab/trusted-server/.github/workflows/release-build.yml`, because the reusable workflow contains the attestation action. The source ref is the exact selected `refs/tags/` and the source digest is its reviewed peeled commit. The expected GitHub OIDC issuer is `https://token.actions.githubusercontent.com`. -The CLI's release policy fixes repository, signer, issuer, supported targets/variants, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. +The CLI's release policy fixes GitHub host `github.com`, repository, signer, issuer, supported targets/variants, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. The checked subprocess uses explicit host/repository selection, fixed verifier arguments, and an approved GitHub CLI executable/version; it clears unapproved host/configuration overrides and accepts no forwarded verifier arguments. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. The release index is authenticated before its target/variant/digest fields are trusted. Online consumers validate immutable release membership, expected tag/source identity, index provenance, artifact provenance, and the artifact's SPDX attestation. `SHA256SUMS` is a convenience check only until authenticated through the release or independently signed evidence. @@ -294,17 +302,18 @@ RELEASE_TAG=v1.2.0 REVIEWED_COMMIT='' gh release download "$RELEASE_TAG" \ - -R IABTechLab/trusted-server \ + -R github.com/IABTechLab/trusted-server \ --pattern 'trusted-server-fastly.wasm*' \ --pattern 'release-index*' \ --pattern SHA256SUMS -gh release verify "$RELEASE_TAG" -R IABTechLab/trusted-server +gh release verify "$RELEASE_TAG" -R github.com/IABTechLab/trusted-server gh release verify-asset "$RELEASE_TAG" release-index.json \ - -R IABTechLab/trusted-server + -R github.com/IABTechLab/trusted-server gh attestation verify release-index.json \ -R IABTechLab/trusted-server \ + --hostname github.com \ --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ --source-ref "refs/tags/$RELEASE_TAG" \ --source-digest "$REVIEWED_COMMIT" \ @@ -312,6 +321,7 @@ gh attestation verify release-index.json \ gh attestation verify trusted-server-fastly.wasm \ -R IABTechLab/trusted-server \ + --hostname github.com \ --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ --source-ref "refs/tags/$RELEASE_TAG" \ --source-digest "$REVIEWED_COMMIT" \ @@ -319,6 +329,7 @@ gh attestation verify trusted-server-fastly.wasm \ gh attestation verify trusted-server-fastly.wasm \ -R IABTechLab/trusted-server \ + --hostname github.com \ --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ --source-ref "refs/tags/$RELEASE_TAG" \ --source-digest "$REVIEWED_COMMIT" \ @@ -326,9 +337,9 @@ gh attestation verify trusted-server-fastly.wasm \ --predicate-type https://spdx.dev/Document/v2.3 # allow-domain: spdx.dev gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm \ - -R IABTechLab/trusted-server + -R github.com/IABTechLab/trusted-server gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm.spdx.json \ - -R IABTechLab/trusted-server + -R github.com/IABTechLab/trusted-server ``` The same checks apply to each named CLI executable before installing or executing it. The integrated verifier also checks index fields and verifies every referenced companion before consuming it. A successful command checking one predicate is not treated as successful verification of all required evidence. @@ -341,10 +352,21 @@ The implementation pins a supported GitHub CLI version in the release tools mani An online preparation step saves the release assets, their bundles, and a trusted-root snapshot acquired through GitHub CLI's trusted-root mechanism. Trust roots and the consumer's approved release tag/commit policy must be transported securely; roots included by an arbitrary artifact supplier are not authoritative. -For example: +Provide a read-only `ts release verify --asset --output-evidence ` export using the same fixed verification library as deployment. It performs the complete online policy without provider credentials or writes. Only after every check succeeds does it save the verified assets/bundles, roots and version-1 `verified-publication.json`, and report that record's SHA-256 plus the verified source commit. Its record fields are the publication bindings listed below; it is a consumer observation rather than another release asset or publisher claim. Partial exports cannot be mistaken for successful verification. The consumer approves and transfers the resulting record pin and roots through its trusted channel. + +For the proposed export interface: ```sh -gh attestation trusted-root > trusted_root.jsonl +ts release verify v1.2.0 --asset trusted-server-fastly.wasm \ + --output-evidence ./release-evidence +``` + +This export command is new work, not available at the baseline. Its output enables the offline deployment example below without requiring operators to hand-author a record asserting that checks passed. + +The native GitHub CLI can also prepare a trusted-root snapshot: + +```sh +gh attestation trusted-root --hostname github.com > trusted_root.jsonl ``` The published bundles avoid dependence on the GitHub attestation API during offline verification. `gh attestation download` is an alternative online export, not an offline retrieval step. @@ -355,6 +377,7 @@ REVIEWED_COMMIT='' gh attestation verify release-index.json \ -R IABTechLab/trusted-server \ + --hostname github.com \ --bundle release-index.provenance.sigstore.json \ --custom-trusted-root trusted_root.jsonl \ --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ @@ -364,6 +387,7 @@ gh attestation verify release-index.json \ gh attestation verify trusted-server-fastly.wasm \ -R IABTechLab/trusted-server \ + --hostname github.com \ --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ --custom-trusted-root trusted_root.jsonl \ --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ @@ -373,6 +397,7 @@ gh attestation verify trusted-server-fastly.wasm \ gh attestation verify trusted-server-fastly.wasm \ -R IABTechLab/trusted-server \ + --hostname github.com \ --bundle trusted-server-fastly.wasm.sbom.sigstore.json \ --custom-trusted-root trusted_root.jsonl \ --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ @@ -382,7 +407,9 @@ gh attestation verify trusted-server-fastly.wasm \ --predicate-type https://spdx.dev/Document/v2.3 # allow-domain: spdx.dev ``` -Offline index verification and digest checks bind the artifact/companions to the signed distribution index. The offline profile does not claim a fresh GitHub release-state lookup or fresh revocation information. Online `gh release verify-asset` has no assumed offline replacement in this design. Consumers requiring immutable-release membership while disconnected retain an independently verified release record through their evidence-transfer process; the signed index alone is identified as build/distribution evidence. +Offline index verification and digest checks bind the artifact/companions to the signed distribution index. A signed build index can also exist before publication, so it alone does not establish official release membership. Official offline `ts deploy --release` additionally requires a securely transferred record of previously successful online immutable-release and asset verification. That record binds the repository, tag, peeled source commit, release-index digest, selected artifact and companion digests, verification policy, and observation time. It is an explicit trust input from the consumer's approved online preparation process, protected by that process's authenticated evidence-transfer channel. Store its bytes as `verified-publication.json` in the evidence directory and require `--publication-record-sha256` supplied independently through that approved channel; the verifier matches the record's exact bytes to this pin before trusting its fields. A checksum copied from the supplied evidence directory is insufficient. Do not describe this consumer-approved record as an offline-verifiable GitHub release attestation unless a supported verifier actually verifies that evidence. + +The offline profile does not claim a fresh GitHub release-state lookup or fresh revocation information. Online `gh release verify-asset` has no assumed offline replacement in this design. Standalone bundle verification without the transferred publication record remains useful build/distribution verification, but cannot produce an official-release `VerifiedArtifact` for deployment. The consumer's policy determines how old a transferred record may be; securely supplied approval and roots remain required. Offline artifact verification also does not make platform deployment offline: uploading still requires provider access. The automated offline verifier is tested with network access denied to establish that no hidden attestation/root lookup is required. @@ -403,12 +430,13 @@ ts deploy --adapter fastly --service-id '' \ --release v1.2.0 --artifact ./trusted-server-fastly.wasm \ --offline --evidence-dir ./release-evidence \ --expected-source-commit '' \ + --publication-record-sha256 '' \ --trusted-root ./trusted_root.jsonl --receipt deployment-receipt.json ``` These are new interfaces to implement, not commands available at the baseline. -`--release` enables the fixed official-release policy. `--artifact` chooses local bytes but requires that policy and the selected release. `--offline` requires the local artifact, evidence directory, securely supplied root, and approved source commit. `--evidence-dir` contains the authenticated index and all required companions. Online deployment resolves and verifies the immutable release record; automation may also supply `--expected-source-commit` to pin its approved revision. +`--release` enables the fixed official-release policy. `--artifact` chooses local bytes but requires that policy and the selected release. `--offline` requires the local artifact, evidence directory, securely supplied root, approved source commit, and the trusted previously verified publication record described above with an independently approved `--publication-record-sha256` pin. `--evidence-dir` contains the authenticated index, all required companions, and that record when offline. The offline record and its approved pin must arrive through the consumer's approved evidence-transfer process. Online deployment resolves and verifies the immutable release record; automation may also supply `--expected-source-commit` to pin its approved revision. Reject unsupported adapters, missing evidence, unsupported targets/variants, conflicting deployment inputs, and artifact-related flags without `--release`. Arbitrary passthrough flags that override package, directory, source-build behavior, verification, service/version selection, or provider safety are forbidden on this route. Artifact deployment bypasses manifest build/deploy overrides; it does not run `fastly compute publish` or an operator-supplied shell command. @@ -418,7 +446,11 @@ Existing source-build commands remain available for development and custom distr Trusted Server owns official release acquisition/verification and the `VerifiedArtifact` value. Its fields are private; callers cannot construct one by supplying a path and an asserted checksum. It owns retained bytes, SHA-256, validated release/source identity, target/variant, verification policy version, and evidence references. Provider code cannot replace its bytes through passthrough arguments. -EdgeZero owns an additive typed artifact deployment request/result and platform lifecycle. Its request accepts an explicitly prepared package and expected digest with adapter/service/staging parameters; its result reports exact observed service/version and activation state. This is additive to source deployment, not a rewrite of all `AdapterAction` execution. EdgeZero treats the Trusted Server verifier as its trusted caller; it is not assigned responsibility for recognizing IAB release policy. Official release flags, evidence acquisition and signer constraints remain in Trusted Server's argument wrapper, not in generic EdgeZero `DeployArgs`. +EdgeZero owns an additive typed artifact deployment request/result and platform lifecycle. Its request accepts an explicitly prepared package and expected digest with adapter/service/staging parameters and validated deployment context: selected platform manifest, declared logical config-store IDs, physical resource mappings, and any allowed version comment. Trusted Server checks that the context includes the configuration logical ID expected by the canonical artifact before mutation. An absent config declaration must not take EdgeZero's generic no-config staging branch and silently retain the production selector. The package and lifecycle use the same resolved context; manifest shell overrides cannot replace it. + +Its result reports exact observed service/version and activation or staging state. Its structured failure reports whether a provider mutation was attempted, any known service/version, the last confirmed phase, and an explicit unknown outcome when a timeout or unparseable response prevents confirmation. An error never implies that no deployment occurred. Uncertain uploads or activation require reconciliation before retry, rather than guessing a version or repeating the write. + +This is additive to source deployment, not a rewrite of all `AdapterAction` execution. EdgeZero treats the Trusted Server verifier as its trusted caller; it is not assigned responsibility for recognizing IAB release policy. Official release flags, evidence acquisition and signer constraints remain in Trusted Server's argument wrapper, not in generic EdgeZero `DeployArgs`. The implementation first lands the upstream capability and pins a reviewed EdgeZero revision. It must not duplicate staging cloning/selector logic in Trusted Server to work around an unconditional upstream build. If upstream support is delayed, the documented native route below permits use of released bytes, but integrated `ts deploy --release` acceptance remains incomplete. @@ -432,13 +464,15 @@ The implementation first lands the upstream capability and pins a reviewed EdgeZ 6. Inspect the completed archive safely and require exactly one expected regular `bin/main.wasm` member with the verified digest. Reject symlinks, traversal, duplicate members, unexpected executable payloads, and manifest/package mismatches. 7. Hash the complete selected archive, retain it privately, and recheck it immediately before provider upload. 8. Only now perform provider writes using the explicit package. Capture the exact provider service/version result. -9. For staging, preserve current version comments and isolated config selectors before staging/health checks. For production, retain the existing activation and health-check policy. -10. Return a structured outcome and write the requested receipt atomically. Rollback uses the previously observed version, not a guessed current active version. +9. For staging, preserve current version comments and isolated config selectors before marking the exact version staged, without activating production. For production, activate the exact uploaded version according to the existing deployment behavior. +10. Return a structured outcome and write the requested receipt atomically. Health checks and rollback remain explicit lifecycle commands or orchestration follow-ups; `ts deploy` does not automatically perform them at the baseline. A production rollback uses a version captured before deployment, never a guessed current active version. No build, wasm annotation, stripping, signing, or post-build hook runs between artifact verification and upload. A Fastly provider package hash is not assumed to equal raw wasm SHA-256. Packaging verification establishes which module was submitted; it does not establish execution at every edge node. Failure before step 8 makes no provider writes, configuration pushes, selector changes, or service provisioning. Failure after provider mutation reports the exact known outcome and preserves enough evidence for recovery; receipt-writing failure must not be presented as proof that no deployment occurred. +Operators/orchestrators serialize mutating deployment and rollback operations per service, including staging. EdgeZero's staging selector twin is mutable and shared by staged versions of one service; concurrent reconciliation can race, and a selector link is not an immutable configuration snapshot. Production rollback's active-version guard is not an atomic provider precondition. Preserve the current guards and documented serialization requirement; this change does not add a distributed lock or solve external actors bypassing that serialization. + #### Native Fastly route during rollout The pinned Fastly 15.1.0 commands support: @@ -471,7 +505,8 @@ A requested receipt is a versioned local JSON observation record, not a publishe - Verification policy version, online/offline profile, and evidence bundle digests. - Package SHA-256 and the observed package member digest. - CLI and platform tool versions. -- Adapter, observed service/version, staging/production mode, activation/health outcome, and observation timestamp. +- Adapter, observed service/version, staging/production mode, activation/staging outcome, and observation timestamp. +- Health status `not_performed` for a deployment-only operation; a later explicit check can produce its own observed result. Unknown provider state remains unknown, never inferred from command failure. - Previous deployment version when actually captured. It contains no tokens, resolved secrets, complete publisher manifest, configuration document, or claimed publisher authorization. It does not assert a config digest unless the deployment operation actually observed and verified one; config push is a separate operation. Exact service/version comes from deployment responses, not a racy post-deploy lookup of whatever version is now active. @@ -505,6 +540,20 @@ The integration registry can provide an enabled capability inventory. It is not No generic attestation framework, arbitrary predicate registry, publisher endpoint, or empty publisher implementation is added for these future requirements. The release/deployment interfaces above provide the useful groundwork without committing to those protocols. +#### Assumptions and decisions deferred to publisher work + +| Foundation selected now | Consequence for future claims | Decision still open | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Artifact identity is external SHA-256 of final bytes | A publisher claim can reference an independently verified release subject | How a provider or other authority establishes that those bytes execute remotely | +| Publisher authorization is separate from release signing | A valid build signature does not authorize a publisher/domain | Enrollment, ownership verification, delegation, trust roots, key rotation and revocation | +| Configuration is loaded at runtime | Claims must bind the snapshot actually used, including explicit policy/secret-version treatment | Public projection, canonicalization, config authorization, freshness and rollback policy | +| Deployment receipts are local observations | Later signing must preserve their limited assurance and known/unknown states | Which authority signs, what it independently observes, and which consumers trust it | +| Claim types may evolve independently | Release index/build facts do not become a universal publisher schema | Transport, DSSE/JWT/EAT or another format, discovery, versioning and purpose-specific keys | +| Runtime state and request context can change | Static artifact identity cannot imply a fresh request/config claim | Nonces/timestamps, replay windows, request/body binding and verifier appraisal policy | +| Vendor approval names its own scope | An enabled integration is not evidence of endorsement | Code/config/policy scope, vendor signing authority and permission enforcement | + +Future designs may revise these integration seams through reviewed versions. No assumption here requires a central publisher registry, reuse of deployment request-signing keys, a particular hosting provider, or a hardware attestation mechanism. + ### 12. Other adapter and asset boundaries | Surface | Follow-up artifact definition | @@ -527,6 +576,7 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p - Missing core/integration output, empty or unexpected output, and bundle/evidence digest mismatches reject release generation. - Changing strict-mode environment after a permissive build cannot reuse the permissive Cargo result. - Operator configuration presence/content and physical resource mappings do not change canonical bytes under the same controlled recipe; prohibited operator build variables reject release preflight. +- Workflow preflight rejects skip-flag presence before sanitization; unreviewed compiler wrappers, Cargo configuration layers and tool startup overrides cannot replace the selected recipe. - Editing repository `edgezero.toml` changes the recorded input/template digest and is rejected when it conflicts with the selected source snapshot. - Build metadata rejects malformed/missing release facts and distinguishes local builds, package version, release version, and service version. - First-artifact feature selection matches current production defaults and does not enable reusable sandbox or test utilities. @@ -550,6 +600,9 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p - A provenance-only, SBOM-only, or checksum-only result cannot produce a `VerifiedArtifact`. - Exercise missing/incompatible GitHub CLI, network failure, malformed JSON and partially successful command sequences. - Verify saved bundles/roots with networking disabled; untrusted or absent roots fail. +- Read-only online export applies the complete deployment verification policy, rejects unsupported assets, performs no provider writes, and emits no successful publication record after a partial failure. +- Official offline deployment rejects absent/untrusted publication records, missing or mismatched independently approved record pins, and records inconsistent with tag/source/index/artifact identities, even when all build bundles verify. A signed index from an unpublished build is insufficient. +- Inherited GitHub host/configuration overrides cannot redirect the official verifier's authority or weaken fixed signer/issuer policy. - Validate index schema/paths, duplicate assets, expected matrix completeness, and companion provenance subjects. - Fail CI eligibility when successful checks are absent for the exact source commit, not merely for current `main`. - Exercise failed matrix jobs, partial draft recovery, conflicting drafts, concurrent runs, already published tags, and uncertain publication responses. @@ -563,9 +616,12 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p - Replacing the original operator artifact path after verification cannot substitute upload bytes; changing retained/package bytes is detected before upload. - Production and staging submit the explicitly verified package and return exact service/version results. - Staged config selectors and physical resource mappings keep current isolation behavior. -- Health-check failure, partial activation, rollback and receipt-write failure report observed state accurately. +- Missing/incompatible manifest or config logical-ID context rejects Trusted Server staging before mutation; the prepared package and lifecycle share the same validated context. +- Partial upload/stage/activation, uncertain responses and receipt-write failure preserve known/unknown outcomes and require reconciliation before retry. Deployment-only receipts report no health check performed. +- Separate health-check and rollback commands can consume the exact returned version; orchestration captures production's previous version before mutation and serializes lifecycle writes per service. - Readiness of `/health` or a reported version is never asserted to be cryptographic artifact verification. - Native CLI startup/help/config-init work outside the source tree; repository-dependent commands retain explicit prerequisites. +- Native linkage inspection and clean Ubuntu 24.04 runtime smoke establish the advertised Linux baseline independently of build-runner provisioning. ### Required repository checks @@ -590,14 +646,17 @@ Published immutable releases are never repaired by overwriting assets or moving ## Alternatives considered -| Approach | Benefit | Reason for the selected order | -| --------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected first; avoids independent-build determinism as an adoption prerequisite | -| Require local reproducible rebuilds | Operators can independently rebuild and compare | Valuable follow-up; native paths/SDKs, JS/environment and transformations need additional controlled experiments | -| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | -| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | -| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | -| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | +| Approach | Benefit | Reason for the selected order | +| --------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected first; avoids independent-build determinism as an adoption prerequisite | +| Require local reproducible rebuilds | Operators can independently rebuild and compare | Valuable follow-up; native paths/SDKs, JS/environment and transformations need additional controlled experiments | +| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | +| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | +| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | +| Rust-native SBOM tooling or `cargo-auditable` | Reduces custom Rust dependency tooling | Cargo's precise SBOM precursor is currently unstable; stable cargo-auditable uses metadata fallback, modifies executable bytes and does not replace JS/native composition. Revisit when a maintained pipeline proves the selected contract | +| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | + +The composition adapter and provider lifecycle interface are the largest implementation costs. Keep the adapter limited to observed selection, metadata joins, relationships and validation; reuse Syft and maintained parsers rather than writing a new package scanner. The fixture proof precedes production workflow work so feasibility failures can change the approved contract early. Publishing verified bytes first remains the chosen order; independent reproducibility and stronger builder isolation add distinct assurance later. ## Acceptance criteria @@ -608,7 +667,7 @@ Published immutable releases are never repaired by overwriting assets or moving - [ ] Strict JS release builds reject missing tools, installation failure, skip flags and stale/incomplete output. - [ ] SBOM fixtures prove target/build-host selection and embedded JS scope, including the external Prebid exclusion. - [ ] Both production and staging artifact deployment preserve verified wasm bytes and perform no provider writes on verification failure. -- [ ] Offline evidence verification works without network access and documents its release-state/freshness limits. +- [ ] Read-only online export produces verified offline evidence; official offline deployment requires the previously verified publication record and its independently approved digest pin, works without verification network access, and documents its freshness limits. - [ ] Build facts distinguish release/package/source/target/variant from service version and final binary checksum. - [ ] Deployment observations preserve exact known results without claiming publisher authorization or remote execution proof. - [ ] Immutable-release settings, active tag rules and release authority are verified and documented. @@ -628,6 +687,11 @@ Authoritative implementation references: - [Immutable-release settings API](https://docs.github.com/en/rest/repos/repos#check-if-immutable-releases-are-enabled-for-a-repository). - [GitHub CLI attestation verification](https://cli.github.com/manual/gh_attestation_verify), [bundle download](https://cli.github.com/manual/gh_attestation_download), and [trusted roots](https://cli.github.com/manual/gh_attestation_trusted-root). - [Attestation action modes and outputs](https://github.com/actions/attest). +- [SLSA build requirements](https://slsa.dev/spec/v1.2/build-requirements). +- [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/). +- [IETF RATS architecture](https://www.rfc-editor.org/rfc/rfc9334.html). +- [Reproducible build definition](https://reproducible-builds.org/docs/definition/). +- [Cargo SBOM precursor](https://doc.rust-lang.org/cargo/reference/unstable.html#sbom) and [cargo-auditable's metadata/precursor selection](https://github.com/rust-secure-code/cargo-auditable/blob/master/cargo-auditable/src/collect_audit_data.rs). - [Syft package catalogers](https://oss.anchore.com/docs/guides/sbom/catalogers/) and [Rust capabilities](https://oss.anchore.com/docs/capabilities/rust/). - [Rollup output hooks](https://rollupjs.org/plugin-development/). - [Cargo build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html) and [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html). From fd5cc4dfda0740d89279493b45d4bac5eabb7cec Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 11:51:59 +0530 Subject: [PATCH 003/153] Require reproducible release builds and operator replay --- ...6-10-09-1235-release-attestation-design.md | 133 ++++++++++++++---- 1 file changed, 104 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 305ac94dbf..1fbc8f4481 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -31,6 +31,7 @@ This design establishes a canonical release artifact per version, target, and ap 7. Make immutable build facts available to future #161 work, while keeping the final binary digest external. 8. Keep artifact identity, publisher identity, configuration identity, deployment observations, and runtime evidence distinct. 9. Preserve existing logical-store mapping and staged configuration isolation, with exact deployment outcomes usable by the separate health-check and rollback lifecycle commands. +10. Require byte-identical clean rebuilds of all initial executable artifacts, publish replayable recipes and inputs, and support operator reconstruction against the authenticated release digest. ## Non-goals @@ -40,7 +41,7 @@ This design establishes a canonical release artifact per version, target, and ap - Resolving permission-policy distribution or authorization semantics. The permission specification owns these decisions; this specification supplies a compatibility requirement. - Vendor endorsement protocols or CODEOWNERS changes. - Signatures or the final artifact checksum embedded in a wasm custom section. -- Reproducible independent builds as a prerequisite for the first release. +- Requiring an external rebuild witness as a release prerequisite, or claiming that two project-controlled builds establish independent builder trust. - Cloudflare, Spin, Axum, containers, Windows, Linux ARM64, universal macOS binaries, or standalone tsjs release distributions. - Attesting the publisher-selected external Prebid.js bundle. - Making all CLI commands self-contained without their documented external tools or repository resources. @@ -52,7 +53,8 @@ This design establishes a canonical release artifact per version, target, and ap | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | First executable artifacts | Fastly `wasm32-wasip1`; CLI `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, and `x86_64-apple-darwin` | | Fastly build variant | Existing production defaults; `reusable-sandbox` and test features are off | -| Deployment strategy | Verify and deploy release artifacts first; independent reproducible builds follow separately | +| Deployment strategy | Both reproducible executable production and verified artifact deployment; downloading verified release bytes remains the default operator route | +| Reproducibility gate | Two clean builds per initial target must match the entire distributed executable; missing results or mismatches block the complete release | | Integrated operator flow | Add an artifact deployment path to `ts deploy` through an additive EdgeZero interface; retain a documented native Fastly packing route during rollout | | SBOM | SPDX 2.3 JSON generated by pinned Syft plus a repository-owned composition step using the actual Cargo build graph and JS bundle evidence | | SBOM distribution | One SBOM per executable, published as a release asset and embedded as that executable's SBOM attestation predicate | @@ -131,7 +133,7 @@ This prohibits publisher domains, service/account IDs, physical store names, cus The repository's logical store identifiers, product defaults, and the fictional CLI configuration scaffold are source-defined product inputs, not an operator's active configuration. They remain identical for all users of a release artifact. The CLI scaffold is used to create a configuration file, never as an embedded deployment configuration fallback. -The repository copy of `edgezero.toml` is immutable within a release build. Physical resource names and version-linked selectors are configured at deployment time. An operator modifying a local manifest must still deploy the downloaded release wasm; the manifest is not supplied to Cargo for that artifact. +The repository copy of `edgezero.toml` is immutable within a release build. Physical resource names and version-linked selectors are configured at deployment time. An operator modifying a local manifest must still deploy verified canonical release bytes, downloaded or reconstructed through the declared recipe; the modified manifest is not supplied to Cargo for that artifact. The proposed embedded permission policy in #1045/#1084 cannot be adopted as publisher-variable build data. A fixed enforcement implementation may be source code, but variable policy must preserve the separation above. A conflicting permission change blocks publication until its owning specification resolves the conflict. This design does not choose a policy store, policy signature, or policy authority. @@ -158,13 +160,60 @@ Every release records the following input classes in build evidence. New inputs `TEMPLATE_BUILD_DIGEST` hashes the complete raw manifest and can change after a comment-only manifest edit. It also includes ordinary untracked files under core source. Release builds therefore use a fresh checkout containing only the expected source snapshot and release outputs outside `core/src`. The digest remains a cache compatibility input; it does not cover the complete binary or compiler and must not be advertised as provenance. -The CLI currently embeds `CARGO_MANIFEST_DIR` as a resource-discovery fallback, and debug metadata can contain paths. These are recorded build inputs. This first release makes no byte-identical-rebuild claim. Removing path-dependent data, normalizing debug information, stabilizing native SDK inputs, and testing independent rebuilds belong to later reproducibility work. +The CLI currently embeds `CARGO_MANIFEST_DIR` as a resource-discovery fallback, and debug metadata can contain paths. Official release builds disable that developer checkout fallback while retaining explicit/current-checkout resource discovery and clear diagnostics for repository-dependent commands. Rust path remapping does not rewrite arbitrary strings produced by `env!`. Debug/native paths and SDK inputs are controlled by the reproducibility contract below; recording ambient values alone is insufficient. Release preflight rejects any presence of `TSJS_SKIP_BUILD` before environment sanitization; silently removing it would hide a prohibited release request. The build script independently enforces the same rule. Reject other prohibited operator build inputs, then construct the permitted build environment rather than inheriting arbitrary compiler/tool overrides. Remove operator `TRUSTED_SERVER__*` and `EDGEZERO__*` values from the actual build environment. The recipe sets or rejects compiler/tool overrides including `RUSTC`, `RUSTDOC`, `RUSTC_WRAPPER`, `RUSTC_WORKSPACE_WRAPPER`, `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `CARGO_BUILD_RUSTFLAGS`, target-specific Cargo rustflags/linkers, Cargo profile overrides, `NODE_OPTIONS`, and npm/Vite configuration overrides. The only compiler wrapper is the reviewed composition recorder. Control Cargo home/configuration layers and prohibit unreviewed command-line configuration overrides; record the approved settings. Vite release execution explicitly disables environment-file loading and uses the approved production mode. Release scripts validate permitted native compiler settings rather than inheriting operator values. Platform authentication is unavailable to build jobs. This is a controlled release recipe, not a claim of a fully hermetic build. -No wall-clock build timestamp, workflow run ID, publisher identity, or final artifact checksum is embedded. Operational timestamps and run identifiers remain external evidence. `SOURCE_DATE_EPOCH`, if used by a component, is derived from the source commit and recorded. +No wall-clock build timestamp, workflow run ID, publisher identity, or final artifact checksum is embedded. Operational timestamps and run identifiers remain external evidence. Set `SOURCE_DATE_EPOCH` to the peeled source commit's committer timestamp and record it. Audit tools that do not honor this variable; setting it is not a blanket determinism guarantee. + +#### Reproducible executable contract + +Issue #1235 permits verified release deployment, reproducible local builds, or both in a stated order. This specification selects both. Establish the executable recipes and reproducibility fixtures before release publication, then retain the verified deployment path so operators can use canonical bytes without rebuilding. Nothing in the baseline proves reproducibility yet; the following requirements must be demonstrated during implementation. + +Reproducibility means that the specified source, environment and instructions reconstruct the **complete distributed executable bytes**, separately for each target/variant. It does not mean different target binaries match, or that an arbitrary ambient host can run bare Cargo and obtain the same result. The contract covers embedded JS and every final executable transformation, including any required macOS ad-hoc signature. It does not require byte-identical Sigstore bundles/certificates, transparency entries, run timestamps, logs, SBOM generation times, every intermediate, or publisher-specific Fastly archives. + +Each artifact has a tracked recipe implementation and a version-1 `.build-recipe.json` descriptor, authenticated through provenance and the release index. The descriptor contains: + +- Repository, peeled commit, release facts, package/bin, target/variant, recipe source revision and script digests. +- Exact Rust/toolchain components, Node/npm and bundler identities; permitted features, complete effective release profile, Cargo source/configuration layers and compiler/linker arguments. +- Locked source/material identities and the authenticated retained-input archive digest, including git dependencies, npm optional packages, native bundler tools and permitted lifecycle inputs. +- Host architecture, byte-affecting platform/toolchain identities, native compiler/linker/archiver/build tools, sysroot or SDK, and required environment bootstrap instructions. +- Build-path/remapping policy, locale/timezone, commit-derived epoch, generic target CPU/code-generation policy, relevant concurrency settings, and every final byte transformation. + +Descriptor version and fields are validated against the reviewed recipe; a downloadable descriptor cannot substitute arbitrary commands or tools. Operator replay uses the tracked reproduction script from the approved source revision. Environment and dependency inputs must be retrievable and integrity-verifiable without release-workflow credentials. Missing pins, unavailable required inputs or an unproven target block publication; they are not silently waived. Implementation selects actual tool/SDK/image pins through the reviewed release-tools manifest and proves the complete target matrix before the first tag. + +**Environment and dependency reconstruction.** Fastly and Linux CLI builds run in an OCI builder image pinned by manifest digest and explicit `linux/amd64` platform on GitHub-hosted Linux runners. Retain the image and its reviewed bootstrap definition; builder-image construction pins base images and fetched tool/native inputs, rather than resolving floating OS packages. The image is a build environment, not a new Trusted Server container distribution. Document image retrieval/export and digest checking for operator replay. + +macOS recipes use native Apple tooling on the matching architecture, with explicit Xcode/CLT build, SDK identity/content digest, OS compatibility requirements, `SDKROOT`, deployment target and native tool identities. Validate required identities before compilation and record the actual runner OS/image. A hosted runner label is a scheduler selection, not an immutable environment pin. Provide a tested bootstrap on a separate compatible Mac; any OS/tool input that affects bytes becomes an explicit checked recipe input. A missing SDK/tool pin fails rather than falling back to whatever Xcode is active. Operators obtain platform SDKs through their supported distribution; this design does not promise SDK redistribution inside a public source archive. + +Prepare an authenticated, versioned `release-inputs.tar.zst` containing the exact source snapshot, integrity-checked Cargo vendored sources including locked git dependencies, generated Cargo source-replacement configuration, and per-platform npm input archives/cache needed for fresh installation. Retain platform tool archives/images where distributable; otherwise the descriptor supplies acquisition prerequisites and integrity checks. The input archive is self-contained for project dependencies, not a substitute for the declared OS/SDK environment. Assemble it before compilation with normalized file ordering/metadata and safe extraction rules. It contains source/dependency materials and preparation configuration, never generated final recipe descriptors, rebuild records or executables; the later recipe/index can name its digest without a checksum cycle. Recorded raw vendored-material identities do not replace original Cargo source/revision identities in the SBOM. + +Both release builds and operator replay compare the archive's tracked source bytes, file modes and link targets with the approved Git tree independently obtained from the repository. A signed archive naming a commit is insufficient if its contents differ from that commit. Locked dependency archives/revisions are checked independently as well. Preparation may access the network; both clean compile phases use freshly extracted materials, Cargo `--frozen` and fresh `npm ci` with recorded offline options. Enforce network denial for compilation/lifecycle processes as well: Cargo's offline flag alone does not prevent a dependency build script from fetching additional inputs. Unexpected fetches fail and require a reviewed input/recipe correction. + +**Path, time and native output control.** Use canonical internal source/dependency/output/toolchain paths and tested Rust remappings covering relative and absolute paths, plus native compiler/debug prefix mappings where applicable. Preserve the declared `debug = 1` profile; any stripping is a pinned final transformation shared by CI and local replay. Normalize source/material mtimes using the recorded epoch where tools consume them, fix locale/timezone and reject host-specific CPU auto-tuning. Vary external checkout locations while keeping declared internal paths canonical; do not claim unrestricted internal-path independence without testing it. + +Linux selects a deterministic content-derived linker build-ID policy. macOS controls linker OSO debug paths and modification times, validates the selected linker's reproducible mode/epoch behavior, and preserves a tested content-derived UUID policy. Do not introduce random UUIDs or indiscriminately disable UUID generation. Any required linker ad-hoc signing has recorded deterministic settings and is included in the final comparison; removing a signature only for comparison is forbidden. Developer ID signing, trusted timestamps, notarization and stapling remain outside the first distribution. Rust remapping alone does not normalize native linker output or arbitrary generated source. + +**Release gate.** For every one of the four initial targets, Build A and Build B execute the complete recipe in separate fresh hosted jobs. Each verifies/materializes its own source and dependencies, owns new Cargo/npm/JS output directories, and consumes no executable, compiled cache, generated JS or generated code from the other build. They use the same checked recipe/environment inputs. The trusted collection job checks current-run artifact identities, source/recipe/environment consistency, embedded JS/module digests, final sizes and SHA-256 values, then byte-compares the two complete executables. No normalization or excluded byte regions are allowed at comparison time. A missing result, environment mismatch, input mutation or unequal bytes stops signing/publication of the complete release. + +After successful comparison, choose Build A's executable as the canonical asset and attest it with its SBOM, build evidence, recipe and `.rebuild-check.json` as explicit provenance subjects. The version-1 rebuild record binds the source, recipe/input digests, observed environments, both build-job/artifact identities, both final lengths/digests, module inventory and comparison outcome. Workflow topology and current-run artifacts establish that the jobs were separate; an arbitrary predicate field asserting independence is insufficient. These project-controlled executions establish measured recipe repeatability. They do not establish independent witness trust, prevent a compromised common compiler from producing the same malicious output, or imply SLSA Build Level 3. + +**Operator replay.** Verify the release/index, executable, recipe and input identities before executing the recipe. The tracked `scripts/release/reproduce.sh` validates the approved descriptor, bootstraps/checks its target environment, extracts only authenticated inputs into a fresh workspace, builds without compilation network access and emits the final executable. It never downloads Build A's executable as a build input. A proposed invocation after obtaining the approved source is: + +```sh +./scripts/release/reproduce.sh \ + --recipe ./release-evidence/trusted-server-fastly.wasm.build-recipe.json \ + --inputs ./release-inputs.tar.zst \ + --source-commit '' \ + --output-dir ./rebuild-output + +cmp ./release-evidence/trusted-server-fastly.wasm \ + ./rebuild-output/trusted-server-fastly.wasm +``` + +This script is new implementation work. The same interface supports each named native CLI asset under its declared platform recipe. A matching locally reconstructed wasm can use the existing `--release --artifact` verification path: its digest is the attested release subject, so ordinary online/offline provenance, SBOM and publication checks still apply. Matching bytes do not create new GitHub provenance, and successful compilation without digest equality is not verification. A mismatch reports failure and preserves diagnostic output; pinned `diffoscope` or equivalent format-aware diagnostics may explain it but cannot convert it into success. ### 4. Artifact matrix and distribution @@ -177,7 +226,7 @@ No wall-clock build timestamp, workflow run ID, publisher identity, or final art Runner labels fix an OS generation, not an immutable VM image. Capture the actual image/toolchain versions and perform executable startup checks on each selected runner. Inspect native linkage and loader paths on Linux/macOS. Validate the Linux CLI's version/help/config-init commands outside a checkout in a clean Ubuntu 24.04 runtime with only its documented runtime dependencies installed; a provisioned build runner alone does not establish that compatibility. Reject dependencies on absolute runner-only paths or undeclared libraries. Changes to compatibility require a reviewed recipe change. Linux portability to older glibc, ARM Linux, and older macOS is not implied. -Use explicit Cargo packages/targets with `--release --locked`. Fastly does not enable `reusable-sandbox`; production build commands do not use `--all-features`. CLI uses its existing default feature graph, including EdgeZero's default adapter registrations. Do not remove dependencies merely because they appear unrelated at the workspace level. +Use explicit Cargo packages/targets with `--release --frozen` after the authenticated input-preparation step; this includes locked resolution. Fastly does not enable `reusable-sandbox`; production build commands do not use `--all-features`. CLI uses its existing default feature graph, including EdgeZero's default adapter registrations. Do not remove dependencies merely because they appear unrelated at the workspace level. Ship raw executables to avoid an archive/extracted-executable identity ambiguity. Downloads may need their executable permission restored after verification. Any linker signing, stripping, or other transformation happens before checksums and attestation. No operation may modify an attested executable afterward. @@ -187,12 +236,14 @@ Each executable has named companions: - `.provenance.sigstore.json` — the generated build-provenance bundle. - `.sbom.sigstore.json` — the generated SBOM attestation bundle. - `.build-evidence.json` — selected build inputs, composition roles, and embedded module inventory. +- `.build-recipe.json` — the authenticated versioned recipe and reconstruction inputs. +- `.rebuild-check.json` — observed clean-build identities and exact executable comparison. -Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, and `SHA256SUMS`. The checksum file covers every other published asset and never itself. The release index excludes its own digest and its own attestation bundle; its signed identity is external, so there is no checksum cycle. +Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, `release-inputs.tar.zst`, and `SHA256SUMS`. The index records the input archive's name/size/digest; its provenance also names that archive as a subject. The checksum file covers every other published asset and never itself. The release index excludes its own digest and its own attestation bundle; its signed identity is external, so there is no checksum cycle. The release index is a versioned distribution index, not a publisher-attestation schema. Version 1 records repository, tag, source commit, recipe revision, and an exact artifact list. Each artifact entry records name, byte length, SHA-256, package, target, variant, compatibility baseline, and companion asset names/digests. Consumers reject duplicate names, unknown required schema versions, traversal paths, and conflicting identities. -Each artifact's provenance covers its binary, SBOM file, and build-evidence file as explicit subjects. The separate SBOM invocation covers the binary as subject and the SPDX document as predicate. The release index receives its own provenance attestation after the matrix artifacts and bundles are collected. Index assembly verifies that all entries came from the expected source/ref and complete matrix. +Each artifact's provenance covers its binary, SBOM file, build-evidence file, recipe and rebuild-check file as explicit subjects after the dual-build gate passes. The separate SBOM invocation covers the binary as subject and the SPDX document as predicate. The release index and input archive receive provenance after the matrix artifacts and bundles are collected. Index assembly verifies that all entries came from the expected source/ref and complete matrix. `ts --version` identifies the release version and source revision. The existing Cargo package version is retained separately; the release process does not edit `Cargo.toml` to make an existing workspace version equal the tag. @@ -207,13 +258,13 @@ Strict mode: 1. Rejects the presence of `TSJS_SKIP_BUILD`, including an empty value or `0`. 2. Requires the JS manifest, lockfile, pinned Node/npm, and build entry points. 3. Runs `npm ci` with build dependencies included even if `node_modules` already exists; any invocation or installation failure stops the build. -4. Deletes old JS output and builds bundles during the Cargo build that embeds them. +4. Deletes old JS output and builds bundles during each clean Cargo build that embeds them. 5. Requires exactly core plus the sorted integration entry points discovered from tracked source. Missing, empty, duplicate, or unexpected `tsjs-*.js` output is an error. 6. Produces bundle composition evidence during that build and checks each bundle's recorded digest against the bytes copied into `OUT_DIR`. 7. Checks the exit status of explicitly requested JS tests. Release validation also runs JS tests as an independent blocking step. 8. Tracks the strict flag, skip/test flags, selected tools, recipe inputs, and relevant environment changes so an incremental local invocation cannot silently keep a previous permissive result. -Release jobs have fresh checkouts and isolated Cargo target directories. Do not restore compiled Cargo output, `dist`, `OUT_DIR`, or `node_modules` from caches. Verified Cargo/npm download caches may be used only with archive bytes revalidated against locked checksums/integrity before fresh extraction; restored extracted source trees are not assumed to match a lockfile. Pinned git dependencies are freshly checked out and matched to their locked revisions. No parallel Cargo process mutates a shared crate `dist` directory; each matrix job owns its checkout. +Release jobs have fresh checkouts and isolated Cargo target directories. Do not restore compiled Cargo output, `dist`, `OUT_DIR`, or `node_modules` from caches. Verified Cargo/npm download caches may be used only with archive bytes revalidated against locked checksums/integrity before fresh extraction; restored extracted source trees are not assumed to match a lockfile. Input preparation freshly checks out pinned git dependencies and verifies their locked revisions before vendoring; each offline clean build independently verifies and materializes that authenticated vendored content. No parallel Cargo process mutates a shared crate `dist` directory; each matrix job owns its checkout. Record npm lifecycle execution and generated native/tool inputs separately from the package archive integrity. `npm ci` authenticates selected archive content under the lockfile, but permitted install scripts execute code and may generate or modify installed files; it does not make every resulting file byte-identical to the archive. Review and record the required lifecycle behavior, including esbuild's native tool setup, rather than disabling all scripts and silently breaking the selected bundler. @@ -259,7 +310,7 @@ A fixture proof is an implementation prerequisite: target-specific Cargo selecti Eligibility uses reviewed workflow-file identities, not arbitrary successful check names. Require completed `success` runs of `test.yml` and `format.yml` for the exact peeled commit in this repository, with `push` to `main` as the accepted event/ref. Clippy is part of `format.yml` at the baseline. Enumerate the required non-optional jobs in the recipe; skipped, cancelled, unrelated, fork, PR-only, stale, or still-running results do not satisfy the gate. The recipe must account for every AGENTS.md CI requirement. Gates whose workflow is path-filtered run as explicit blocking release-validation jobs when there is no eligible exact-commit result. A tag on an older reviewed commit is eligible only if that commit's required checks can still be established or rerun through the trusted validation path. -The workflow invokes local `.github/workflows/release-build.yml` at the same source revision. The reusable workflow owns the fixed artifact matrix, recipes, build-evidence generation, and attestation calls, including its own index collection/signing job. The caller publication job does not sign the index. The reusable workflow does not accept arbitrary shell commands, script paths, artifact paths, repository overrides, or caller-generated provenance predicates. +The workflow invokes local `.github/workflows/release-build.yml` at the same source revision. The reusable workflow owns input preparation, the fixed artifact matrix with separate A/B build jobs, comparison and attestation jobs, and its own index collection/signing job. Signing jobs consume validated current-run outputs only after every required reproducibility comparison succeeds; build/lifecycle-script execution receives no signing permissions. The caller publication job does not sign the index. The reusable workflow does not accept arbitrary shell commands, script paths, artifact paths, repository overrides, or caller-generated provenance predicates. The initial supported assurance is SLSA Build Level 2. Level 3 requires a separately reviewed isolation design in which build execution cannot subvert provenance generation; moving steps into a reusable workflow alone does not satisfy that requirement. The early reusable boundary keeps that upgrade separate from runtime and publisher interfaces. @@ -269,15 +320,16 @@ All external actions are pinned by full commit SHA with a readable version comme | ----------------------------------- | -------------------------------------------------------------------------------------------------------- | | Eligibility and validation | `contents: read`; `checks: read` and `actions: read` only where needed to verify exact-commit CI results | | Settings capability preflights | `contents: read` for `GITHUB_TOKEN`; isolated repository administration-read credential described below | -| Build and attest | `contents: read`, `id-token: write`, `attestations: write`, `artifact-metadata: write` | +| Prepare inputs and clean builds A/B | `contents: read`; no OIDC or attestation writes | +| Compare and attest validated builds | `contents: read`, `id-token: write`, `attestations: write`, `artifact-metadata: write` | | Collect, validate, and attest index | Same attestation permissions; only authenticated artifacts from the current release run | | Draft preparation and final publish | Separate jobs with `contents: write`; no OIDC signing, settings credential or deployment credentials | Declare read-only defaults and grant writes per job. Reusable-workflow callers grant only the permissions required by the called jobs. No platform credentials, operator configuration, or publisher secrets enter the release workflow. -For each artifact, invoke SHA-pinned `actions/attest` twice: once for SLSA provenance with explicit binary/SBOM/build-evidence subjects, and once with the binary subject plus `sbom-path`. Supplying `sbom-path` selects SBOM mode; it does not also generate the separate provenance claim. Keep the returned Sigstore bundles as named release assets. +For each artifact, invoke SHA-pinned `actions/attest` twice after the comparison gate: once for SLSA provenance with explicit binary/SBOM/build-evidence/recipe/rebuild-check subjects, and once with the binary subject plus `sbom-path`. Supplying `sbom-path` selects SBOM mode; it does not also generate the separate provenance claim. Keep the returned Sigstore bundles as named release assets. -Before publication, the collection job verifies all provenance/SBOM bundles with the expected reusable signer, source ref and commit, and hosted-runner policy. It verifies that each signed SPDX predicate corresponds to the published SBOM's parsed document, and that all artifact/build-evidence/SBOM subject digests match. It then creates and attests the release index. +Before publication, the collection job verifies all provenance/SBOM bundles with the expected reusable signer, source ref and commit, and hosted-runner policy. It verifies that each signed SPDX predicate corresponds to the published SBOM's parsed document, that all artifact/build-evidence/SBOM/recipe/rebuild subject digests match, and that the full matrix has successful consistent rebuild records. It then creates and attests the release index and retained input archive. The draft preparation job creates a draft, uploads the complete expected asset set and checksums, re-downloads the draft assets, and validates their digests/evidence. A repeat settings preflight depends on successful draft preparation, and a separate final publication job depends on that preflight. Only then does it publish. Immutable releases must already be enabled; the initial settings preflight checks the repository capability before builds begin and the publication job confirms the resulting release is immutable afterward. If the setting cannot be established, the workflow stops without publishing. @@ -293,7 +345,7 @@ The trusted source repository is `IABTechLab/trusted-server`. The signer is `IAB The CLI's release policy fixes GitHub host `github.com`, repository, signer, issuer, supported targets/variants, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. The checked subprocess uses explicit host/repository selection, fixed verifier arguments, and an approved GitHub CLI executable/version; it clears unapproved host/configuration overrides and accepts no forwarded verifier arguments. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. -The release index is authenticated before its target/variant/digest fields are trusted. Online consumers validate immutable release membership, expected tag/source identity, index provenance, artifact provenance, and the artifact's SPDX attestation. `SHA256SUMS` is a convenience check only until authenticated through the release or independently signed evidence. +The release index is authenticated before its target/variant/digest fields are trusted. Online consumers validate immutable release membership, expected tag/source identity, index provenance, artifact provenance, the artifact's SPDX attestation, and authenticated consistent recipe/rebuild records. The project workflow's rebuild record is observed comparison evidence, not an independent witness endorsement. Operator reconstruction additionally verifies the retained input archive before extraction. `SHA256SUMS` is a convenience check only until authenticated through the release or independently signed evidence. The following uses a fictional future release tag. The reviewed commit is obtained from the verified immutable release record and the consumer's release approval policy; a value read from an unauthenticated JSON index is insufficient. @@ -442,6 +494,8 @@ Reject unsupported adapters, missing evidence, unsupported targets/variants, con Existing source-build commands remain available for development and custom distributions, with a clear diagnostic that the resulting bytes have not been established as the canonical release. The supported attested production path uses release artifacts. A local build is not equivalent merely because its source commit or compiled version string matches. +A local build following the published reproduction recipe is eligible only when its complete digest matches the selected official artifact and the existing release/evidence checks succeed. It uses the same retained-byte/package path, never a special bypass for locally compiled code. Arbitrary local builds retain the diagnostic above. + #### Interface ownership Trusted Server owns official release acquisition/verification and the `VerifiedArtifact` value. Its fields are private; callers cannot construct one by supplying a path and an asserted checksum. It owns retained bytes, SHA-256, validated release/source identity, target/variant, verification policy version, and evidence references. Provider code cannot replace its bytes through passthrough arguments. @@ -593,6 +647,19 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p - Unknown contributing package ownership, missing graph coverage, broken SPDX relationships, and oversized predicates reject publication. - Published SBOM data matches the verified SPDX predicate and separately attested SBOM-file digest. +### Reproducibility and operator reconstruction + +- Demonstrate complete byte equality of A/B builds for Fastly, Linux CLI, macOS ARM64 CLI and macOS Intel CLI before the first release. There is no per-target warning-only fallback. +- Use separate clean jobs and newly materialized source/dependencies/outputs; prove Build B cannot consume A's executable, compiled cache or generated JS/code. +- Compare source, recipe, platform/toolchain inputs and embedded module digests as well as final size/SHA-256 and byte equality. Inject a one-byte executable change, missing build, wrong recipe/SDK and incorrect source epoch; each blocks publication. +- Replay the published recipe on a clean Linux container host for both Linux-built assets and on a separate compatible Mac for each native Apple target. Reconstruct from authenticated retained inputs without workflow/provider credentials or compilation networking. +- Vary external checkout directory, fresh dependency materialization and execution time while honoring the declared canonical internal environment. Test permitted parallelism variations where the recipe claims they do not affect bytes. +- Inspect final files for leaked developer checkout paths; test the release CLI's disabled compiled-path fallback and retained explicit/current-checkout resource behavior. +- Validate native build IDs, Apple OSO paths/times, UUID and ad-hoc signature settings; compare the actual distributed signed bytes without removing sections for comparison. +- Verify input-archive identity and safe bounded extraction; reject source contents/modes/link targets inconsistent with the independently obtained approved Git tree. Missing archives/tools, incompatible platforms, prohibited fetches or changed inputs fail with a clear diagnostic. +- A locally reconstructed matching wasm passes ordinary release verification before provider writes; a mismatching one fails even when compiled version/source facts match. +- Recipe/rebuild/input-archive identities remain consistent with provenance/index/offline publication records. Runtime claims never interpret repeatability as proof of remote execution or independent compiler trust. + ### Verification and publication - Verify both provenance and SPDX predicates for every binary with fixed signer/ref/commit constraints. @@ -632,13 +699,13 @@ The specification-only PR needs Markdown formatting, relative-reference validati ## Rollout 1. **Approve the contract.** Land this specification in its own PR. Record maintainer approval; implementation PRs link it. Confirm the permission work accepts the configuration separation requirement. -2. **Prove composition and upstream capability.** Validate the selected SBOM pipeline fixtures and land EdgeZero's additive prebuilt production/staging request/result. Pin the reviewed upstream revision before adopting it. -3. **Implement release build support.** Add strict JS mode, composition evidence, build facts, recipes/tools pins, index generation, provenance/SBOM signing and publication workflows. Reconcile obsolete CI comments claiming configuration is baked into wasm. +2. **Prove composition, reproducibility and upstream capability.** Validate the selected SBOM pipeline fixtures, demonstrate exact-byte clean builds and operator replay for all four target recipes, and land EdgeZero's additive prebuilt production/staging request/result. Pin the reviewed upstream revision before adopting it. An unresolved target blocks the selected first-release matrix; any reduced matrix requires a reviewed specification change. +3. **Implement release build support.** Add strict JS mode, composition evidence, build facts, deterministic recipes/tools/environment pins, retained inputs, A/B comparison gates, index generation, provenance/SBOM signing and publication workflows. Reconcile obsolete CI comments claiming configuration is baked into wasm. 4. **Implement verified deployment.** Add the fixed release policy, online/offline evidence handling, retained artifact/package verification, provider sequencing and optional local receipts. Document the native Fastly route as a transitional/manual path. 5. **Prepare release authority.** Verify immutable releases and tag rulesets, provision the isolated repository-scoped read-only settings credential, audit bypass permissions and action/tool pins, and prove publication in a disposable public repository. Do not backfill historical tags with claims that their old operator builds were attested. 6. **Publish the first eligible new tag.** Complete the full artifact matrix, verify immutable membership and all predicates, and exercise an explicitly authorized test deployment through the supported artifact route. -7. **Move operator guidance to release bytes.** Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Explain supported targets, installation prerequisites, identity policy, offline limits, staging and rollback, and the distinction between custom source builds and canonical artifacts. -8. **Continue separately.** #161 designs publisher trust, loaded-config metadata, claim signing/freshness and runtime transport. Later work can add adapters, independent reproducible builds and stronger builder isolation without changing the artifact/deployment identity boundary. +7. **Move operator guidance to verified bytes.** Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document the default download/deploy route and exact operator rebuild recipe, expected digest checks, environment/input acquisition, supported targets, installation prerequisites, identity policy, offline limits, staging and rollback. Distinguish ordinary custom builds from verified reconstructed artifacts. +8. **Continue separately.** #161 designs publisher trust, loaded-config metadata, claim signing/freshness and runtime transport. Later work can add adapters, independent rebuild witnesses, broader environment portability and stronger builder isolation without changing the artifact/deployment identity boundary. A delayed EdgeZero change does not justify rebuilding inside the verified deployment path. Releases may be distributed with the manual packing instructions during rollout, but #1235's integrated deployment acceptance is not marked complete until the declared route is available and verified. @@ -646,17 +713,18 @@ Published immutable releases are never repaired by overwriting assets or moving ## Alternatives considered -| Approach | Benefit | Reason for the selected order | -| --------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected first; avoids independent-build determinism as an adoption prerequisite | -| Require local reproducible rebuilds | Operators can independently rebuild and compare | Valuable follow-up; native paths/SDKs, JS/environment and transformations need additional controlled experiments | -| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | -| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | -| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | -| Rust-native SBOM tooling or `cargo-auditable` | Reduces custom Rust dependency tooling | Cargo's precise SBOM precursor is currently unstable; stable cargo-auditable uses metadata fallback, modifies executable bytes and does not replace JS/native composition. Revisit when a maintained pipeline proves the selected contract | -| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | +| Approach | Benefit | Reason for the selected order | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected default operator route; operators need not rebuild to deploy verified bytes | +| Reproducible executable recipes plus verified deployment | Operators can reconstruct and compare while retaining direct verified deployment | Selected together: all initial targets must pass clean-build comparisons and replay fixtures before publication | +| Require every operator to rebuild before deployment | Every deployment starts with local reconstruction | Unnecessary operational prerequisite; reconstruction is supported, while mandatory release comparisons establish recipe repeatability | +| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | +| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | +| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | +| Rust-native SBOM tooling or `cargo-auditable` | Reduces custom Rust dependency tooling | Cargo's precise SBOM precursor is currently unstable; stable cargo-auditable uses metadata fallback, modifies executable bytes and does not replace JS/native composition. Revisit when a maintained pipeline proves the selected contract | +| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | -The composition adapter and provider lifecycle interface are the largest implementation costs. Keep the adapter limited to observed selection, metadata joins, relationships and validation; reuse Syft and maintained parsers rather than writing a new package scanner. The fixture proof precedes production workflow work so feasibility failures can change the approved contract early. Publishing verified bytes first remains the chosen order; independent reproducibility and stronger builder isolation add distinct assurance later. +Composition tooling, cross-platform reproducibility and the provider lifecycle interface are the largest implementation costs. Keep the adapter limited to observed selection, metadata joins, relationships and validation; reuse Syft and maintained parsers rather than writing a new package scanner. Composition/replay fixture proofs precede production workflow work so feasibility failures can change the approved contract early. Recipe reproducibility complements provenance and verified deployment; independent witnesses and stronger builder isolation add further assurance later. ## Acceptance criteria @@ -665,6 +733,9 @@ The composition adapter and provider lifecycle interface are the largest impleme - [ ] Protected eligible `v*` tags publish the complete immutable artifact/SBOM/bundle/index/checksum distribution. - [ ] All executable artifacts pass provenance, SBOM and immutable-release asset verification with the documented policy. - [ ] Strict JS release builds reject missing tools, installation failure, skip flags and stale/incomplete output. +- [ ] Every initial executable passes separate clean A/B builds with exact complete-byte equality; any missing or mismatched target blocks publication. +- [ ] Authenticated recipes and retained inputs support demonstrated operator replay for all four targets without release credentials or compilation networking. +- [ ] Locally reconstructed wasm must match the official digest and pass ordinary evidence verification before deployment; version/source metadata alone never suffices. - [ ] SBOM fixtures prove target/build-host selection and embedded JS scope, including the external Prebid exclusion. - [ ] Both production and staging artifact deployment preserve verified wasm bytes and perform no provider writes on verification failure. - [ ] Read-only online export produces verified offline evidence; official offline deployment requires the previously verified publication record and its independently approved digest pin, works without verification network access, and documents its freshness limits. @@ -691,6 +762,10 @@ Authoritative implementation references: - [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/). - [IETF RATS architecture](https://www.rfc-editor.org/rfc/rfc9334.html). - [Reproducible build definition](https://reproducible-builds.org/docs/definition/). +- [Build-environment definition](https://reproducible-builds.org/docs/perimeter/) and [SOURCE_DATE_EPOCH](https://reproducible-builds.org/docs/source-date-epoch/). +- [Rust source-path remapping](https://doc.rust-lang.org/rustc/remap-source-paths.html), [Cargo vendoring](https://doc.rust-lang.org/cargo/commands/cargo-vendor.html), and [Apple target/SDK selection](https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html). +- [npm clean installation](https://docs.npmjs.com/cli/v11/commands/npm-ci/). +- [Apple build UUID guidance](https://developer.apple.com/documentation/technotes/tn3178-checking-for-and-resolving-build-uuid-problems) and [Apple linker options](https://github.com/apple-oss-distributions/ld64/blob/main/doc/man/man1/ld-classic.1). - [Cargo SBOM precursor](https://doc.rust-lang.org/cargo/reference/unstable.html#sbom) and [cargo-auditable's metadata/precursor selection](https://github.com/rust-secure-code/cargo-auditable/blob/master/cargo-auditable/src/collect_audit_data.rs). - [Syft package catalogers](https://oss.anchore.com/docs/guides/sbom/catalogers/) and [Rust capabilities](https://oss.anchore.com/docs/capabilities/rust/). - [Rollup output hooks](https://rollupjs.org/plugin-development/). From 712f355f7a518f34b9714e59cc94980969247ab6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 9 Oct 2026 12:29:48 +0530 Subject: [PATCH 004/153] Extend release attestation contracts to all server adapters --- ...6-10-09-1235-release-attestation-design.md | 222 +++++++++++++----- 1 file changed, 158 insertions(+), 64 deletions(-) diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 1fbc8f4481..aed8d77ec6 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -8,6 +8,8 @@ **Source baseline:** `da31a215e62a8201610de367ee06d572708952df`. +**Adapter coverage:** All four server adapters have defined contracts below. Phase 1 enables Fastly plus the operator CLI; Phase 2 extends the supported release set to Cloudflare, Spin and Axum after their explicit proof gates. Four Phase 1 assets are one Fastly wasm plus three CLI executables, not four server adapters. + **Related work:** [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#167](https://github.com/IABTechLab/trusted-server/pull/167), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), and [#1084](https://github.com/IABTechLab/trusted-server/pull/1084). ## Problem @@ -31,7 +33,8 @@ This design establishes a canonical release artifact per version, target, and ap 7. Make immutable build facts available to future #161 work, while keeping the final binary digest external. 8. Keep artifact identity, publisher identity, configuration identity, deployment observations, and runtime evidence distinct. 9. Preserve existing logical-store mapping and staged configuration isolation, with exact deployment outcomes usable by the separate health-check and rollback lifecycle commands. -10. Require byte-identical clean rebuilds of all initial executable artifacts, publish replayable recipes and inputs, and support operator reconstruction against the authenticated release digest. +10. Require byte-identical clean rebuilds of every selected canonical artifact, publish replayable recipes and inputs, and support operator reconstruction against the authenticated release digest. +11. Define the canonical payload, full build pipeline, reproducibility, verification, supported deployment/local-start operations and publisher handoffs for Fastly, Cloudflare, Spin and Axum; enable them through explicit release cohorts. ## Non-goals @@ -42,7 +45,8 @@ This design establishes a canonical release artifact per version, target, and ap - Vendor endorsement protocols or CODEOWNERS changes. - Signatures or the final artifact checksum embedded in a wasm custom section. - Requiring an external rebuild witness as a release prerequisite, or claiming that two project-controlled builds establish independent builder trust. -- Cloudflare, Spin, Axum, containers, Windows, Linux ARM64, universal macOS binaries, or standalone tsjs release distributions. +- Production container distributions, Windows, Linux ARM64, universal macOS binaries, or standalone tsjs release distributions. +- A generic cloud deployment system for Axum, production-readiness certification of the development server, or automatic support for every Spin deployment plugin. - Attesting the publisher-selected external Prebid.js bundle. - Making all CLI commands self-contained without their documented external tools or repository resources. - Apple Developer ID signing/notarization or automatic installer/self-update infrastructure. @@ -52,12 +56,14 @@ This design establishes a canonical release artifact per version, target, and ap | Question | Decision | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | First executable artifacts | Fastly `wasm32-wasip1`; CLI `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, and `x86_64-apple-darwin` | +| Full adapter scope | Phase 2 adds final Cloudflare Worker module distribution, validated Spin WASIp2 component and Axum executables for the three native CLI targets | +| Release cohorts | A reviewed source-controlled release manifest selects Phase 1 or the cumulative Phase 2 set before builds; every selected artifact is mandatory | | Fastly build variant | Existing production defaults; `reusable-sandbox` and test features are off | | Deployment strategy | Both reproducible executable production and verified artifact deployment; downloading verified release bytes remains the default operator route | -| Reproducibility gate | Two clean builds per initial target must match the entire distributed executable; missing results or mismatches block the complete release | +| Reproducibility gate | Two clean builds per selected artifact must match its complete canonical bytes; missing results or mismatches block the complete release cohort | | Integrated operator flow | Add an artifact deployment path to `ts deploy` through an additive EdgeZero interface; retain a documented native Fastly packing route during rollout | | SBOM | SPDX 2.3 JSON generated by pinned Syft plus a repository-owned composition step using the actual Cargo build graph and JS bundle evidence | -| SBOM distribution | One SBOM per executable, published as a release asset and embedded as that executable's SBOM attestation predicate | +| SBOM distribution | One SBOM per canonical artifact, published as a release asset and embedded as that artifact's SBOM attestation predicate | | Release trigger | Protected `v*` tag push, followed by strict stable/prerelease SemVer validation; no arbitrary-ref manual publishing | | Workflow boundary | `.github/workflows/release.yml` calls `.github/workflows/release-build.yml`; the reusable workflow builds and signs | | SLSA claim | Initially Build Level 2; using a reusable workflow does not by itself justify a Level 3 claim | @@ -71,18 +77,21 @@ This design establishes a canonical release artifact per version, target, and ap The baseline has two repository-owned build scripts: core and JS. EdgeZero is pinned to `683202c66948146ec360f490126f1d60f920288b`; its CLI build script generates adapter registrations from its manifest and Cargo features. It does not embed publisher settings. Trusted Server does not currently use EdgeZero's manifest-embedding `app!` macro. -| Surface | Baseline behavior | Design consequence | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| `crates/trusted-server-cli/src/run.rs` | Delegates build, deploy, and typed config push to EdgeZero | Artifact deployment must extend the owning interface rather than wrap an unconditional build | -| `edgezero.toml` | Fastly production deploy selects `fastly compute publish` | Verified artifact deployment bypasses source-build manifest commands | -| EdgeZero Fastly staging | Builds, clones/updates a service version, redirects its configuration selector, and stages it | Verification must precede the first provider write; staging must accept a prebuilt package | -| `crates/trusted-server-core/src/config_payload.rs` | Verifies a blob envelope, resolves secrets, and returns validated `Settings` | Existing configuration separation is retained | -| `crates/trusted-server-core/build.rs` | Extracts the default logical config-store ID and hashes core inputs into `TEMPLATE_BUILD_DIGEST` | Release builds use the repository manifest unchanged; the template digest is not artifact identity | -| `crates/trusted-server-js/build.rs` | Builds and embeds discovered `tsjs-*.js`, with several fallback paths | A strict release mode must reject fallback and incomplete module inventories | -| `crates/trusted-server-js/lib/build-all.mjs` | Vite/Rollup IIFEs, esbuild minification, cleaned `dist` | Capture package contribution evidence from the actual bundle build | -| `crates/trusted-server-cli/src/prebid_bundle.rs` | Locates JS resources in a repository and has a compiled checkout-path fallback | CLI distribution must document source-resource dependencies and record this compile-time input | -| `.github/workflows/test.yml` | Builds/tests adapters and native CLI without release publication | Add a dedicated release workflow; do not turn ordinary PR CI into a signing authority | -| `crates/trusted-server-core/src/request_signing/` | Deployment Ed25519 keys, JWKS discovery, request signatures | These keys and signatures remain separate from release provenance | +| Surface | Baseline behavior | Design consequence | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-cli/src/run.rs` | Delegates build, deploy, and typed config push to EdgeZero | Artifact deployment must extend the owning interface rather than wrap an unconditional build | +| `edgezero.toml` | Fastly production deploy selects `fastly compute publish` | Verified artifact deployment bypasses source-build manifest commands | +| EdgeZero Fastly staging | Builds, clones/updates a service version, redirects its configuration selector, and stages it | Verification must precede the first provider write; staging must accept a prebuilt package | +| `crates/trusted-server-core/src/config_payload.rs` | Verifies a blob envelope, resolves secrets, and returns validated `Settings` | Existing configuration separation is retained | +| `crates/trusted-server-core/build.rs` | Extracts the default logical config-store ID and hashes core inputs into `TEMPLATE_BUILD_DIGEST` | Release builds use the repository manifest unchanged; the template digest is not artifact identity | +| `crates/trusted-server-js/build.rs` | Builds and embeds discovered `tsjs-*.js`, with several fallback paths | A strict release mode must reject fallback and incomplete module inventories | +| `crates/trusted-server-js/lib/build-all.mjs` | Vite/Rollup IIFEs, esbuild minification, cleaned `dist` | Capture package contribution evidence from the actual bundle build | +| `crates/trusted-server-cli/src/prebid_bundle.rs` | Locates JS resources in a repository and has a compiled checkout-path fallback | CLI distribution must document source-resource dependencies and record this compile-time input | +| `.github/workflows/test.yml` | Builds/tests adapters and native CLI without release publication | Add a dedicated release workflow; do not turn ordinary PR CI into a signing authority | +| `crates/trusted-server-core/src/request_signing/` | Deployment Ed25519 keys, JWKS discovery, request signatures | These keys and signatures remain separate from release provenance | +| Cloudflare `build.sh` / `wrangler.toml` | worker-build generates JS/WASM; local build script loads operator environment and installs tools; Wrangler can execute a custom build | Release recipes bypass this developer launcher, pin the complete transformation chain and submit prepared modules | +| Spin manifest versus pinned SDK/EdgeZero | Repository source/build uses WASIp1; pinned EdgeZero uses WASIp2 and locked SDK expects its component toolchain | Align target/SDK/runtime/manifest/CI before Spin release enablement; never relabel an old core module as a component | +| Axum entry point and EdgeZero serve | Native development server loads config at startup; source serve invokes Cargo and remote deploy is unsupported | Publish independently attested native executables and add direct verified local launch, preserving its development role | ## Design @@ -168,11 +177,11 @@ The recipe sets or rejects compiler/tool overrides including `RUSTC`, `RUSTDOC`, No wall-clock build timestamp, workflow run ID, publisher identity, or final artifact checksum is embedded. Operational timestamps and run identifiers remain external evidence. Set `SOURCE_DATE_EPOCH` to the peeled source commit's committer timestamp and record it. Audit tools that do not honor this variable; setting it is not a blanket determinism guarantee. -#### Reproducible executable contract +#### Reproducible artifact contract Issue #1235 permits verified release deployment, reproducible local builds, or both in a stated order. This specification selects both. Establish the executable recipes and reproducibility fixtures before release publication, then retain the verified deployment path so operators can use canonical bytes without rebuilding. Nothing in the baseline proves reproducibility yet; the following requirements must be demonstrated during implementation. -Reproducibility means that the specified source, environment and instructions reconstruct the **complete distributed executable bytes**, separately for each target/variant. It does not mean different target binaries match, or that an arbitrary ambient host can run bare Cargo and obtain the same result. The contract covers embedded JS and every final executable transformation, including any required macOS ad-hoc signature. It does not require byte-identical Sigstore bundles/certificates, transparency entries, run timestamps, logs, SBOM generation times, every intermediate, or publisher-specific Fastly archives. +Reproducibility means that the specified source, environment and instructions reconstruct the **complete canonical artifact bytes**, separately for each target/variant. Phase 1 artifacts are individual executables; Phase 2 includes Cloudflare's deterministic module archive and its exact member bytes. It does not mean different target binaries match, or that an arbitrary ambient host can run bare Cargo and obtain the same result. The contract covers embedded JS and every final executable transformation, including any required macOS ad-hoc signature. It does not require byte-identical Sigstore bundles/certificates, transparency entries, run timestamps, logs, SBOM generation times, every intermediate, or publisher-specific provider archives/envelopes. Each artifact has a tracked recipe implementation and a version-1 `.build-recipe.json` descriptor, authenticated through provenance and the release index. The descriptor contains: @@ -184,7 +193,7 @@ Each artifact has a tracked recipe implementation and a version-1 `.build Descriptor version and fields are validated against the reviewed recipe; a downloadable descriptor cannot substitute arbitrary commands or tools. Operator replay uses the tracked reproduction script from the approved source revision. Environment and dependency inputs must be retrievable and integrity-verifiable without release-workflow credentials. Missing pins, unavailable required inputs or an unproven target block publication; they are not silently waived. Implementation selects actual tool/SDK/image pins through the reviewed release-tools manifest and proves the complete target matrix before the first tag. -**Environment and dependency reconstruction.** Fastly and Linux CLI builds run in an OCI builder image pinned by manifest digest and explicit `linux/amd64` platform on GitHub-hosted Linux runners. Retain the image and its reviewed bootstrap definition; builder-image construction pins base images and fetched tool/native inputs, rather than resolving floating OS packages. The image is a build environment, not a new Trusted Server container distribution. Document image retrieval/export and digest checking for operator replay. +**Environment and dependency reconstruction.** All Linux-built artifacts use an OCI builder image pinned by manifest digest and explicit `linux/amd64` platform on GitHub-hosted Linux runners. Each recipe identifies its required image and tools; adapters need not share an image. Retain the images and their reviewed bootstrap definitions; builder-image construction pins base images and fetched tool/native inputs, rather than resolving floating OS packages. These images are build environments, not new Trusted Server container distributions. Document image retrieval/export and digest checking for operator replay. macOS recipes use native Apple tooling on the matching architecture, with explicit Xcode/CLT build, SDK identity/content digest, OS compatibility requirements, `SDKROOT`, deployment target and native tool identities. Validate required identities before compilation and record the actual runner OS/image. A hosted runner label is a scheduler selection, not an immutable environment pin. Provide a tested bootstrap on a separate compatible Mac; any OS/tool input that affects bytes becomes an explicit checked recipe input. A missing SDK/tool pin fails rather than falling back to whatever Xcode is active. Operators obtain platform SDKs through their supported distribution; this design does not promise SDK redistribution inside a public source archive. @@ -196,9 +205,9 @@ Both release builds and operator replay compare the archive's tracked source byt Linux selects a deterministic content-derived linker build-ID policy. macOS controls linker OSO debug paths and modification times, validates the selected linker's reproducible mode/epoch behavior, and preserves a tested content-derived UUID policy. Do not introduce random UUIDs or indiscriminately disable UUID generation. Any required linker ad-hoc signing has recorded deterministic settings and is included in the final comparison; removing a signature only for comparison is forbidden. Developer ID signing, trusted timestamps, notarization and stapling remain outside the first distribution. Rust remapping alone does not normalize native linker output or arbitrary generated source. -**Release gate.** For every one of the four initial targets, Build A and Build B execute the complete recipe in separate fresh hosted jobs. Each verifies/materializes its own source and dependencies, owns new Cargo/npm/JS output directories, and consumes no executable, compiled cache, generated JS or generated code from the other build. They use the same checked recipe/environment inputs. The trusted collection job checks current-run artifact identities, source/recipe/environment consistency, embedded JS/module digests, final sizes and SHA-256 values, then byte-compares the two complete executables. No normalization or excluded byte regions are allowed at comparison time. A missing result, environment mismatch, input mutation or unequal bytes stops signing/publication of the complete release. +**Release gate.** For every artifact in the selected cohort, Build A and Build B execute the complete recipe in separate fresh hosted jobs. Each verifies/materializes its own source and dependencies, owns new Cargo/npm/JS output directories, and consumes no executable, compiled cache, generated JS or generated code from the other build. They use the same checked recipe/environment inputs. The trusted collection job checks current-run artifact identities, source/recipe/environment consistency, embedded JS/module digests, final sizes and SHA-256 values, then byte-compares the complete canonical artifacts. Cloudflare compares both the final archive and every declared module/inventory member; Spin compares the final validated component, not an intermediate. No normalization or excluded byte regions are allowed at comparison time. A missing result, environment mismatch, input mutation or unequal bytes stops signing/publication of the complete release. -After successful comparison, choose Build A's executable as the canonical asset and attest it with its SBOM, build evidence, recipe and `.rebuild-check.json` as explicit provenance subjects. The version-1 rebuild record binds the source, recipe/input digests, observed environments, both build-job/artifact identities, both final lengths/digests, module inventory and comparison outcome. Workflow topology and current-run artifacts establish that the jobs were separate; an arbitrary predicate field asserting independence is insufficient. These project-controlled executions establish measured recipe repeatability. They do not establish independent witness trust, prevent a compromised common compiler from producing the same malicious output, or imply SLSA Build Level 3. +After successful comparison, choose Build A's artifact as the canonical asset and attest it with its SBOM, build evidence, recipe and `.rebuild-check.json` as explicit provenance subjects. The version-1 rebuild record binds the source, recipe/input digests, observed environments, both build-job/artifact identities, both final lengths/digests, module inventory and comparison outcome. Workflow topology and current-run artifacts establish that the jobs were separate; an arbitrary predicate field asserting independence is insufficient. These project-controlled executions establish measured recipe repeatability. They do not establish independent witness trust, prevent a compromised common compiler from producing the same malicious output, or imply SLSA Build Level 3. **Operator replay.** Verify the release/index, executable, recipe and input identities before executing the recipe. The tracked `scripts/release/reproduce.sh` validates the approved descriptor, bootstraps/checks its target environment, extracts only authenticated inputs into a fresh workspace, builds without compilation network access and emits the final executable. It never downloads Build A's executable as a build input. A proposed invocation after obtaining the approved source is: @@ -213,10 +222,12 @@ cmp ./release-evidence/trusted-server-fastly.wasm \ ./rebuild-output/trusted-server-fastly.wasm ``` -This script is new implementation work. The same interface supports each named native CLI asset under its declared platform recipe. A matching locally reconstructed wasm can use the existing `--release --artifact` verification path: its digest is the attested release subject, so ordinary online/offline provenance, SBOM and publication checks still apply. Matching bytes do not create new GitHub provenance, and successful compilation without digest equality is not verification. A mismatch reports failure and preserves diagnostic output; pinned `diffoscope` or equivalent format-aware diagnostics may explain it but cannot convert it into success. +This script is new implementation work. The same interface supports every selected artifact under its declared platform recipe, including Cloudflare archive/member comparison and final Spin component validation. A matching locally reconstructed artifact can use the proposed `--release --artifact` consumption path for its adapter and permitted operation: its digest is the attested release subject, so ordinary online/offline provenance, SBOM and publication checks still apply. Matching bytes do not create new GitHub provenance, and successful compilation without digest equality is not verification. A mismatch reports failure and preserves diagnostic output; pinned `diffoscope` or equivalent format-aware diagnostics may explain it but cannot convert it into success. ### 4. Artifact matrix and distribution +This is the Phase 1 matrix. Section 12 defines the five additions and proof gates for the cumulative Phase 2 matrix. + | Asset name | Cargo package and target | Hosted runner | Compatibility | | ----------------------------- | ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------- | | `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly`, `wasm32-wasip1` | `ubuntu-24.04` | Fastly production default feature set | @@ -228,22 +239,22 @@ Runner labels fix an OS generation, not an immutable VM image. Capture the actua Use explicit Cargo packages/targets with `--release --frozen` after the authenticated input-preparation step; this includes locked resolution. Fastly does not enable `reusable-sandbox`; production build commands do not use `--all-features`. CLI uses its existing default feature graph, including EdgeZero's default adapter registrations. Do not remove dependencies merely because they appear unrelated at the workspace level. -Ship raw executables to avoid an archive/extracted-executable identity ambiguity. Downloads may need their executable permission restored after verification. Any linker signing, stripping, or other transformation happens before checksums and attestation. No operation may modify an attested executable afterward. +Phase 1 ships raw executables to avoid an archive/extracted-executable identity ambiguity. Phase 2 retains raw Spin/Axum subjects and defines Cloudflare's deterministic archive plus authenticated member inventory explicitly in §12. Downloads may need their executable permission restored after verification. Any linker signing, stripping, or other transformation happens before checksums and attestation. No operation may modify an attested executable afterward. -Each executable has named companions: +Each canonical artifact has named companions: - `.spdx.json` — its artifact-specific SBOM. - `.provenance.sigstore.json` — the generated build-provenance bundle. - `.sbom.sigstore.json` — the generated SBOM attestation bundle. - `.build-evidence.json` — selected build inputs, composition roles, and embedded module inventory. - `.build-recipe.json` — the authenticated versioned recipe and reconstruction inputs. -- `.rebuild-check.json` — observed clean-build identities and exact executable comparison. +- `.rebuild-check.json` — observed clean-build identities and exact canonical-artifact comparison. Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, `release-inputs.tar.zst`, and `SHA256SUMS`. The index records the input archive's name/size/digest; its provenance also names that archive as a subject. The checksum file covers every other published asset and never itself. The release index excludes its own digest and its own attestation bundle; its signed identity is external, so there is no checksum cycle. -The release index is a versioned distribution index, not a publisher-attestation schema. Version 1 records repository, tag, source commit, recipe revision, and an exact artifact list. Each artifact entry records name, byte length, SHA-256, package, target, variant, compatibility baseline, and companion asset names/digests. Consumers reject duplicate names, unknown required schema versions, traversal paths, and conflicting identities. +The release index is a versioned distribution index, not a publisher-attestation schema. Version 1 records repository, tag, source commit, recipe revision, reviewed release-manifest revision, selected cohort and an exact artifact list. Each artifact entry records name, byte length, SHA-256, adapter/product role, canonical kind, package, target, variant, compatibility/runtime contract, allowed consumption capabilities, any authenticated member inventory and companion asset names/digests. Consumers validate these against their reviewed policy and reject duplicate names, unknown required schema versions or kinds, traversal paths and conflicting identities. A target triple alone cannot distinguish a core wasm module from a component or a processed Worker distribution. -Each artifact's provenance covers its binary, SBOM file, build-evidence file, recipe and rebuild-check file as explicit subjects after the dual-build gate passes. The separate SBOM invocation covers the binary as subject and the SPDX document as predicate. The release index and input archive receive provenance after the matrix artifacts and bundles are collected. Index assembly verifies that all entries came from the expected source/ref and complete matrix. +Each artifact's provenance covers its canonical distribution, SBOM file, build-evidence file, recipe and rebuild-check file as explicit subjects after the dual-build gate passes. The separate SBOM invocation covers that canonical artifact as subject and the SPDX document as predicate. The release index and input archive receive provenance after the matrix artifacts and bundles are collected. Index assembly verifies that all entries came from the expected source/ref and complete matrix. `ts --version` identifies the release version and source revision. The existing Cargo package version is retained separately; the release process does not edit `Cargo.toml` to make an existing workspace version equal the tag. @@ -310,7 +321,7 @@ A fixture proof is an implementation prerequisite: target-specific Cargo selecti Eligibility uses reviewed workflow-file identities, not arbitrary successful check names. Require completed `success` runs of `test.yml` and `format.yml` for the exact peeled commit in this repository, with `push` to `main` as the accepted event/ref. Clippy is part of `format.yml` at the baseline. Enumerate the required non-optional jobs in the recipe; skipped, cancelled, unrelated, fork, PR-only, stale, or still-running results do not satisfy the gate. The recipe must account for every AGENTS.md CI requirement. Gates whose workflow is path-filtered run as explicit blocking release-validation jobs when there is no eligible exact-commit result. A tag on an older reviewed commit is eligible only if that commit's required checks can still be established or rerun through the trusted validation path. -The workflow invokes local `.github/workflows/release-build.yml` at the same source revision. The reusable workflow owns input preparation, the fixed artifact matrix with separate A/B build jobs, comparison and attestation jobs, and its own index collection/signing job. Signing jobs consume validated current-run outputs only after every required reproducibility comparison succeeds; build/lifecycle-script execution receives no signing permissions. The caller publication job does not sign the index. The reusable workflow does not accept arbitrary shell commands, script paths, artifact paths, repository overrides, or caller-generated provenance predicates. +The workflow invokes local `.github/workflows/release-build.yml` at the same source revision. The reusable workflow owns input preparation, the fixed reviewed matrix for the selected cohort with separate A/B build jobs, comparison and attestation jobs, and its own index collection/signing job. Signing jobs consume validated current-run outputs only after every required reproducibility comparison succeeds; build/lifecycle-script execution receives no signing permissions. The caller publication job does not sign the index. The reusable workflow does not accept arbitrary shell commands, script paths, artifact paths, repository overrides, or caller-generated provenance predicates. The initial supported assurance is SLSA Build Level 2. Level 3 requires a separately reviewed isolation design in which build execution cannot subvert provenance generation; moving steps into a reusable workflow alone does not satisfy that requirement. The early reusable boundary keeps that upgrade separate from runtime and publisher interfaces. @@ -327,7 +338,7 @@ All external actions are pinned by full commit SHA with a readable version comme Declare read-only defaults and grant writes per job. Reusable-workflow callers grant only the permissions required by the called jobs. No platform credentials, operator configuration, or publisher secrets enter the release workflow. -For each artifact, invoke SHA-pinned `actions/attest` twice after the comparison gate: once for SLSA provenance with explicit binary/SBOM/build-evidence/recipe/rebuild-check subjects, and once with the binary subject plus `sbom-path`. Supplying `sbom-path` selects SBOM mode; it does not also generate the separate provenance claim. Keep the returned Sigstore bundles as named release assets. +For each artifact, invoke SHA-pinned `actions/attest` twice after the comparison gate: once for SLSA provenance with explicit canonical-artifact/SBOM/build-evidence/recipe/rebuild-check subjects, and once with the canonical artifact subject plus `sbom-path`. Supplying `sbom-path` selects SBOM mode; it does not also generate the separate provenance claim. Keep the returned Sigstore bundles as named release assets. Before publication, the collection job verifies all provenance/SBOM bundles with the expected reusable signer, source ref and commit, and hosted-runner policy. It verifies that each signed SPDX predicate corresponds to the published SBOM's parsed document, that all artifact/build-evidence/SBOM/recipe/rebuild subject digests match, and that the full matrix has successful consistent rebuild records. It then creates and attests the release index and retained input archive. @@ -343,7 +354,7 @@ Administrative prerequisites are part of rollout: immutable releases enabled; ac The trusted source repository is `IABTechLab/trusted-server`. The signer is `IABTechLab/trusted-server/.github/workflows/release-build.yml`, because the reusable workflow contains the attestation action. The source ref is the exact selected `refs/tags/` and the source digest is its reviewed peeled commit. The expected GitHub OIDC issuer is `https://token.actions.githubusercontent.com`. -The CLI's release policy fixes GitHub host `github.com`, repository, signer, issuer, supported targets/variants, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. The checked subprocess uses explicit host/repository selection, fixed verifier arguments, and an approved GitHub CLI executable/version; it clears unapproved host/configuration overrides and accepts no forwarded verifier arguments. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. +The CLI's release policy fixes GitHub host `github.com`, repository, signer, issuer, supported adapter/product roles, artifact kinds, targets/variants, runtime contracts, consumption capabilities, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. The checked subprocess uses explicit host/repository selection, fixed verifier arguments, and an approved GitHub CLI executable/version; it clears unapproved host/configuration overrides and accepts no forwarded verifier arguments. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. The release index is authenticated before its target/variant/digest fields are trusted. Online consumers validate immutable release membership, expected tag/source identity, index provenance, artifact provenance, the artifact's SPDX attestation, and authenticated consistent recipe/rebuild records. The project workflow's rebuild record is observed comparison evidence, not an independent witness endorsement. Operator reconstruction additionally verifies the retained input archive before extraction. `SHA256SUMS` is a convenience check only until authenticated through the release or independently signed evidence. @@ -498,7 +509,7 @@ A local build following the published reproduction recipe is eligible only when #### Interface ownership -Trusted Server owns official release acquisition/verification and the `VerifiedArtifact` value. Its fields are private; callers cannot construct one by supplying a path and an asserted checksum. It owns retained bytes, SHA-256, validated release/source identity, target/variant, verification policy version, and evidence references. Provider code cannot replace its bytes through passthrough arguments. +Trusted Server owns official release acquisition/verification and the `VerifiedArtifact` value. Its fields are private; callers cannot construct one by supplying a path and an asserted checksum. It owns retained bytes, SHA-256, validated release/source identity, adapter/product role, canonical kind, target/variant, runtime contract, validated member inventory where applicable, verification policy version and evidence references. Consumption requires a compatible adapter/action; a CLI executable cannot be passed as an Axum server merely because its native triple matches. Provider code cannot replace its bytes through passthrough arguments. EdgeZero owns an additive typed artifact deployment request/result and platform lifecycle. Its request accepts an explicitly prepared package and expected digest with adapter/service/staging parameters and validated deployment context: selected platform manifest, declared logical config-store IDs, physical resource mappings, and any allowed version comment. Trusted Server checks that the context includes the configuration logical ID expected by the canonical artifact before mutation. An absent config declaration must not take EdgeZero's generic no-config staging branch and silently retain the production selector. The package and lifecycle use the same resolved context; manifest shell overrides cannot replace it. @@ -555,15 +566,15 @@ A wasm cannot hash its own deployed bytes through the current platform abstracti A requested receipt is a versioned local JSON observation record, not a publisher signature or a platform attestation. Version 1 contains: -- Release repository/tag/source commit, artifact target/variant, and raw module SHA-256. +- Release repository/tag/source commit, adapter/product role, canonical artifact kind/target/variant and SHA-256; final module/member digests where the kind requires them. - Verification policy version, online/offline profile, and evidence bundle digests. -- Package SHA-256 and the observed package member digest. +- Prepared deployment-envelope/package SHA-256 and observed canonical member digests when packaging occurs; these fields are explicitly inapplicable for direct native launch. - CLI and platform tool versions. -- Adapter, observed service/version, staging/production mode, activation/staging outcome, and observation timestamp. +- A tagged adapter observation: Fastly service/version; Cloudflare account/Worker/environment, version ID and deployment ID; Spin local application/process or OCI repository/manifest digest and actual provider revision only when observed; Axum local process/listener. Record the operation/mode, known/unknown phase outcomes and observation timestamp without fabricating provider identifiers. - Health status `not_performed` for a deployment-only operation; a later explicit check can produce its own observed result. Unknown provider state remains unknown, never inferred from command failure. - Previous deployment version when actually captured. -It contains no tokens, resolved secrets, complete publisher manifest, configuration document, or claimed publisher authorization. It does not assert a config digest unless the deployment operation actually observed and verified one; config push is a separate operation. Exact service/version comes from deployment responses, not a racy post-deploy lookup of whatever version is now active. +It contains no tokens, resolved secrets, complete publisher manifest, configuration document, or claimed publisher authorization. It does not assert a config digest unless the deployment operation actually observed and verified one; config push is a separate operation. Exact provider identifiers come from the corresponding operation responses, not a racy lookup of whatever revision is now active. Local-launch observations distinguish spawn, readiness, exit and signal outcomes; a PID/listener is neither a provider deployment revision nor remote execution proof. Store receipts with restricted local permissions and explicit caller-selected paths. A future publisher signer can reference the record's defined bytes/digest, but #161 must define its authority, validation and freshness policy. A local receipt alone is not independent evidence against the operator producing it. @@ -573,7 +584,7 @@ Preserve the following distinctions when #161 adds its implementation: | Identity | Defined meaning | Future ownership | | --------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- | -| Artifact digest | SHA-256 of the exact distributed executable | Release/deployment tooling | +| Artifact digest | SHA-256 of the exact canonical distribution; module identities recorded separately | Release/deployment tooling | | Raw configuration-document digest | Exact serialized bytes, where a protocol explicitly selects those bytes | Config-attestation design | | Canonical unresolved-data digest | Existing versioned EdgeZero envelope data hash, including secret references | Config loader plus future snapshot metadata | | Effective settings identity | A future explicit projection of defaults, policy, selections and secret-version treatment | #161 and permission design | @@ -608,17 +619,80 @@ No generic attestation framework, arbitrary predicate registry, publisher endpoi Future designs may revise these integration seams through reviewed versions. No assumption here requires a central publisher registry, reuse of deployment request-signing keys, a particular hosting provider, or a hardware attestation mechanism. -### 12. Other adapter and asset boundaries +### 12. All-adapter artifact and execution contracts + +The following contracts are part of this design, not unspecified future artifact definitions. Their rollout is gated by adapter-specific implementation evidence. The trust policy, strict JS mode, no-operator-configuration requirement, recipe/input authentication, A/B comparisons, provenance/SBOM/index checks and online/offline publication policy apply to every enabled artifact. An adapter does not inherit another adapter's digest, SBOM, compatibility result or reproducibility proof. + +#### Release cohorts and full matrix + +A tracked, reviewed release manifest selects the cumulative cohort before tag execution. Phase 1 has the four assets in §4. Phase 2 adds five assets below, making nine canonical distributions across all four server adapters plus the CLI. The manifest records supported adapter, artifact kind, role, target/variant, recipe and operation capabilities; the authenticated release index includes its revision and selected cohort. Matrix assembly and verification use an exact expected set. A failed/missing selected artifact blocks the entire release; the workflow cannot downgrade the cohort after a failure. Historical Phase 1 releases do not acquire Phase 2 claims retroactively. An older verifier rejects unsupported kinds/capabilities rather than weakening checks. + +| Phase 2 asset | Canonical kind / package | Target / builder | Supported initial consumption | +| ---------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `trusted-server-cloudflare.tar.zst` | Deterministic final Worker module set / `trusted-server-adapter-cloudflare` | `wasm32-unknown-unknown`, feature `cloudflare`; pinned Linux image | Prepared Worker version upload and explicit activation | +| `trusted-server-spin.wasm` | Validated final Wasm component / `trusted-server-adapter-spin` | `wasm32-wasip2`, feature `spin`; pinned Linux image | Verified local Spin execution and verified OCI publication; remote activation only for an explicitly supported provider | +| `trusted-server-axum-x86_64-unknown-linux-gnu` | Native executable / `trusted-server-adapter-axum`, bin `trusted-server-axum` | Linux x86-64 recipe and baseline from §4 | Verified local development-server launch | +| `trusted-server-axum-aarch64-apple-darwin` | Same native executable | macOS ARM64 recipe and baseline from §4 | Verified local development-server launch | +| `trusted-server-axum-x86_64-apple-darwin` | Same native executable | macOS Intel recipe and baseline from §4 | Verified local development-server launch | + +Each receives its own standard companions from §4 and inclusion in the cumulative index/input inventory. Axum shares environment pins with the CLI, not its composition results. Cloudflare's archive itself is the attested SBOM/provenance subject, with final executable members explicitly identified and related inside the SPDX document. Recipes and rebuild records compare the complete archive and each member. Spin's subject is the final component actually submitted, including any approved release-time adaptation; raw precursor output is build material. + +This phase selection distinguishes defined architectural scope from shipping prerequisites. Phase 1 can ship before Phase 2, while Phase 2 cannot be advertised as supported merely because a matrix row exists. Phase 2 enablement requires all five additions, all shared gates and the operation-specific proofs below. A different phased split requires a reviewed manifest/spec change. + +#### Cloudflare: final module set and prepared API submission + +**Build and identity.** Release tooling bypasses the developer `build.sh`, which sources local environment files and installs tools. Run the locked SDK transformation pipeline in the controlled Linux recipe: Cargo output, matching worker-build/wasm-bindgen, SDK glue/metadata, wasm optimization, shim handling, bundling/minification and import rewriting. At the baseline, `worker` is 0.8.5 and wasm-bindgen is 0.2.126; actual release tool pins/digests come from the selected lockfile and reviewed tools manifest. Record worker-build's internal binaries and matching versions rather than assuming Cargo's toolchain covers them. Prohibit undeclared shim, optimizer/bundler, minification, execution-model and panic/nightly overrides. Prepare all required tools before the network-denied build phase; runtime tool downloads fail. + +The canonical archive contains `modules.json` and the final JS/WASM executable files. Inventory version 1 records the main module, each archive path/provider module name/media type/length/SHA-256, declared platform-provided imports and a runtime compatibility contract. Preserve names/import relationships produced by the approved recipe; do not hardcode an assumed output layout instead of validating it. Reject undeclared executable members, duplicate names, links, path traversal and missing imports. Archive order, modes, ownership, timestamps and compression are normalized by the pinned recipe. No provider account, Worker name, physical KV ID, route, secret or active app configuration is included. + +Compatibility date/flags are source-selected runtime requirements, separate from physical deployment bindings. The baseline requires `2024-09-23`, `nodejs_compat` and `cache_option_enabled`; the recipe inventory declares its supported settings and deployment rejects conflicting overrides. Changing them requires a reviewed compatibility contract, even where executable bytes remain identical. The SBOM covers final Rust/WASM, generated SDK/wasm-bindgen glue, shim/snippet or polyfill contributors, and embedded Trusted Server JS. Transformation tools retain build roles; a raw Cargo-wasm inventory alone is insufficient. + +**Verified submission.** Trusted Server verifies the archive and each retained member before constructing a `PreparedCloudflareModules` request. EdgeZero uses the established script version-upload API: `POST /accounts/{account_id}/workers/scripts/{script_name}/versions`, with explicit multipart module parts and metadata. Validate the resolved account/Worker/environment, main-module name, bindings and compatibility settings; compare actual outgoing executable parts' names/types/digests to the authenticated inventory before sending. Multipart boundaries and publisher binding metadata are deployment-envelope data, not canonical code identity. Explicit binding inheritance must fail when unresolved, rather than silently adopting missing/unknown bindings. Existing configuration push remains a separate authorized operation. + +Upload success records the returned Worker version ID. Activation is a separate request to the deployments API referencing that exact version with an explicit traffic allocation; record the returned deployment ID independently. Initial production mode selects that version at 100 percent. Do not infer activation from upload success or a later lookup of whichever version is active. Routes/domains/triggers require their own validated operation; this artifact path does not silently provision them. Reject Fastly-style `--staging` unless a separately implemented Cloudflare mode has a defined isolation contract. Serialize writes for the same Worker/environment and preserve previous deployment observations for explicit recovery. + +The selected route bypasses Wrangler build/discovery/bundling. `--no-bundle` alone does not prevent a Wrangler custom build. A later Wrangler transport may be added only after pinned-tool tests prove isolated configuration without build hooks and exact outgoing module equality; it cannot replace the chosen route based on dry-run success alone. The Beta Worker-ID API is not silently substituted for the script-name API. Receipt fields describe submitted module hashes, account/Worker/environment, observed version/deployment IDs and known/pending/unknown activation state, not independent remote execution proof. + +#### Spin: component ABI, local execution and OCI transport + +**Build migration and identity.** Phase 2 selects `wasm32-wasip2` with feature `spin` and a validated component subject. The current WASIp1 manifest/commands must be migrated together with `rust-toolchain.toml`, Cargo aliases, CI, operator examples and affected AGENTS.md commands. Align the locked Spin SDK, imports/exports/world, target and a pinned compatible Spin runtime before enablement. Pinned EdgeZero already uses WASIp2 while repository overrides use WASIp1; reconcile that owning build interface rather than relying on override precedence. Old local core-module output is not evidence that the new component contract passes. -| Surface | Follow-up artifact definition | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -| Cloudflare | Final worker-build JS/WASM asset set and Wrangler bundling semantics, with build hooks bypassed during verified deployment | -| Spin | Deployable component and any package/OCI transformations, variables/bindings, and a validated upload path | -| Axum | Native executable or container distribution and an explicit deployment mechanism | -| Standalone tsjs | Individual bundle subjects, module inventory and composition evidence; runtime concatenation remains separately identifiable | -| External Prebid.js | Publisher-selected bundle digest/SRI and future configuration/deployment binding | +Each clean build validates Wasm component format and the expected SDK ABI, then tests it in the pinned runtime with the actual Trusted Server configuration/KV/secrets and a meaningful publisher request. Any componentization, adaptation, dependency composition or optimization needed before submission is an explicit release recipe step performed inside both clean builds; compare and attest the final bytes. Added component dependencies or a changed ABI require a reviewed variant, new SBOM and recipe. Do not invent a generic conversion command for incompatible SDK/target output. The SBOM includes actual linked/adapted components and embedded JS, with compiler/adapter tools separate. -Each future artifact gets its own digest, target/variant recipe, SBOM and byte-preserving deployment contract. It does not inherit assurance solely because another adapter from the same source release was attested. Embedded tsjs is covered by its containing binary's identity; enabled/deferred module selection remains runtime behavior. +**Verified local execution.** Add `ts serve --adapter spin --release ` through the shared verifier and typed local-run context. Generate a private Spin application manifest selecting the retained verified component under the intended component ID, with explicit runtime variables, KV stores and outbound permissions. Omit build sections; prohibit watch/build flags and sanitize inherited `SPIN_ALWAYS_BUILD` and plugin selectors. Pin and directly invoke Spin with this prepared manifest. Keep operator configuration/secrets/permissions outside canonical component bytes; do not use bare-Wasm launch where it loses the required app context. Record child lifecycle and runtime readiness separately. Spin's reserved host health route is insufficient proof that Trusted Server loaded valid settings; require an application request fixture for adapter acceptance. + +**OCI publication and optional remote activation.** Prepare a Spin-compatible OCI layout locally from the retained component and a separately validated publishable app envelope using pinned tooling. The private local-run context is not directly publishable: allow unresolved secret references and runtime binding declarations, but reject resolved secret defaults/environment, active app-config documents, KV/SQLite snapshots and mounted configuration/secret files throughout the OCI config, layers, annotations and manifests. Provision those values separately through the selected runtime/provider context. Validate the entire descriptor graph with bounded parsing/extraction and reject undeclared data layers as well as executable members; no secret scan alone substitutes for this allowlist. + +Inspect all descriptors and the selected component's decompressed layer bytes before registry writes; reject undeclared dependency composition, extra executable layers or altered component bytes. Publish explicit content-addressed descriptors, capture the resulting manifest digest, and verify retrieved layer content against the canonical component before any provider activation. An OCI tag is a lookup convenience, not an immutable identity; deployment uses the captured manifest digest. Registry/app-manifest digests differ from the component digest because deployment context varies. + +`spin registry push` can build through an environment setting and can compose dependencies; omission of `--build` is not sufficient proof. The EdgeZero transport must preserve the prepared-layout contract, whether it reuses a validated Spin library or a proven pinned CLI packaging/export path. Unknown transformations reject the operation. + +`spin deploy` dispatches to a plugin; it is not a universal cloud API. Remote support requires an explicit provider endpoint, pinned plugin/tool identity, credential/context policy and capability declaration for upload, activation, stage, inspection and rollback. A supported provider must return its actual app/resource/revision identity with known/unknown phase outcomes. Unselected plugins and unsupported modes fail before registry/provider writes; never silently switch providers or activate production for a staging request. Phase 2 requires verified local run and OCI publication. Remote provider activation is a separately enabled capability with its own tests, not a prerequisite that invents a generic provider contract. + +#### Axum: native development distribution and verified local launch + +Build the named `trusted-server-axum` binary using explicit package/bin/target, production profile and `--frozen`, with strict fresh JS. Each native target independently satisfies the §3 path/time/native signature controls, A/B gate, SBOM composition and clean-runtime smoke tests. The release profile does not turn the existing development server into a supported production hosting product. Remote orchestration/container delivery remains separate. + +Add `ts serve --adapter axum --release --port ` with a Trusted Server wrapper around the current serve arguments and the shared evidence/receipt inputs. Select only an Axum executable matching the host architecture and compatibility baseline; an operator CLI executable is not an Axum payload. Verify before any child process start, retain the file privately, restore executable permission and recheck its bytes before direct execution. Bypass Cargo and manifest shell hooks. Reject inherited dynamic-loader injection and unsupported remote deployment/staging arguments; use a controlled working directory and child environment. + +Make the current runtime configuration bridge explicit: resolve the declared logical config store, selected physical name and key; read/verify the already-pushed local envelope; supply it through the existing `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` interface and explicitly selected secret environment values. Set matching runtime store-name/key selectors and secret-store mappings in the controlled child environment; the application resolves these independently from the envelope variable name. Reject inconsistent selections rather than silently loading defaults. The native process does not automatically read EdgeZero's local JSON file. No automatic config push, secret creation or new configuration protocol is introduced. Set validated `PORT`; the baseline executable binds loopback and does not consume the source launcher's adapter-port variable or parse `axum.toml` as its comment suggests. + +Report process spawn, application readiness and exit separately, forward signals and clean up the owned child after startup failure. When readiness is requested, require application `/health` success within the explicit timeout; a bound port or living process is insufficient because missing configuration/secrets currently produces a 500 router rather than process exit. Adapter acceptance additionally exercises a real publisher request and existing unsupported runtime capabilities. Receipts contain local launch identity/PID/listener/outcome, never a fabricated cloud service version, and never secret values. + +#### Shared EdgeZero seam and publisher handoffs + +Extend the Phase 1 prepared-artifact seam with concrete supported payload types: Fastly package, Cloudflare module set, Spin application/component or OCI layout, and Axum executable. Keep provider-specific contexts and outcomes typed. A closed adapter/action/payload compatibility table is sufficient; no arbitrary predicate/plugin registry or universal cloud deployment framework is required. Add cases when their implementation lands, not empty runtime variants in Phase 1. Trusted Server owns official release policy and verified artifact construction; EdgeZero owns retained payload integrity, provider transport/local process lifecycle and structured known/unknown results. Unknown kinds/actions are typed capability errors before mutation or process start. + +| Adapter | Initial supported action | Observed execution/deployment identity | Unsupported operation handling | +| ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Fastly | Verified package deployment, production/staging | Service ID and exact integer service version, activation/staging state | Existing explicit lifecycle safeguards; no arbitrary source-build passthrough | +| Cloudflare | Prepared version upload, explicit production activation | Account/Worker/environment, Worker version ID and deployment ID separately | Reject staging/unimplemented routing or rollout modes | +| Spin | Verified local run and OCI publication | Local child/runtime instance or registry manifest digest; provider app/revision only when supported and observed | Reject unselected plugins and unsupported provider modes | +| Axum | Verified local development-server launch | Local launch instance/PID/listener and lifecycle state | Remote deploy, staging and provider rollback remain unsupported | + +Publisher/runtime claims reference the appropriate artifact and observed identity without equating either with publisher authorization. All adapters preserve runtime app-config, physical bindings, secrets and variable permission policy outside canonical artifacts. Future #161 snapshot work must bind what the operation actually loaded: Fastly reusable sandbox state, Axum startup state, Spin's operation-loaded KV settings, and Cloudflare's stored binding/environment snapshot. Cloudflare route construction per request does not imply its stored `OnceCell` configuration has refreshed. No receipt asserts a configuration digest merely from a current store lookup or hashes resolved secret values. + +Standalone tsjs and publisher-selected external Prebid remain separate distributions. Embedded tsjs is covered by each containing artifact, while enabled/deferred integration selection is runtime behavior. External Prebid requires its own future bundle/configuration binding and does not inherit the adapter's SBOM claim. ## Test plan @@ -637,7 +711,7 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p ### SBOM composition -- Validate SPDX 2.3 schema and the root binary SHA-256 for all matrix artifacts. +- Validate SPDX 2.3 schema and the root canonical-artifact SHA-256 for all selected matrix artifacts; validate Cloudflare archive/member relationships and Spin final-component identity. - Target-specific normal dependencies and host-built proc macros/build dependencies retain correct roles and per-unit features, including a package compiled in both contexts. Compiler-trace mappings and exact source joins are fixture-tested. - Dev-only and unrelated workspace packages do not become artifact runtime components. - Native/vendor/sysroot components and external library requirements are represented according to observed build declarations. @@ -652,17 +726,18 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p - Demonstrate complete byte equality of A/B builds for Fastly, Linux CLI, macOS ARM64 CLI and macOS Intel CLI before the first release. There is no per-target warning-only fallback. - Use separate clean jobs and newly materialized source/dependencies/outputs; prove Build B cannot consume A's executable, compiled cache or generated JS/code. - Compare source, recipe, platform/toolchain inputs and embedded module digests as well as final size/SHA-256 and byte equality. Inject a one-byte executable change, missing build, wrong recipe/SDK and incorrect source epoch; each blocks publication. -- Replay the published recipe on a clean Linux container host for both Linux-built assets and on a separate compatible Mac for each native Apple target. Reconstruct from authenticated retained inputs without workflow/provider credentials or compilation networking. +- Replay every selected published recipe on a clean Linux container host for Linux-built artifacts and on a separate compatible Mac for each native Apple artifact. Reconstruct from authenticated retained inputs without workflow/provider credentials or compilation networking. - Vary external checkout directory, fresh dependency materialization and execution time while honoring the declared canonical internal environment. Test permitted parallelism variations where the recipe claims they do not affect bytes. - Inspect final files for leaked developer checkout paths; test the release CLI's disabled compiled-path fallback and retained explicit/current-checkout resource behavior. - Validate native build IDs, Apple OSO paths/times, UUID and ad-hoc signature settings; compare the actual distributed signed bytes without removing sections for comparison. - Verify input-archive identity and safe bounded extraction; reject source contents/modes/link targets inconsistent with the independently obtained approved Git tree. Missing archives/tools, incompatible platforms, prohibited fetches or changed inputs fail with a clear diagnostic. - A locally reconstructed matching wasm passes ordinary release verification before provider writes; a mismatching one fails even when compiled version/source facts match. - Recipe/rebuild/input-archive identities remain consistent with provenance/index/offline publication records. Runtime claims never interpret repeatability as proof of remote execution or independent compiler trust. +- Before Phase 2 enablement, independently demonstrate A/B equality and operator replay for all five additional artifacts. Cloudflare proves exact final archive/member bytes after all JS/WASM transforms; Spin proves final component bytes after all approved adaptation/composition; each Axum target proves its own final executable and native compatibility. Phase 1 evidence cannot satisfy these gates. ### Verification and publication -- Verify both provenance and SPDX predicates for every binary with fixed signer/ref/commit constraints. +- Verify both provenance and SPDX predicates for every selected canonical artifact with fixed signer/ref/commit and adapter/kind/capability constraints. - Reject altered binaries, bundles, index entries and companions; wrong repository/signer/issuer/ref/commit; unsupported target/variant; and self-hosted-runner provenance. - A provenance-only, SBOM-only, or checksum-only result cannot produce a `VerifiedArtifact`. - Exercise missing/incompatible GitHub CLI, network failure, malformed JSON and partially successful command sequences. @@ -690,10 +765,20 @@ Each future artifact gets its own digest, target/variant recipe, SBOM and byte-p - Native CLI startup/help/config-init work outside the source tree; repository-dependent commands retain explicit prerequisites. - Native linkage inspection and clean Ubuntu 24.04 runtime smoke establish the advertised Linux baseline independently of build-runner provisioning. +### All-adapter enablement and publisher handoffs + +- Exact cohort membership is checked before signing and again before publication. Missing Phase 2 outputs cannot trigger a Phase 1 fallback; old consumers reject unsupported kinds/capabilities. Every artifact has consistent recipes, evidence, SBOM, attestations and publication records. +- Cloudflare fixtures cover SDK glue, JS snippets/polyfills and final module ownership; safe deterministic archive extraction; module names/types/import resolution; the compatibility contract; binding inheritance; and equality of actual multipart executable parts before writes. Reject implicit custom builds, bundling/discovery, unreviewed compatibility overrides and unsupported staged/routing operations. Upload and activation failures preserve version/deployment IDs and uncertainty without activating a different version. +- Spin fixtures build fresh WASIp2 output, validate the selected component ABI/world against the locked SDK/runtime, and execute an application request through its required KV/config/permission context. Host-reserved readiness alone is insufficient. Reject unapproved composition, build/watch hooks and ambient `SPIN_ALWAYS_BUILD` or deployment-plugin overrides. Verify local OCI layout descriptors and actual component layer bytes before push; inject secret variable defaults, app-config blobs, state snapshots and mounted files into config/layers/annotations and require rejection before any registry write. Validate retrieved digest/content before any supported remote activation. Mutable tags, changed layers, unsupported plugins and ambiguous outcomes cannot bypass the capability contract. +- Axum fixtures launch the verified native executable outside a checkout, using the declared `PORT`, local envelope bridge and selected secret references. Exercise nondefault physical store names, config keys and secret-store selectors, including mismatches between envelope variable names and runtime selectors. Prove source-build hooks and loader overrides cannot execute, missing config/secrets cannot pass readiness via a listening 500-error router, and a positive application request succeeds. Exercise startup failure, signal forwarding and owned-child cleanup; reject remote deploy/staging/rollback before spawning or writing. +- Prepared payloads cannot cross adapter/action boundaries. Receipts retain distinct artifact, packaging, provider and local-process identities; unsupported or unknown observations are not fabricated. Test startup-held Axum settings, cached Cloudflare settings, reused Fastly settings and operation-loaded Spin settings so future claims cannot substitute freshly fetched config for the snapshot actually used. + ### Required repository checks Implementation verification uses the target-matched tests/lints for changed core, JS, CLI and adapter behavior, plus upstream EdgeZero tests for the additive deploy interface. Before implementation PR handoff, run the complete AGENTS.md CI gate list, including reusable Fastly tests, parity tests, CLI host tests, native template-build-digest tests, JS build/tests/format, and documentation/Markdown formatting. +Phase 2 Spin migration updates those target-matched aliases/gates and their documentation together. Passing today's WASIp1 command list does not establish the new WASIp2 SDK/runtime contract; the reviewed migrated commands and positive component/application fixtures are mandatory. + The specification-only PR needs Markdown formatting, relative-reference validation, and design review. It does not claim runtime tests establish behavior that has not been implemented. ## Rollout @@ -703,9 +788,11 @@ The specification-only PR needs Markdown formatting, relative-reference validati 3. **Implement release build support.** Add strict JS mode, composition evidence, build facts, deterministic recipes/tools/environment pins, retained inputs, A/B comparison gates, index generation, provenance/SBOM signing and publication workflows. Reconcile obsolete CI comments claiming configuration is baked into wasm. 4. **Implement verified deployment.** Add the fixed release policy, online/offline evidence handling, retained artifact/package verification, provider sequencing and optional local receipts. Document the native Fastly route as a transitional/manual path. 5. **Prepare release authority.** Verify immutable releases and tag rulesets, provision the isolated repository-scoped read-only settings credential, audit bypass permissions and action/tool pins, and prove publication in a disposable public repository. Do not backfill historical tags with claims that their old operator builds were attested. -6. **Publish the first eligible new tag.** Complete the full artifact matrix, verify immutable membership and all predicates, and exercise an explicitly authorized test deployment through the supported artifact route. +6. **Publish the first eligible Phase 1 tag.** Complete its four-artifact matrix, verify immutable membership and all predicates, and exercise an explicitly authorized test deployment through the supported artifact route. 7. **Move operator guidance to verified bytes.** Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document the default download/deploy route and exact operator rebuild recipe, expected digest checks, environment/input acquisition, supported targets, installation prerequisites, identity policy, offline limits, staging and rollback. Distinguish ordinary custom builds from verified reconstructed artifacts. -8. **Continue separately.** #161 designs publisher trust, loaded-config metadata, claim signing/freshness and runtime transport. Later work can add adapters, independent rebuild witnesses, broader environment portability and stronger builder isolation without changing the artifact/deployment identity boundary. +8. **Implement and prove Phase 2.** Land the paired Spin WASIp2 migration, Cloudflare final-module recipe/prepared API transport, Spin verified local-run/OCI publication and Axum native recipes/direct-launch bridge. Add concrete EdgeZero payload/context/result cases and capability errors. Prove composition, every additional recipe's A/B equality/operator replay and each declared operation before enabling the cumulative cohort. This work can proceed alongside Phase 1 without changing a published release. +9. **Enable the cumulative nine-artifact cohort on a new tag.** Review the tracked manifest, run all shared and adapter-specific gates, and update verification/operation guidance and changelog for the newly supported kinds. Never attach new assets or support claims to an already immutable Phase 1 release. +10. **Continue publisher and assurance work separately.** #161 designs publisher trust, loaded-config metadata, claim signing/freshness and runtime transport. Later work can add independently reviewed provider capabilities, independent rebuild witnesses, broader environment portability and stronger builder isolation without changing the artifact/deployment identity boundary. A delayed EdgeZero change does not justify rebuilding inside the verified deployment path. Releases may be distributed with the manual packing instructions during rollout, but #1235's integrated deployment acceptance is not marked complete until the declared route is available and verified. @@ -713,28 +800,29 @@ Published immutable releases are never repaired by overwriting assets or moving ## Alternatives considered -| Approach | Benefit | Reason for the selected order | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected default operator route; operators need not rebuild to deploy verified bytes | -| Reproducible executable recipes plus verified deployment | Operators can reconstruct and compare while retaining direct verified deployment | Selected together: all initial targets must pass clean-build comparisons and replay fixtures before publication | -| Require every operator to rebuild before deployment | Every deployment starts with local reconstruction | Unnecessary operational prerequisite; reconstruction is supported, while mandatory release comparisons establish recipe repeatability | -| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | -| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | -| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | -| Rust-native SBOM tooling or `cargo-auditable` | Reduces custom Rust dependency tooling | Cargo's precise SBOM precursor is currently unstable; stable cargo-auditable uses metadata fallback, modifies executable bytes and does not replace JS/native composition. Revisit when a maintained pipeline proves the selected contract | -| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | +| Approach | Benefit | Reason for the selected order | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected default operator route; operators need not rebuild to deploy verified bytes | +| Reproducible executable recipes plus verified deployment | Operators can reconstruct and compare while retaining direct verified deployment | Selected together: all selected cohort artifacts must pass clean-build comparisons and replay fixtures before publication | +| Require every operator to rebuild before deployment | Every deployment starts with local reconstruction | Unnecessary operational prerequisite; reconstruction is supported, while mandatory release comparisons establish recipe repeatability | +| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | +| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | +| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | +| Rust-native SBOM tooling or `cargo-auditable` | Reduces custom Rust dependency tooling | Cargo's precise SBOM precursor is currently unstable; stable cargo-auditable uses metadata fallback, modifies executable bytes and does not replace JS/native composition. Revisit when a maintained pipeline proves the selected contract | +| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | +| Require all adapters in the first release | One initial full-platform launch | Not selected: Cloudflare postprocessing/transport and the Spin target migration are independent prerequisites. Define all contracts now, ship an explicitly limited Phase 1 cohort, then enable the complete Phase 2 cohort only after its proof gates | Composition tooling, cross-platform reproducibility and the provider lifecycle interface are the largest implementation costs. Keep the adapter limited to observed selection, metadata joins, relationships and validation; reuse Syft and maintained parsers rather than writing a new package scanner. Composition/replay fixture proofs precede production workflow work so feasibility failures can change the approved contract early. Recipe reproducibility complements provenance and verified deployment; independent witnesses and stronger builder isolation add further assurance later. ## Acceptance criteria - [ ] This draft is approved and merged independently; implementation links the approved version and updates it for material design changes. -- [ ] The no-configuration requirement and compile-time inventory are enforced for every initial artifact. +- [ ] The no-configuration requirement and compile-time inventory are enforced for every selected cohort artifact. - [ ] Protected eligible `v*` tags publish the complete immutable artifact/SBOM/bundle/index/checksum distribution. -- [ ] All executable artifacts pass provenance, SBOM and immutable-release asset verification with the documented policy. +- [ ] All selected canonical artifacts pass provenance, SBOM and immutable-release asset verification with the documented adapter/kind/capability policy. - [ ] Strict JS release builds reject missing tools, installation failure, skip flags and stale/incomplete output. -- [ ] Every initial executable passes separate clean A/B builds with exact complete-byte equality; any missing or mismatched target blocks publication. -- [ ] Authenticated recipes and retained inputs support demonstrated operator replay for all four targets without release credentials or compilation networking. +- [ ] Every selected canonical artifact passes separate clean A/B builds with exact complete-byte equality; any missing or mismatched artifact blocks publication, including Cloudflare archive/member bytes and final Spin component bytes. +- [ ] Authenticated recipes and retained inputs support demonstrated operator replay for every selected artifact without release credentials or compilation networking; Phase 1 has four artifacts and cumulative Phase 2 has nine. - [ ] Locally reconstructed wasm must match the official digest and pass ordinary evidence verification before deployment; version/source metadata alone never suffices. - [ ] SBOM fixtures prove target/build-host selection and embedded JS scope, including the external Prebid exclusion. - [ ] Both production and staging artifact deployment preserve verified wasm bytes and perform no provider writes on verification failure. @@ -744,6 +832,8 @@ Composition tooling, cross-platform reproducibility and the provider lifecycle i - [ ] Immutable-release settings, active tag rules and release authority are verified and documented. - [ ] CLI/operator verification guidance and changelog are updated; historical source builds are not relabeled as attested. - [ ] Publisher/configuration/runtime/vendor protocols remain separate, with the necessary handoff contracts documented for #161. +- [ ] Phase 2 enablement proves Cloudflare's final module archive and exact prepared upload/activation, Spin's migrated validated component and local-run/OCI path, and all three Axum executables with verified local launch. Unimplemented remote-provider modes fail before side effects. +- [ ] Typed EdgeZero payload/context/results preserve canonical bytes and adapter-specific identities across the four supported adapters; future publisher claims bind the actual loaded snapshot and retain the limits of local observations. ## Supersession and references @@ -772,5 +862,9 @@ Authoritative implementation references: - [Cargo build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html) and [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html). - [GitHub-hosted runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). - Pinned Fastly v15.1.0 [publish](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/publish.go#L119), [wasm annotation](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/build.go#L208), and [precompiled wasm packing](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go#L87). +- Pinned Cloudflare worker-build v0.8.5 [transformation pipeline](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/main.rs) and [tool versions](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/versions.rs). +- Cloudflare [script version upload](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/versions/methods/create/), [multipart metadata](https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/), [deployment API](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/deployments/) and [Wrangler bundling](https://developers.cloudflare.com/workers/wrangler/bundling/). +- Spin v4 [Rust component targets](https://spinframework.dev/v4/rust-components), [local execution](https://spinframework.dev/v4/running-apps), [registry push and deploy commands](https://spinframework.dev/v4/cli-reference) and [reserved HTTP routes](https://spinframework.dev/v4/http-trigger#reserved-routes). +- Existing [Axum operator configuration bridge](../../guide/axum-dev.md) describes the development launcher being adapted; it is not already a verified-artifact launch path. Tool/action versions and hosted-runner availability are validated when implementation pins are selected. A later incompatible tool/API change requires a reviewed recipe or specification update, not a silent change to verification policy. From 24a8a7180a32a30e0438d9f5b77697c058c6e9c3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 12 Oct 2026 11:49:26 +0530 Subject: [PATCH 005/153] Resolve release attestation review findings --- .../specs/2026-01-15-attestation-design.md | 2 +- ...6-10-09-1235-release-attestation-design.md | 1038 +++++------------ ...12-1235-release-attestation-feasibility.md | 130 +++ .../schemas/release-index-v1.schema.json | 581 +++++++++ 4 files changed, 1035 insertions(+), 716 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md create mode 100644 docs/superpowers/specs/schemas/release-index-v1.schema.json diff --git a/docs/superpowers/specs/2026-01-15-attestation-design.md b/docs/superpowers/specs/2026-01-15-attestation-design.md index 87b135bdbc..c333ca8b03 100644 --- a/docs/superpowers/specs/2026-01-15-attestation-design.md +++ b/docs/superpowers/specs/2026-01-15-attestation-design.md @@ -1,6 +1,6 @@ # Runtime Integration Attestation Architecture -> **Status:** Proposal +> **Status:** Historical proposal. Binary-attestation sections are superseded in direction by the [October release artifact specification](2026-10-09-1235-release-attestation-design.md), which becomes the implementation contract upon approval. Runtime/configuration proposals remain reference material for #161. > **Author:** Architecture Team > **Date:** January 2026 diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index aed8d77ec6..618cc9c362 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -1,870 +1,478 @@ -# Release binary attestation and publisher attestation foundations +# Release artifact attestation and verified consumption **Issue:** [#1235 — Publish attested release binaries with build provenance and SBOMs](https://github.com/IABTechLab/trusted-server/issues/1235) -**Date:** 2026-10-09 +**Date:** 2026-10-09. **Review revision:** 2026-10-12. -**Status:** Draft for maintainer review. Implementation requires approval and merge of this specification in its own PR. +**Status:** Draft for maintainer review. Approval and merge of this specification precede an implementation PR. Requirements below describe proposed behavior, not an existing release pipeline. The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) records measured prototypes, missing implementations and unresolved hosted/provider qualifications; document review alone is not feasibility proof. -**Source baseline:** `da31a215e62a8201610de367ee06d572708952df`. +**Source baseline:** PR #1262 at `7f6056bf83eb96aa4167efca64d43280299fba73`. The implementation MUST reconcile [#1214](https://github.com/IABTechLab/trusted-server/pull/1214) (isolated JS builds) and [#1213](https://github.com/IABTechLab/trusted-server/pull/1213) (source-version headers) before release enablement; neither is assumed merged at this baseline. -**Adapter coverage:** All four server adapters have defined contracts below. Phase 1 enables Fastly plus the operator CLI; Phase 2 extends the supported release set to Cloudflare, Spin and Axum after their explicit proof gates. Four Phase 1 assets are one Fastly wasm plus three CLI executables, not four server adapters. - -**Related work:** [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#167](https://github.com/IABTechLab/trusted-server/pull/167), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), and [#1084](https://github.com/IABTechLab/trusted-server/pull/1084). +**Related work:** [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#1179](https://github.com/IABTechLab/trusted-server/pull/1179), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), [#1084](https://github.com/IABTechLab/trusted-server/pull/1084), and [#167](https://github.com/IABTechLab/trusted-server/pull/167). ## Problem -Publishers and vendors need to verify that a distributed Trusted Server binary was built from the reviewed repository source. Current CI builds binaries for validation but publishes no attested release distribution. Operators build their own wasm, and `ts deploy` delegates to deployment paths that can build again before uploading. - -A signature on a release artifact is useful only when the deployment path preserves the artifact identity being verified. Fastly packaging also matters: `compute publish` builds before deployment, and `compute build` can annotate the wasm after Cargo produces it. Attesting one file and uploading a transformed file breaks the connection to the original attestation. +Publishers and vendors need evidence that distributed binaries were built from reviewed source, and operators need a route that submits the verified bytes. Ordinary CI publishes no attested distribution. Source deployment delegates to build-capable EdgeZero/Fastly commands. Fastly build annotation can also change Cargo's wasm output. -Project attestation will eventually include publisher identity, configuration, deployment, runtime claims, and possibly vendor endorsements. Binary provenance must provide a stable subject for those claims without treating them as equivalent or choosing their future wire format prematurely. - -This design establishes a canonical release artifact per version, target, and approved build variant; provenance and SBOM evidence for those bytes; and a deployment path that consumes verified bytes. It defines the minimal interfaces that later publisher attestation can reference. +The previous draft coupled four-target reconstruction and all-adapter enablement to the first release. This contract instead selects assurance requirements per artifact before execution. Required evidence remains mandatory; an optional measurement cannot quietly become a claim that it passed. Build provenance, publication, local authorization and remote execution are separate claims. ## Goals -1. Publish the first Fastly wasm and Linux/macOS `ts` executables through an immutable GitHub Release. -2. Generate and verify SLSA build provenance and an artifact-specific SBOM attestation for every executable artifact. -3. Require fresh locked Rust and JS builds with an explicit compile-time input inventory. -4. Prevent publisher configuration, secrets, resource bindings, and publisher-specific permission policy from affecting release binary bytes. -5. Deploy an attested Fastly artifact without compiling or modifying its wasm, for both production and staging. -6. Provide exact online and offline verification procedures with a constrained signer, source ref, and source revision. -7. Make immutable build facts available to future #161 work, while keeping the final binary digest external. -8. Keep artifact identity, publisher identity, configuration identity, deployment observations, and runtime evidence distinct. -9. Preserve existing logical-store mapping and staged configuration isolation, with exact deployment outcomes usable by the separate health-check and rollback lifecycle commands. -10. Require byte-identical clean rebuilds of every selected canonical artifact, publish replayable recipes and inputs, and support operator reconstruction against the authenticated release digest. -11. Define the canonical payload, full build pipeline, reproducibility, verification, supported deployment/local-start operations and publisher handoffs for Fastly, Cloudflare, Spin and Axum; enable them through explicit release cohorts. +1. Publish Fastly wasm and three Linux/macOS operator CLI executables with build provenance, artifact-specific SPDX SBOMs, bundles and immutable release membership. +2. Enforce configuration independence and fresh locked inputs, including one shared, twice-built JS module set. +3. Require complete-byte Fastly A/B equality; report native CLI comparisons without making native reproducibility a first-release prerequisite. +4. Document exact verification and a byte-preserving Fastly production route first, then integrate production/staging through a new EdgeZero API. +5. Provide stable build facts and deployment observations for #161 without claiming publisher authorization or remote execution proof. ## Non-goals -- Runtime attestation endpoints, per-request claims, remote execution proof, and freshness/replay protocols. These belong to #161. -- Publisher enrollment, domain ownership verification, a publisher trust registry, or selection of DSSE/JWT for publisher claims. -- Signing or attesting `trusted-server.toml`, computing a new public configuration hash, or exposing resolved settings. -- Resolving permission-policy distribution or authorization semantics. The permission specification owns these decisions; this specification supplies a compatibility requirement. -- Vendor endorsement protocols or CODEOWNERS changes. -- Signatures or the final artifact checksum embedded in a wasm custom section. -- Requiring an external rebuild witness as a release prerequisite, or claiming that two project-controlled builds establish independent builder trust. -- Production container distributions, Windows, Linux ARM64, universal macOS binaries, or standalone tsjs release distributions. -- A generic cloud deployment system for Axum, production-readiness certification of the development server, or automatic support for every Spin deployment plugin. -- Attesting the publisher-selected external Prebid.js bundle. -- Making all CLI commands self-contained without their documented external tools or repository resources. -- Apple Developer ID signing/notarization or automatic installer/self-update infrastructure. - -## Decisions - -| Question | Decision | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| First executable artifacts | Fastly `wasm32-wasip1`; CLI `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, and `x86_64-apple-darwin` | -| Full adapter scope | Phase 2 adds final Cloudflare Worker module distribution, validated Spin WASIp2 component and Axum executables for the three native CLI targets | -| Release cohorts | A reviewed source-controlled release manifest selects Phase 1 or the cumulative Phase 2 set before builds; every selected artifact is mandatory | -| Fastly build variant | Existing production defaults; `reusable-sandbox` and test features are off | -| Deployment strategy | Both reproducible executable production and verified artifact deployment; downloading verified release bytes remains the default operator route | -| Reproducibility gate | Two clean builds per selected artifact must match its complete canonical bytes; missing results or mismatches block the complete release cohort | -| Integrated operator flow | Add an artifact deployment path to `ts deploy` through an additive EdgeZero interface; retain a documented native Fastly packing route during rollout | -| SBOM | SPDX 2.3 JSON generated by pinned Syft plus a repository-owned composition step using the actual Cargo build graph and JS bundle evidence | -| SBOM distribution | One SBOM per canonical artifact, published as a release asset and embedded as that artifact's SBOM attestation predicate | -| Release trigger | Protected `v*` tag push, followed by strict stable/prerelease SemVer validation; no arbitrary-ref manual publishing | -| Workflow boundary | `.github/workflows/release.yml` calls `.github/workflows/release-build.yml`; the reusable workflow builds and signs | -| SLSA claim | Initially Build Level 2; using a reusable workflow does not by itself justify a Level 3 claim | -| Signing mechanism | GitHub `actions/attest` v4, pinned by full commit SHA; GitHub OIDC and public-good Sigstore | -| Verification implementation | Reuse the GitHub CLI's verifier through a checked subprocess interface; do not implement a second Sigstore verifier in Rust | -| Artifact identity | SHA-256 of the final distributed file, with exact target and build variant recorded separately | -| Runtime build facts | Release version, package version, source commit, target, and build variant; no self-computed wasm checksum | -| Publisher groundwork | A verified artifact input, structured deployment observations, and documented identity boundaries; no publisher claim implementation | - -## Current behavior and affected surfaces - -The baseline has two repository-owned build scripts: core and JS. EdgeZero is pinned to `683202c66948146ec360f490126f1d60f920288b`; its CLI build script generates adapter registrations from its manifest and Cargo features. It does not embed publisher settings. Trusted Server does not currently use EdgeZero's manifest-embedding `app!` macro. - -| Surface | Baseline behavior | Design consequence | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `crates/trusted-server-cli/src/run.rs` | Delegates build, deploy, and typed config push to EdgeZero | Artifact deployment must extend the owning interface rather than wrap an unconditional build | -| `edgezero.toml` | Fastly production deploy selects `fastly compute publish` | Verified artifact deployment bypasses source-build manifest commands | -| EdgeZero Fastly staging | Builds, clones/updates a service version, redirects its configuration selector, and stages it | Verification must precede the first provider write; staging must accept a prebuilt package | -| `crates/trusted-server-core/src/config_payload.rs` | Verifies a blob envelope, resolves secrets, and returns validated `Settings` | Existing configuration separation is retained | -| `crates/trusted-server-core/build.rs` | Extracts the default logical config-store ID and hashes core inputs into `TEMPLATE_BUILD_DIGEST` | Release builds use the repository manifest unchanged; the template digest is not artifact identity | -| `crates/trusted-server-js/build.rs` | Builds and embeds discovered `tsjs-*.js`, with several fallback paths | A strict release mode must reject fallback and incomplete module inventories | -| `crates/trusted-server-js/lib/build-all.mjs` | Vite/Rollup IIFEs, esbuild minification, cleaned `dist` | Capture package contribution evidence from the actual bundle build | -| `crates/trusted-server-cli/src/prebid_bundle.rs` | Locates JS resources in a repository and has a compiled checkout-path fallback | CLI distribution must document source-resource dependencies and record this compile-time input | -| `.github/workflows/test.yml` | Builds/tests adapters and native CLI without release publication | Add a dedicated release workflow; do not turn ordinary PR CI into a signing authority | -| `crates/trusted-server-core/src/request_signing/` | Deployment Ed25519 keys, JWKS discovery, request signatures | These keys and signatures remain separate from release provenance | -| Cloudflare `build.sh` / `wrangler.toml` | worker-build generates JS/WASM; local build script loads operator environment and installs tools; Wrangler can execute a custom build | Release recipes bypass this developer launcher, pin the complete transformation chain and submit prepared modules | -| Spin manifest versus pinned SDK/EdgeZero | Repository source/build uses WASIp1; pinned EdgeZero uses WASIp2 and locked SDK expects its component toolchain | Align target/SDK/runtime/manifest/CI before Spin release enablement; never relabel an old core module as a component | -| Axum entry point and EdgeZero serve | Native development server loads config at startup; source serve invokes Cargo and remote deploy is unsupported | Publish independently attested native executables and add direct verified local launch, preserving its development role | - -## Design - -### 1. Trust claims and assurance boundary - -| Claim | Subject and authority | What it does not establish | -| ------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | -| Build provenance | Exact artifact digest; trusted GitHub workflow identity | Publisher identity, approved configuration, or live execution | -| Release membership | Tag, source commit, and release assets; GitHub immutable release attestation | Build correctness or acceptable software behavior | -| Publisher identity | Publisher/domain/key relationship; a future externally trusted identity policy | Which artifact is deployed | -| Deployment binding | Artifact, service/version, environment, and observed deployment result | Independent proof of which bytes execute at an edge location | -| Runtime claims | Loaded instance/configuration/request snapshot; future #161 policy | Truth against an operator controlling both code and signing keys without additional evidence | -| Vendor endorsement | Defined code/configuration/policy scope; vendor authority | Approval of unrelated scope or publisher authorization | - -A publisher can run altered code that reports an official artifact digest and signs that statement with a legitimate deployment key. The official binary provenance and that publisher signature can both verify. This design therefore promises provenance of distributed bytes and preservation of submitted bytes, not remote execution attestation. - -Consumers continue to trust the reviewed source, the chosen build workflow, GitHub's identity assertions, and the applicable Sigstore trust roots. Provenance does not establish absence of vulnerabilities or correctness of an SBOM. Release integrity does not prevent an authorized future release from containing different software. - -The release evidence uses established supply-chain standards: SLSA build provenance through GitHub/Sigstore and SPDX 2.3 for the SBOM. The release index, build-composition adapter, CLI policy, and local deployment receipt are project-specific contracts. Their use does not establish certification or a standardized publisher-attestation protocol. SLSA Build Level 2 is an implementation target to validate against the platform and producer requirements, not a rating achieved by this document. - -The separation of evidence, authority, verification policy, and a consumer's authorization decision is also consistent with the IETF RATS architecture. RATS is an architectural reference for future runtime work; this design implements neither a RATS protocol nor hardware-backed remote attestation. - -```mermaid -flowchart LR - source[Reviewed tag and locked inputs] --> build[Release build] - build --> binary[Canonical artifact bytes] - build --> composition[Build composition evidence] - binary --> attest[Provenance and SBOM attestations] - composition --> attest - binary --> release[Immutable release] - attest --> release - release --> verify[Verify artifact and evidence] - verify --> package[Package without rebuilding] - bindings[Publisher platform bindings] --> package - package --> deploy[Submit verified module] - config[Runtime publisher configuration] --> runtime[Running deployment] - deploy --> runtime - deploy --> receipt[Deployment observations] - receipt -. future publisher claim .-> future[Publisher attestation] - runtime -. future loaded snapshot .-> future -``` - -### 2. No configuration in release binaries - -**Requirement: no configuration may be compiled into the release binary. Anything that varies by deployment must enter through runtime app configuration or platform stores/bindings.** - -This prohibits publisher domains, service/account IDs, physical store names, customer-specific integration selection, secrets, configuration revisions, and deployment-specific permission policy as build inputs. A canonical artifact includes the repository's registered integrations; configuration selects their runtime behavior. Operators do not produce reduced publisher-specific binaries and call them the canonical release. - -The repository's logical store identifiers, product defaults, and the fictional CLI configuration scaffold are source-defined product inputs, not an operator's active configuration. They remain identical for all users of a release artifact. The CLI scaffold is used to create a configuration file, never as an embedded deployment configuration fallback. - -The repository copy of `edgezero.toml` is immutable within a release build. Physical resource names and version-linked selectors are configured at deployment time. An operator modifying a local manifest must still deploy verified canonical release bytes, downloaded or reconstructed through the declared recipe; the modified manifest is not supplied to Cargo for that artifact. - -The proposed embedded permission policy in #1045/#1084 cannot be adopted as publisher-variable build data. A fixed enforcement implementation may be source code, but variable policy must preserve the separation above. A conflicting permission change blocks publication until its owning specification resolves the conflict. This design does not choose a policy store, policy signature, or policy authority. - -### 3. Complete compile-time input contract - -Every release records the following input classes in build evidence. New inputs must be added to this inventory in the implementation PR introducing them. An attestation is not a claim that generic GitHub provenance automatically enumerates every transitive input; the supplementary build evidence supplies the details below. - -| Input | Scope and control | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Git source snapshot | Exact peeled tag commit and tracked bytes of workspace/crate manifests, Rust/TS/JS source, embedded assets, build scripts, and release scripts | -| Cargo resolution | Root `Cargo.lock`, registry checksums, exact git revisions, resolved package/features, target and host dependency roles | -| npm resolution | JS `package.json` and `package-lock.json`, pinned Node and bundled npm version, installed package versions and integrity metadata | -| Toolchain configuration | `rust-toolchain.toml`, `.tool-versions`, `.cargo/config.toml`, Rust compiler/LLVM/sysroot identity, Node/npm identity | -| Build recipe | Package and binary target, target triple, explicit feature policy, release profile including current `debug = 1`, linker/native compiler settings and any byte transformations | -| Repository EdgeZero manifest | Entire tracked `edgezero.toml`, default logical configuration ID derived by core, and the manifest digest | -| Core template build digest | All non-editor regular files under `core/src`, core `build.rs`/`Cargo.toml`, and root `Cargo.toml`/`Cargo.lock`/`edgezero.toml`; normalized relative paths and original bytes | -| Embedded/generated JS | Fresh core and discovered integration IIFEs, exact module IDs/digests, and the generated Rust module table copied into `OUT_DIR` | -| Other embedded assets | Core GPT bootstrap, CLI browser collector/consent scripts, and the CLI's tracked `trusted-server.example.toml` scaffold | -| OpenRTB bindings | Checked-in generated bindings and their tracked sources; normal releases do not regenerate protobuf or invoke an ambient `protoc` | -| Dependency-generated code | Build-script and proc-macro outputs, including EdgeZero CLI adapter registrations derived from its pinned manifest and enabled features | -| Release build facts | Validated release version, source commit, target and variant supplied by the release recipe; package version comes from the unchanged Cargo manifest | -| Native build inputs | Runner image version, SDK/Xcode where applicable, linker, compiler/assembler, build tools, vendored native library metadata, and linked external libraries | -| Paths and environment | Checkout/build paths, `CARGO_MANIFEST_DIR`, `OUT_DIR`, reviewed compile-trace wrapper, sanitized compiler/native-tool settings, explicit Vite mode, and permitted release environment values | - -`TEMPLATE_BUILD_DIGEST` hashes the complete raw manifest and can change after a comment-only manifest edit. It also includes ordinary untracked files under core source. Release builds therefore use a fresh checkout containing only the expected source snapshot and release outputs outside `core/src`. The digest remains a cache compatibility input; it does not cover the complete binary or compiler and must not be advertised as provenance. +Operator reconstruction, retained-input archives, automated offline deployment, Cloudflare/Spin/Axum distributions, standalone tsjs assets, external rebuild witnesses and SLSA Build Level 3 require follow-up specifications. Runtime/configuration attestation, publisher enrollment, variable permission-policy distribution, request signing and vendor endorsements remain with their owners. Windows, Linux ARM64, containers, Apple notarization and self-update are excluded from this distribution. -The CLI currently embeds `CARGO_MANIFEST_DIR` as a resource-discovery fallback, and debug metadata can contain paths. Official release builds disable that developer checkout fallback while retaining explicit/current-checkout resource discovery and clear diagnostics for repository-dependent commands. Rust path remapping does not rewrite arbitrary strings produced by `env!`. Debug/native paths and SDK inputs are controlled by the reproducibility contract below; recording ambient values alone is insufficient. +Offline build-evidence verification is documented here, but cannot establish fresh official release membership and cannot authorize `ts deploy --release`. The external Prebid bundle remains outside the embedded artifact's SBOM. CLI commands that need source resources retain documented prerequisites. -Release preflight rejects any presence of `TSJS_SKIP_BUILD` before environment sanitization; silently removing it would hide a prohibited release request. The build script independently enforces the same rule. Reject other prohibited operator build inputs, then construct the permitted build environment rather than inheriting arbitrary compiler/tool overrides. Remove operator `TRUSTED_SERVER__*` and `EDGEZERO__*` values from the actual build environment. +## Contract vocabulary and ownership -The recipe sets or rejects compiler/tool overrides including `RUSTC`, `RUSTDOC`, `RUSTC_WRAPPER`, `RUSTC_WORKSPACE_WRAPPER`, `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `CARGO_BUILD_RUSTFLAGS`, target-specific Cargo rustflags/linkers, Cargo profile overrides, `NODE_OPTIONS`, and npm/Vite configuration overrides. The only compiler wrapper is the reviewed composition recorder. Control Cargo home/configuration layers and prohibit unreviewed command-line configuration overrides; record the approved settings. Vite release execution explicitly disables environment-file loading and uses the approved production mode. Release scripts validate permitted native compiler settings rather than inheriting operator values. Platform authentication is unavailable to build jobs. This is a controlled release recipe, not a claim of a fully hermetic build. +MUST, MUST NOT, SHOULD and MAY have the meanings in BCP 14, only when capitalized. Each numbered requirement is normative. Rationale, rollout ordering, informational records and deferred investigations do not add implicit acceptance gates. -No wall-clock build timestamp, workflow run ID, publisher identity, or final artifact checksum is embedded. Operational timestamps and run identifiers remain external evidence. Set `SOURCE_DATE_EPOCH` to the peeled source commit's committer timestamp and record it. Audit tools that do not honor this variable; setting it is not a blanket determinism guarantee. +| Owner | Responsibility | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release tooling | Controlled builds, composition evidence, required comparison gates, signing and immutable publication | +| Trusted Server verifier | Fixed release policy; distinct checks of build evidence, official publication and consumer/action authorization; construction of an opaque `VerifiedArtifact` retaining the bytes | +| EdgeZero | Prepared payload integrity, resolved provider context, concrete lifecycle operations and durable known/unknown outcomes; no IAB release-policy decisions | +| Consumer | Trusted verifier bootstrap, approved source/tag/action/context and credentials | +| #161 | Publisher authority, loaded configuration claims, signing/freshness and runtime transport | -#### Reproducible artifact contract - -Issue #1235 permits verified release deployment, reproducible local builds, or both in a stated order. This specification selects both. Establish the executable recipes and reproducibility fixtures before release publication, then retain the verified deployment path so operators can use canonical bytes without rebuilding. Nothing in the baseline proves reproducibility yet; the following requirements must be demonstrated during implementation. - -Reproducibility means that the specified source, environment and instructions reconstruct the **complete canonical artifact bytes**, separately for each target/variant. Phase 1 artifacts are individual executables; Phase 2 includes Cloudflare's deterministic module archive and its exact member bytes. It does not mean different target binaries match, or that an arbitrary ambient host can run bare Cargo and obtain the same result. The contract covers embedded JS and every final executable transformation, including any required macOS ad-hoc signature. It does not require byte-identical Sigstore bundles/certificates, transparency entries, run timestamps, logs, SBOM generation times, every intermediate, or publisher-specific provider archives/envelopes. - -Each artifact has a tracked recipe implementation and a version-1 `.build-recipe.json` descriptor, authenticated through provenance and the release index. The descriptor contains: - -- Repository, peeled commit, release facts, package/bin, target/variant, recipe source revision and script digests. -- Exact Rust/toolchain components, Node/npm and bundler identities; permitted features, complete effective release profile, Cargo source/configuration layers and compiler/linker arguments. -- Locked source/material identities and the authenticated retained-input archive digest, including git dependencies, npm optional packages, native bundler tools and permitted lifecycle inputs. -- Host architecture, byte-affecting platform/toolchain identities, native compiler/linker/archiver/build tools, sysroot or SDK, and required environment bootstrap instructions. -- Build-path/remapping policy, locale/timezone, commit-derived epoch, generic target CPU/code-generation policy, relevant concurrency settings, and every final byte transformation. - -Descriptor version and fields are validated against the reviewed recipe; a downloadable descriptor cannot substitute arbitrary commands or tools. Operator replay uses the tracked reproduction script from the approved source revision. Environment and dependency inputs must be retrievable and integrity-verifiable without release-workflow credentials. Missing pins, unavailable required inputs or an unproven target block publication; they are not silently waived. Implementation selects actual tool/SDK/image pins through the reviewed release-tools manifest and proves the complete target matrix before the first tag. - -**Environment and dependency reconstruction.** All Linux-built artifacts use an OCI builder image pinned by manifest digest and explicit `linux/amd64` platform on GitHub-hosted Linux runners. Each recipe identifies its required image and tools; adapters need not share an image. Retain the images and their reviewed bootstrap definitions; builder-image construction pins base images and fetched tool/native inputs, rather than resolving floating OS packages. These images are build environments, not new Trusted Server container distributions. Document image retrieval/export and digest checking for operator replay. - -macOS recipes use native Apple tooling on the matching architecture, with explicit Xcode/CLT build, SDK identity/content digest, OS compatibility requirements, `SDKROOT`, deployment target and native tool identities. Validate required identities before compilation and record the actual runner OS/image. A hosted runner label is a scheduler selection, not an immutable environment pin. Provide a tested bootstrap on a separate compatible Mac; any OS/tool input that affects bytes becomes an explicit checked recipe input. A missing SDK/tool pin fails rather than falling back to whatever Xcode is active. Operators obtain platform SDKs through their supported distribution; this design does not promise SDK redistribution inside a public source archive. - -Prepare an authenticated, versioned `release-inputs.tar.zst` containing the exact source snapshot, integrity-checked Cargo vendored sources including locked git dependencies, generated Cargo source-replacement configuration, and per-platform npm input archives/cache needed for fresh installation. Retain platform tool archives/images where distributable; otherwise the descriptor supplies acquisition prerequisites and integrity checks. The input archive is self-contained for project dependencies, not a substitute for the declared OS/SDK environment. Assemble it before compilation with normalized file ordering/metadata and safe extraction rules. It contains source/dependency materials and preparation configuration, never generated final recipe descriptors, rebuild records or executables; the later recipe/index can name its digest without a checksum cycle. Recorded raw vendored-material identities do not replace original Cargo source/revision identities in the SBOM. - -Both release builds and operator replay compare the archive's tracked source bytes, file modes and link targets with the approved Git tree independently obtained from the repository. A signed archive naming a commit is insufficient if its contents differ from that commit. Locked dependency archives/revisions are checked independently as well. Preparation may access the network; both clean compile phases use freshly extracted materials, Cargo `--frozen` and fresh `npm ci` with recorded offline options. Enforce network denial for compilation/lifecycle processes as well: Cargo's offline flag alone does not prevent a dependency build script from fetching additional inputs. Unexpected fetches fail and require a reviewed input/recipe correction. +```mermaid +flowchart TD + A[Controlled build and composition evidence] --> B[Signing and immutable publication] + B --> C[Trusted Server verifier] + P[Trusted local policy and source approval] --> C + C --> D[Opaque VerifiedArtifact with retained bytes] + D --> E[EdgeZero prepared payload and resolved context] + E --> F[Observed operation state and recovery record] +``` -**Path, time and native output control.** Use canonical internal source/dependency/output/toolchain paths and tested Rust remappings covering relative and absolute paths, plus native compiler/debug prefix mappings where applicable. Preserve the declared `debug = 1` profile; any stripping is a pinned final transformation shared by CI and local replay. Normalize source/material mtimes using the recorded epoch where tools consume them, fix locale/timezone and reject host-specific CPU auto-tuning. Vary external checkout locations while keeping declared internal paths canonical; do not claim unrestricted internal-path independence without testing it. +## 1. Threat model and claim limits -Linux selects a deterministic content-derived linker build-ID policy. macOS controls linker OSO debug paths and modification times, validates the selected linker's reproducible mode/epoch behavior, and preserves a tested content-derived UUID policy. Do not introduce random UUIDs or indiscriminately disable UUID generation. Any required linker ad-hoc signing has recorded deterministic settings and is included in the final comparison; removing a signature only for comparison is forbidden. Developer ID signing, trusted timestamps, notarization and stapling remain outside the first distribution. Rust remapping alone does not normalize native linker output or arbitrary generated source. +| Threat | Capability | Defending checks | Residual trust or limitation | +| ---------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| TH-1 Asset/mirror tampering | Replace executable, index or companions | R-INDEX, R-EVIDENCE, R-PUBLICATION: signed digests, exact subjects and release membership | Authentic malicious source is still malicious | +| TH-2 Malicious evidence transfer | Substitute roots, policy, approved commit or offline pins | R-POLICY, R-BOOTSTRAP, R-OFFLINE: obtain trust inputs independently of supplied evidence | A compromised trusted transfer channel defeats its pins | +| TH-3 Compromised dependency/build script | Generate altered code or fetch undeclared inputs | R-INPUTS, R-JS, R-SBOM, R-BUILD: locked materials, controlled environment, observed/declarative coverage, no build credentials | Locks authenticate origin, not safety; same malicious input can reproduce | +| TH-4 Rogue tag pusher | Tag unapproved source or bypass checks | R-SOURCE, R-PUBLISH: branch/source authorization, same-commit validation, restricted tag authority | Authorized maintainers and bypass actors remain trusted | +| TH-5 Compromised runner image/compiler | Produce altered bytes and consistent A/B results | R-BUILD, R-SIGN: checked environment identities, hosted runners, separated signing permissions | A/B is project-controlled repeatability, not independent builder trust | +| TH-6 Compromised operator host | Replace gh/ts, retained files or runtime; lie about deployment | R-BOOTSTRAP, R-DEPLOY, R-RECOVERY: trusted bootstrap, retained handles, pre-upload equality, recorded observations | Local checks cannot establish honest remote execution against this host | +| TH-7 GitHub compromise | Misstate workflow identity, source or immutable membership | R-POLICY, R-EVIDENCE, R-PUBLICATION: constrained identities and Sigstore evidence | GitHub/OIDC, applicable roots and release authority are trust roots; this design does not defeat their compromise | -**Release gate.** For every artifact in the selected cohort, Build A and Build B execute the complete recipe in separate fresh hosted jobs. Each verifies/materializes its own source and dependencies, owns new Cargo/npm/JS output directories, and consumes no executable, compiled cache, generated JS or generated code from the other build. They use the same checked recipe/environment inputs. The trusted collection job checks current-run artifact identities, source/recipe/environment consistency, embedded JS/module digests, final sizes and SHA-256 values, then byte-compares the complete canonical artifacts. Cloudflare compares both the final archive and every declared module/inventory member; Spin compares the final validated component, not an intermediate. No normalization or excluded byte regions are allowed at comparison time. A missing result, environment mismatch, input mutation or unequal bytes stops signing/publication of the complete release. +Build provenance establishes that the constrained workflow produced a digest. Immutable release evidence establishes publication membership. A/B records measured equality under declared conditions. None proves absence of vulnerabilities, truthful configuration claims, publisher/domain authority or that submitted bytes execute at every edge location. SLSA Build Level 2 is an implementation target; a local reusable workflow alone does not establish Level 3. -After successful comparison, choose Build A's artifact as the canonical asset and attest it with its SBOM, build evidence, recipe and `.rebuild-check.json` as explicit provenance subjects. The version-1 rebuild record binds the source, recipe/input digests, observed environments, both build-job/artifact identities, both final lengths/digests, module inventory and comparison outcome. Workflow topology and current-run artifacts establish that the jobs were separate; an arbitrary predicate field asserting independence is insufficient. These project-controlled executions establish measured recipe repeatability. They do not establish independent witness trust, prevent a compromised common compiler from producing the same malicious output, or imply SLSA Build Level 3. +## 2. Selected artifacts and assurance profiles -**Operator replay.** Verify the release/index, executable, recipe and input identities before executing the recipe. The tracked `scripts/release/reproduce.sh` validates the approved descriptor, bootstraps/checks its target environment, extracts only authenticated inputs into a fresh workspace, builds without compilation network access and emits the final executable. It never downloads Build A's executable as a build input. A proposed invocation after obtaining the approved source is: +**R-COHORT.** `scripts/release/release-cohort.json` MUST select the complete artifact set and required assurance profile before jobs execute. The first cohort is `phase1-fastly-cli`. Every selected artifact MUST have successful canonical Build A, build provenance, SBOM, composition validation, shared JS input equality, artifact-specific retained-JS coverage and immutable publication. A failure of any selected requirement MUST block publication; the workflow MUST NOT shrink the set or lower a profile after failure. -```sh -./scripts/release/reproduce.sh \ - --recipe ./release-evidence/trusted-server-fastly.wasm.build-recipe.json \ - --inputs ./release-inputs.tar.zst \ - --source-commit '' \ - --output-dir ./rebuild-output - -cmp ./release-evidence/trusted-server-fastly.wasm \ - ./rebuild-output/trusted-server-fastly.wasm -``` +| Asset | Package / target | Required profile | Compatibility qualification | +| ----------------------------- | ------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | +| `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly` / `wasm32-wasip1` | `provenance-sbom-repeatable` | Fastly production defaults; variant `default` | +| `ts-x86_64-unknown-linux-gnu` | `trusted-server-cli` / `x86_64-unknown-linux-gnu` | `provenance-sbom` | glibc floor 2.34; clean UBI 9 (RHEL 9 family), Ubuntu 22.04 and Debian 12 startup fixtures | +| `ts-aarch64-apple-darwin` | `trusted-server-cli` / `aarch64-apple-darwin` | `provenance-sbom` | macOS loader deployment target 13.0; initial release qualification on hosted macOS 15 only | +| `ts-x86_64-apple-darwin` | `trusted-server-cli` / `x86_64-apple-darwin` | `provenance-sbom` | Same loader floor and macOS 15 qualification; actual Intel startup fixture required | -This script is new implementation work. The same interface supports every selected artifact under its declared platform recipe, including Cloudflare archive/member comparison and final Spin component validation. A matching locally reconstructed artifact can use the proposed `--release --artifact` consumption path for its adapter and permitted operation: its digest is the attested release subject, so ordinary online/offline provenance, SBOM and publication checks still apply. Matching bytes do not create new GitHub provenance, and successful compilation without digest equality is not verification. A mismatch reports failure and preserves diagnostic output; pinned `diffoscope` or equivalent format-aware diagnostics may explain it but cannot convert it into success. +**R-PLATFORM.** Recipes MUST use explicit packages/targets and recorded effective features/release profile. A pinned Linux builder with glibc no newer than 2.34 MUST establish the GNU compatibility floor; scheduling it on Ubuntu 24.04 does not establish a 2.39 floor. Native link inspection MUST validate the actual ELF GLIBC symbol ceiling and reject undeclared libraries and runner-only absolute paths. macOS MUST use a recorded matching-host recipe initially; a reviewed ARM-to-Intel cross recipe MAY replace it only after full CLI build and Intel execution qualification. `MACOSX_DEPLOYMENT_TARGET=13.0` MUST be explicit. It is a loader/ABI floor, not a claim that macOS 13/14 execution is qualified. The initial required startup matrix is macOS 15 on ARM64 and Intel. macOS 13 hosted runners have retired and macOS 14 is retiring; neither is a first-release test gate. Broader runtime support requires separately available execution hosts and evidence. Schema `compatibility.minimum_version` records the loader/libc floor; `build-evidence.compatibility_tests` records the actually qualified OS, architecture and outcome. Intel execution remains mandatory on an Intel host. `macos-15-intel` availability MUST NOT be assumed indefinitely: qualify a replacement execution host or review removal of the target before that runner becomes unavailable. Failure to establish these baselines blocks this cohort before its first tag; it does not justify advertising untested compatibility. A static musl distribution is an alternative requiring a reviewed cohort/target change. -### 4. Artifact matrix and distribution +**R-VARIANT.** The first Fastly artifact MUST retain the existing default, single-request production variant, with `reusable-sandbox` disabled. This is a deliberate conservative shipping choice pending deployed reuse qualification, not a claim that reuse is publisher configuration. There is no Fastly `test` feature. A second attested `reusable-sandbox` variant, or promotion to the default after the four runtime bounds and reuse lifecycle pass qualification, requires a reviewed cohort/policy change. Operators requesting reuse MUST receive an explicit unsupported-variant diagnostic rather than be told an unattested custom build is official. -This is the Phase 1 matrix. Section 12 defines the five additions and proof gates for the cumulative Phase 2 matrix. +Native CLI A/B outcome is reported as `matched`, `mismatched`, `failed` or `unavailable`. Its measurement job MUST reach a bounded terminal outcome, but equality is not required by `provenance-sbom`. A later change can promote an individual target after qualification. No profile in this contract claims separately bootstrapped operator reconstruction. -| Asset name | Cargo package and target | Hosted runner | Compatibility | -| ----------------------------- | ------------------------------------------------ | ---------------- | -------------------------------------------------------------------------------- | -| `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly`, `wasm32-wasip1` | `ubuntu-24.04` | Fastly production default feature set | -| `ts-x86_64-unknown-linux-gnu` | `trusted-server-cli`, `x86_64-unknown-linux-gnu` | `ubuntu-24.04` | Initial Linux baseline: Ubuntu 24.04; record actual dynamic library requirements | -| `ts-aarch64-apple-darwin` | `trusted-server-cli`, `aarch64-apple-darwin` | `macos-15` | macOS 15 ARM64; explicit deployment target 15.0 | -| `ts-x86_64-apple-darwin` | `trusted-server-cli`, `x86_64-apple-darwin` | `macos-15-intel` | macOS 15 Intel; explicit deployment target 15.0 | +## 3. Configuration independence and complete build inputs -Runner labels fix an OS generation, not an immutable VM image. Capture the actual image/toolchain versions and perform executable startup checks on each selected runner. Inspect native linkage and loader paths on Linux/macOS. Validate the Linux CLI's version/help/config-init commands outside a checkout in a clean Ubuntu 24.04 runtime with only its documented runtime dependencies installed; a provisioned build runner alone does not establish that compatibility. Reject dependencies on absolute runner-only paths or undeclared libraries. Changes to compatibility require a reviewed recipe change. Linux portability to older glibc, ARM Linux, and older macOS is not implied. +**R-CONFIG.** No configuration may be compiled into release binaries. Publisher domains, account/service IDs, physical bindings, active settings, secrets, selected integrations, configuration revisions and deployment-variable permission policy MUST enter at runtime. All artifacts MUST use the tracked repository `edgezero.toml` unchanged. Logical store identifiers, product defaults and the fictional CLI scaffold are source-defined product inputs shared by all operators. The scaffold MUST NOT be an active-config fallback. The permission work in #1045/#1084 MUST resolve any conflicting embedded variable-policy design before release enablement; this contract does not choose its distribution protocol. -Use explicit Cargo packages/targets with `--release --frozen` after the authenticated input-preparation step; this includes locked resolution. Fastly does not enable `reusable-sandbox`; production build commands do not use `--all-features`. CLI uses its existing default feature graph, including EdgeZero's default adapter registrations. Do not remove dependencies merely because they appear unrelated at the workspace level. +**R-INPUTS.** Build evidence MUST record every input class below, with selected identities/digests and roles. Introducing an input requires updating the inventory in the same implementation PR. Generic SLSA provenance is not assumed to enumerate these transitively. -Phase 1 ships raw executables to avoid an archive/extracted-executable identity ambiguity. Phase 2 retains raw Spin/Axum subjects and defines Cloudflare's deterministic archive plus authenticated member inventory explicitly in §12. Downloads may need their executable permission restored after verification. Any linker signing, stripping, or other transformation happens before checksums and attestation. No operation may modify an attested executable afterward. +| Input | Scope and control | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Git source snapshot | Exact peeled tag commit and tracked bytes of workspace/crate manifests, Rust/TS/JS source, embedded assets, build scripts, and release scripts | +| Cargo resolution | Root `Cargo.lock`, registry checksums, exact git revisions, resolved package/features, target and host dependency roles | +| npm resolution | JS `package.json` and `package-lock.json`, pinned Node and bundled npm version, installed package versions and integrity metadata | +| Toolchain configuration | `rust-toolchain.toml`, `.tool-versions`, `.cargo/config.toml`, Rust compiler/LLVM/sysroot identity, Node/npm identity | +| Build recipe | Package and binary target, target triple, explicit feature policy, release profile including current `debug = 1`, linker/native compiler settings and any byte transformations | +| Repository EdgeZero manifest | Entire tracked `edgezero.toml`, default logical configuration ID derived by core, and the manifest digest | +| Core template build digest | All non-editor regular files under `core/src`, core `build.rs`/`Cargo.toml`, and root `Cargo.toml`/`Cargo.lock`/`edgezero.toml`; normalized relative paths and original bytes | +| Embedded/generated JS | Fresh core and discovered integration IIFEs, exact module IDs/digests, and the generated Rust module table copied into `OUT_DIR` | +| Other embedded assets | Core GPT bootstrap, CLI browser collector/consent scripts, and the repository-root `trusted-server.example.toml` scaffold embedded by the CLI | +| OpenRTB bindings | Checked-in generated bindings and their tracked sources; normal releases do not regenerate protobuf or invoke an ambient `protoc` | +| Dependency-generated code | Build-script and proc-macro outputs, including EdgeZero CLI adapter registrations derived from its pinned manifest and enabled features | +| Release build facts | Validated source commit supplied after environment sanitization; target/package version from Cargo; variant derived by the owning adapter/product; release tag stays external | +| Native build inputs | Runner image version, SDK/Xcode where applicable, linker, compiler/assembler, build tools, vendored native library metadata, and linked external libraries | +| Paths and environment | Checkout/build paths, `CARGO_MANIFEST_DIR`, `OUT_DIR`, sanitized compiler/native-tool settings, explicit Vite mode, and permitted release environment values | -Each canonical artifact has named companions: +`TEMPLATE_BUILD_DIGEST` includes complete raw manifest bytes and ordinary non-editor core source files, including untracked files. It is a template/cache compatibility input, not an executable digest or provenance. Fresh checkouts MUST contain only expected source/materials; generated outputs MUST remain outside source-only directories. Before/after checks MUST detect tracked-input changes and unexpected source-directory files. -- `.spdx.json` — its artifact-specific SBOM. -- `.provenance.sigstore.json` — the generated build-provenance bundle. -- `.sbom.sigstore.json` — the generated SBOM attestation bundle. -- `.build-evidence.json` — selected build inputs, composition roles, and embedded module inventory. -- `.build-recipe.json` — the authenticated versioned recipe and reconstruction inputs. -- `.rebuild-check.json` — observed clean-build identities and exact canonical-artifact comparison. +**R-ENV.** Release preflight MUST reject the presence of `TSJS_SKIP_BUILD` with any value before sanitization. It MUST remove inherited operator `TRUSTED_SERVER__*` and `EDGEZERO__*`, then inject only validated release build facts (R-FACTS). Compiler/Cargo profile/linker flags, wrappers, Cargo home/configuration layers, native build selectors, `NODE_OPTIONS`, npm/Vite overrides and environment-file loading MUST be selected or rejected by the recipe. No ambient compiler wrapper is allowed. Release Vite mode MUST be explicit. Build jobs MUST have no provider/signing credentials. Wall-clock times, run IDs, publisher identity and final checksum MUST NOT be embedded. `SOURCE_DATE_EPOCH` MUST derive from the approved commit's committer timestamp. -Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, `release-inputs.tar.zst`, and `SHA256SUMS`. The index records the input archive's name/size/digest; its provenance also names that archive as a subject. The checksum file covers every other published asset and never itself. The release index excludes its own digest and its own attestation bundle; its signed identity is external, so there is no checksum cycle. +The CLI's compiled developer checkout fallback (`CARGO_MANIFEST_DIR`) MUST be disabled for official releases; explicit/current-checkout discovery can remain for documented source-dependent commands. Rust remapping does not rewrite arbitrary `env!` strings. CLI help, version and `config init` MUST run outside a checkout. -The release index is a versioned distribution index, not a publisher-attestation schema. Version 1 records repository, tag, source commit, recipe revision, reviewed release-manifest revision, selected cohort and an exact artifact list. Each artifact entry records name, byte length, SHA-256, adapter/product role, canonical kind, package, target, variant, compatibility/runtime contract, allowed consumption capabilities, any authenticated member inventory and companion asset names/digests. Consumers validate these against their reviewed policy and reject duplicate names, unknown required schema versions or kinds, traversal paths and conflicting identities. A target triple alone cannot distinguish a core wasm module from a component or a processed Worker distribution. +## 4. Shared JS build and artifact repeatability -Each artifact's provenance covers its canonical distribution, SBOM file, build-evidence file, recipe and rebuild-check file as explicit subjects after the dual-build gate passes. The separate SBOM invocation covers that canonical artifact as subject and the SPDX document as predicate. The release index and input archive receive provenance after the matrix artifacts and bundles are collected. Index assembly verifies that all entries came from the expected source/ref and complete matrix. +**R-JS.** Release work MUST extend #1214's private output, expected-module validation, checked test failures and `TSJS_PREBUILT_DIR`; it MUST NOT add a parallel `TSJS_RELEASE_BUILD` fallback mode. One release-wide JS job MUST independently run two fresh locked installations/builds with pinned Node/npm. Both MUST reject any skip flag, missing npm, failed `npm ci`, requested-test failure, missing/empty/extra modules or changed source. Release validation MUST run JS tests even if no local test flag was requested. Each build owns its installation/output directory and includes required build dependencies. -`ts --version` identifies the release version and source revision. The existing Cargo package version is retained separately; the release process does not edit `Cargo.toml` to make an existing workspace version equal the tag. +The two JS runs MUST compare final bytes for core plus every sorted tracked integration entry point. Only on equality MAY the job publish its Build A bundles and `tsjs-inventory.json`. That version-1 inventory MUST record sorted module IDs, filename, size, SHA-256, contributing source/package identities and tool/lifecycle identities. Each Rust build MUST consume these current-run bundles through `TSJS_PREBUILT_DIR` and a checked expected inventory, validate the exact set/digests of bytes copied into `OUT_DIR`, and run no npm. The first cohort MUST declare only `fastly-production` for Fastly until the integrated staging milestone is qualified. Adding `fastly-staging` requires a reviewed cohort and consumer-policy update. An artifact capability MUST intersect with an implemented local transport; downloaded metadata cannot enable an unavailable operation. The collector MUST verify identical prepared JS input inventories across all builds that compile the JS crate. Each artifact MUST separately record which module bytes remain in its final file. Claimed retained modules MUST match the shared inventory; a CLI executable MAY retain none after linking. Copies into `OUT_DIR` are build inputs and MUST NOT alone be called final embedded code, nor force unused JS into the CLI to satisfy an equality check. -CLI help and `ts config init` must work outside a checkout. Repository-dependent developer commands and `ts prebid client` keep documented prerequisites; missing source resources return a clear diagnostic rather than implying that the release binary includes those resources. The release does not introduce an installer for them. +The shared JS set is a declared input to Rust builds. Sharing it means the Rust A/B pair does not independently test npm/Vite; the separate JS A/B gate supplies that evidence. Installed package inventory and permitted lifecycle outputs MUST be recorded separately from lockfile archive integrity. A platform esbuild binary removed from the prepared installation MUST cause the network-denied bundle phase to fail instead of downloading a replacement. -### 5. Strict JS and clean build execution +**R-BUILD.** `scripts/release/build.sh` and `scripts/release/release-tools.json` at the approved source commit MUST define the executable recipes and checked tool/environment identities. A downloaded descriptor is informational and MUST NOT supply commands. Rust builds MUST use `--locked` (or `--frozen` after verified source preparation), a fresh target directory and checked dependency material. Compiled Cargo/JS outputs MUST NOT be restored from earlier runs. Verified download caches MAY be reused only after archive checksum/revision validation and fresh extraction. The compile/bundle phases MUST enforce network denial with a qualified OS/container boundary, including build-script/lifecycle downloads; a positive unconfined socket control and denied confined socket probe MUST run for each selected recipe. Cargo/npm offline flags, proxy settings or lockfiles alone do not enforce process network denial. Local macOS `sandbox-exec` socket denial has been demonstrated, but the full hosted native recipe still requires qualification; preparation is a separate network-enabled phase. Required native/system SDK identities and code transformations MUST be checked before compilation. -Introduce a tracked `TSJS_RELEASE_BUILD=1` mode for the Cargo JS build script. It is a release policy switch, not a mechanism to embed publisher settings. Preserve the documented local fallback mode outside release execution. +**R-REPEAT.** Fastly Build A and Build B MUST run in separate fresh hosted jobs with independent source/dependency materialization and Cargo outputs. Each MUST include every final byte transformation from the recipe. B MUST vary external checkout location, user where supported, umask, timezone/locale, start time and concurrency within the recipe's declared insensitive envelope; any normalized internal values MUST be recorded as such rather than advertised as varied. Unsupportable variations MUST be recorded and omitted from independence claims. The collector MUST select outputs by the artifact ID and transfer digest returned by each expected job in the current run, verify artifact REST metadata's current workflow run/repository/source SHA, reject reused A/B IDs, validate source/recipe records, and byte-compare complete final wasm. Producing job/attempt association MUST come from the expected upload-step outputs and trusted workflow dependency topology; the artifact API does not expose an independent job/attempt-origin field. Transfer digest is the Actions archive digest, separately checked from final executable SHA-256; transfer mismatch warnings MUST be promoted to failure. It MUST NOT use artifact names as the sole binding. Missing output, inconsistent checked inputs or unequal bytes MUST block the entire selected release. No comparison-time stripping, normalization or excluded byte regions is allowed. -Strict mode: +Native CLI comparison MUST use the same fresh-job/result-binding discipline, but a recorded nonmatching or unavailable B result does not block its profile. Build A and its composition/smoke checks remain mandatory. A signed measurement stating failure MUST NOT be relabeled success. -1. Rejects the presence of `TSJS_SKIP_BUILD`, including an empty value or `0`. -2. Requires the JS manifest, lockfile, pinned Node/npm, and build entry points. -3. Runs `npm ci` with build dependencies included even if `node_modules` already exists; any invocation or installation failure stops the build. -4. Deletes old JS output and builds bundles during each clean Cargo build that embeds them. -5. Requires exactly core plus the sorted integration entry points discovered from tracked source. Missing, empty, duplicate, or unexpected `tsjs-*.js` output is an error. -6. Produces bundle composition evidence during that build and checks each bundle's recorded digest against the bytes copied into `OUT_DIR`. -7. Checks the exit status of explicitly requested JS tests. Release validation also runs JS tests as an independent blocking step. -8. Tracks the strict flag, skip/test flags, selected tools, recipe inputs, and relevant environment changes so an incremental local invocation cannot silently keep a previous permissive result. +**R-NATIVE.** Native records MUST capture compiler, linker, archiver, SDK/sysroot, native source/libraries and relevant options. Linux SHOULD use a content-derived build ID. A future native repeatability-profile qualification MUST cover canonical rustup paths, OSO debug paths/mtimes, a tested content-derived UUID policy and any required ad-hoc signature. Random/disabled UUIDs or removing signatures only for comparison are not acceptable shortcuts. Developer ID signing, notarization and stapling remain outside scope. These controls support native measurements; they do not imply operator reconstruction is supported. -Release jobs have fresh checkouts and isolated Cargo target directories. Do not restore compiled Cargo output, `dist`, `OUT_DIR`, or `node_modules` from caches. Verified Cargo/npm download caches may be used only with archive bytes revalidated against locked checksums/integrity before fresh extraction; restored extracted source trees are not assumed to match a lockfile. Input preparation freshly checks out pinned git dependencies and verifies their locked revisions before vendoring; each offline clean build independently verifies and materializes that authenticated vendored content. No parallel Cargo process mutates a shared crate `dist` directory; each matrix job owns its checkout. +## 5. Artifact-specific SPDX composition -Record npm lifecycle execution and generated native/tool inputs separately from the package archive integrity. `npm ci` authenticates selected archive content under the lockfile, but permitted install scripts execute code and may generate or modify installed files; it does not make every resulting file byte-identical to the archive. Review and record the required lifecycle behavior, including esbuild's native tool setup, rather than disabling all scripts and silently breaking the selected bundler. +**R-SBOM.** A repository-owned generator, `scripts/release/generate-sbom.py`, MUST emit SPDX 2.3 JSON for each canonical executable from selected Cargo JSON build messages, explicit-target Cargo metadata/tree evidence, the JS lockfile and Rollup contribution inventory, plus declared-and-validated native inputs. Syft and a `RUSTC_WRAPPER` are not required. This settles the generator; its fixture proof precedes release enablement. -Capture expected source identities before building and ensure tracked inputs have not changed afterward. Generated output belongs in dedicated output paths. Release scripts are responsible for validating source-only directories, not just checking that `git status` reports no tracked modifications. +Coverage MUST distinguish observed Rust units, embedded JS contributors, validated native components, external runtime requirements and build materials/tools. With explicit `--target`, Cargo output layout, target kind, package ID and features MUST distinguish host build scripts/proc macros from target units, including a package built in both contexts. Metadata/tree edges MUST be reconciled with actual compiled membership; ambiguous source/unit joins MUST fail. Cargo metadata features are package-level unions, so effective per-unit features MUST come from compiler messages and explicit-target output context. This is a conservative component inventory, not a promise to recover an exact compiler unit dependency DAG from package metadata. Uncompiled dev-only packages MUST NOT be claimed as runtime contributors. Compiled code later eliminated by linking MAY remain in the conservative inventory; exact final function reachability is not claimed. -### 6. Artifact-specific SBOM generation +The Rollup hook MUST record package/source contributions and virtual/plugin-generated origins. Final bundle hashes MUST be computed after Vite/esbuild transformations. Type-only imports and modules eliminated by Rollup MUST NOT create runtime components. Later minification can remove more code; contribution evidence MUST state this conservative limit. The embedded Prebid shim is covered; a type-only reference to externally distributed `prebid.js` MUST NOT claim that bundle is embedded. Build-only npm dependencies and native bundler tools remain build material even when called dev dependencies. -The generator choice is **pinned Syft with a repository-owned build composition adapter**, emitting SPDX 2.3 JSON. The adapter is release tooling, not a new runtime dependency or a general attestation framework. It reconciles Syft package/license metadata with the packages and files observed in this artifact's build. +Native records MUST bind origin, version/revision, source or linked-library digest, selection policy and validation evidence. The current CLI graph includes AWS-LC, ring and bundled SQLite. The release recipe MUST set `AWS_LC_SYS_USE_SYSTEM=0` and reject conflicting OpenSSL/pkg-config discovery overrides, or explicitly select and digest a reviewed system library. Target-suffixed AWS-LC selectors MUST also be rejected or explicitly selected; a target-specific value can override generic `AWS_LC_SYS_USE_SYSTEM=0`. The recipe MUST set `LIBSQLITE3_SYS_USE_PKG_CONFIG=0` and reject unreviewed SQLCipher/loadable-extension selection; a nonzero pkg-config selector can bypass SQLite's bundled feature. It MUST validate SQLite bundled selection and capture actual native outputs/link directives; a Rust invocation graph alone cannot discover these components. Dynamically linked platform libraries MUST be reported as external requirements, not embedded bytes. Rust sysroot identity MUST also be recorded. -The implementation pins Syft's exact version and verified download digest in a reviewed release-tools manifest. The release records that pin and the composition adapter's source revision. Generator upgrades require fixture and schema validation; a floating installer or `latest` version is not permitted. +SPDX root checksum MUST equal the final executable SHA-256. Package IDs MUST include source/revision or installation location where name/version is ambiguous. SPDX relationships MUST distinguish artifact contributors from build tools/materials. Modules compiled as inputs but absent from the final executable MUST remain build material rather than be claimed as embedded JS. Raw substring presence and known source/link behavior supply a conservative retained-byte check for the current uncompressed strings; they do not establish function reachability. Cargo manifests/license files and npm metadata supply licenses; unknown licenses MUST be `NOASSERTION`. Schema, unique IDs, endpoints, coverage and the attestation predicate-size limit MUST be validated; an oversized or incomplete SBOM MUST block publication. Each published SBOM MUST equal the parsed document carried by its artifact's SBOM attestation. -#### Cargo composition +## 6. Build facts and #161 handoff -Run the selected release build with Cargo JSON messages and fresh output. Preserve `compiler-artifact` and `build-script-executed` evidence, package IDs, features, target kinds, and native link declarations. Correlate these with locked Cargo metadata for both the artifact target and build host; host-built proc macros and build dependencies must not disappear because metadata was filtered only to the wasm target. +**R-FACTS.** One immutable build-fact mechanism MUST reconcile #1213. The recipe MUST supply `TRUSTED_SERVER__GIT_VERSION` as the approved full source commit after sanitation, and release builds MUST reject missing/malformed values and prohibit ambient `.git` tag/branch fallback. Development fallback from #1213 MAY remain outside release execution and MUST be recorded as a development input. -Cargo JSON messages establish compiled membership, but do not themselves expose compile-unit dependency edges or a compilation target triple. Capture actual compiler invocations through a reviewed, allowlisted `RUSTC_WRAPPER` that forwards the unchanged invocation and exit status while recording `--target`, crate type, feature configuration, output identity and `--extern` references. The wrapper belongs to the pinned recipe and introduces no compiler flag transformations. Compiler probes are distinguished from real compilation. Join these records to Cargo package/artifact metadata to preserve each host/target compilation unit and its feature set; resolve dependency roles from the observed edges rather than assuming a workspace metadata graph is the unit graph. Ambiguous unit or source mappings fail generation. +A typed `BuildInfo` MUST derive target from Cargo `TARGET`, package version from `CARGO_PKG_VERSION` and variant/features in the owning adapter/product. Core cannot infer a Fastly feature that is not forwarded to it. Caller-supplied target/variant strings MUST NOT override these derived facts. `TEMPLATE_BUILD_DIGEST` remains separately named. -Classify normal target dependencies, proc macros, build scripts/build dependencies, and tooling separately. Packages with more than one role retain each role. Exclude dev-only packages not used by the selected release build. Include the Rust standard library/sysroot identity and vendored/native components where the build declares them, rather than treating Cargo registry packages as the entire binary composition. +The release tag/version MUST stay external in the release index, not be compiled into bytes. Two tags on one source commit can therefore refer to identical bytes. `ts --version` MUST report package version and source commit; it MUST NOT imply the workspace's current `0.1.0` is the external release version. #1213's `X-TS-Version` represents deterministic source identity in release mode; Fastly service version uses `X-TS-Fastly-Version`. Neither header is a binary checksum or remote execution proof. -This is a conservative build-derived component inventory: selected compiled components may include code eliminated by the linker. The SBOM must not claim exact function-level reachability. A complete workspace lockfile is retained as build material but is not labeled as the selected artifact's runtime dependency list. +For the selected default Fastly artifact, settings load per request. Only a future qualified reusable variant retains settings for its sandbox lifetime. #161 MUST distinguish artifact identity, observed service/version and the configuration snapshot actually used; fetching the latest store after a request does not establish that snapshot. Build facts contain no secrets or active configuration. Deployment request-signing keys/JWKS remain unrelated to release signing. -#### JS composition +## 7. Source eligibility, workflow and immutable publication -Add a release-only Rollup output hook to `build-all.mjs` that records module contributions for each IIFE. Record contributing package roots, versions, lockfile integrity metadata, bundle filenames/digests, and repository source modules using normalized relative paths. The inventory conservatively records contributions surviving Rollup rendering; Vite's later esbuild minification/tree shaking can remove additional code, so Rollup's module records do not establish exact final-code reachability. Exclude type-only imports and modules eliminated by Rollup. Compute the bundle digest from the final written file after all transformations. Virtual/plugin-generated modules and helpers introduced by later transforms identify their generating tool or repository source rather than disappear from the evidence. +**R-SOURCE.** `.github/workflows/release.yml` MUST accept protected `v*` tag pushes and strict stable/prerelease SemVer. Official tags MUST be lightweight, so the verified tag target is the source commit; annotated tags MUST be rejected, not confused with their peeled commits. Eligible commits MUST be contained in reviewed `main` or a maintainer-approved `rc/YYYYMM` branch recorded in `release-cohort.json`. The `rc/` pattern alone MUST NOT grant authority to a new branch. The tag ruleset and branch policy MUST constrain creation/update/deletion and bypass actors. -Preserve the installed npm dependency inventory separately as build material, including Vite, Rollup, esbuild, and relevant native packages. A dependency's npm `devDependency` classification does not mean it is irrelevant to production building. Package ownership mapping failures for contributing third-party modules fail the release; they do not silently create an empty JS component list. +Eligibility MUST run trusted release validation at the exact tag commit for every required AGENTS.md gate. `test.yml`/`format.yml` currently lack push coverage for RC branches, so successful push-to-main checks MUST NOT be the only path. Implementation MUST expose reviewed `workflow_call` validation jobs or equivalent explicit same-commit release jobs. It MUST enumerate required jobs and reject skipped/cancelled/PR-only/fork results. A tag immediately after merge MUST wait for these release validation jobs with a checked deadline (initially 90 minutes), then fail with a rerun diagnostic on timeout. It MUST NOT publish using an unrelated passing check name. -The Prebid shim is embedded. The external Prebid.js bundle is not. Although the JS project's lockfile includes `prebid.js`, a type-only import in the shim must not make the SBOM claim that full Prebid.js is inside the wasm. Future bundled third-party code must appear even if it is installed under a development dependency declaration. +**R-SIGN.** The local `.github/workflows/release-build.yml` at the caller's source revision MUST own build orchestration, collector validation and file attestations. Build/lifecycle execution MUST occur without OIDC/attestation writes. Signing MUST consume only validated current-run job outputs and MUST NOT compile untrusted source. The authenticated build boundary is the complete workflow invocation, not just the collector. No caller-supplied command, script, arbitrary path/repository or provenance predicate is accepted. All actions MUST be SHA-pinned; tools MUST have exact version/digest pins in `scripts/release/release-tools.json`. -#### Composition and validation +| Job | Permissions | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Eligibility/validation/build/compare | `contents: read`; `actions: read` / `checks: read` only for actual validation/artifact lookup needs | +| Validated artifact and index signing | `contents: read`, `id-token: write`, `attestations: write`; `actions: read` if collecting outputs | +| Draft upload/final publish/reconciliation | `contents: write`; no OIDC, provider credential or repository settings token | -Syft catalogs the installed/build input material to supply package identities, checksums, and supported native/license metadata. The composition adapter enriches Rust licenses from Cargo manifests and available license files rather than assuming Syft's Rust catalogers provide them. It selects and relates the metadata using the Cargo and bundle observations above, adds first-party and sysroot/native records, and emits one SPDX document rooted at the final artifact's digest. Joins include Cargo source identity/revision and npm installation/lockfile location; name and version alone cannot distinguish every package source. +File attestations MUST NOT request `artifact-metadata: write`; no container storage record is being created. Each canonical artifact MUST get two pinned `actions/attest` invocations: build provenance with the executable and metadata subjects, then SPDX mode with the executable subject and published SBOM as predicate. `sbom-path` does not also create separate build provenance. The collector MUST verify all bundles before index signing. -Use explicit SPDX relationships to distinguish components used in the artifact from build dependencies/tools; preserve evidence roles in the companion build-evidence file where SPDX cannot express the full build selection. Dynamic platform libraries are declared external requirements, not falsely represented as vendored bytes. Missing license information uses `NOASSERTION`, not an invented license. Native inputs requiring manual declarations are captured in the reviewed recipe and validated against build evidence. +**R-PUBLISH.** Maintainers MUST enable immutable releases and active tag rules before first publication and record approval of release authority. No `RELEASE_SETTINGS_READ_TOKEN` is introduced. A tag-scoped concurrency group MUST serialize publication without cancelling a publishing run. The publication job MUST create/resume a matching draft, upload the complete asset set, re-download and verify it, publish, then read `immutable == true` using `GITHUB_TOKEN` and verify the GitHub release attestation. It MUST NOT report success before that final check. -Validate SPDX schema, unique component IDs, package sources, relationship endpoints, exact root artifact digest, and coverage against the observed graphs. Reject a document larger than the attestation action's supported predicate size rather than publishing an unattested fallback. +If a release is confirmed mutable, the job MUST mark publication failed, remove that still-mutable release if authorized by the workflow's release-write role, and raise a release incident with the result. An indeterminate response MUST trigger reconciliation, not blind deletion. This is detection/recovery, not a guarantee that a transient mutable publication was never visible; consumers require immutable signed membership and therefore reject it. Published immutable releases MUST NOT be overwritten or retagged. Defects require an advisory and new version. -A fixture proof is an implementation prerequisite: target-specific Cargo selection, host build dependencies, a real third-party JS contribution, the Prebid type-only case, and native component declarations must all be represented correctly. If the chosen generator cannot meet that contract, update this approved specification in the implementation PR before changing format or generator. The generator choice is settled; its adequacy is verified rather than assumed. +A rerun MUST refuse an already published tag. A partial draft MAY resume only when source/tag and all existing asset digests match the intended set. Conflicting bytes require explicit recovery. A failed optional CLI comparison is reportable; a failed required profile leaves no published release. -### 7. Release workflow and publication authority +## 8. Distribution schema and evidence graph -`.github/workflows/release.yml` handles protected tag pushes and release publication. It accepts only validated SemVer `v` tags, resolves the peeled commit, requires it to belong to reviewed `main` history, and requires successful project checks for that exact commit. An annotated tag's object ID is not substituted for the source commit. +**R-INDEX.** The normative [release-index v1 JSON Schema](schemas/release-index-v1.schema.json) MUST be evaluated with the JSON Schema Draft 2020-12 dialect and defines closed kinds, roles, targets, variants, profiles and capabilities. Producer and consumer MUST additionally require `recipe_source_commit` and `cohort_source_commit` to equal the independently approved `source_commit`, and enforce exact cohort membership, unique safe asset names, expected package/target/variant/profile combinations and equality of each size/digest with actual bytes. Unknown schema versions, duplicate JSON keys, duplicate/conflicting identities, traversal names, unknown capabilities and mismatched JS inventories MUST fail. No executable asset is an archive in this cohort. -Eligibility uses reviewed workflow-file identities, not arbitrary successful check names. Require completed `success` runs of `test.yml` and `format.yml` for the exact peeled commit in this repository, with `push` to `main` as the accepted event/ref. Clippy is part of `format.yml` at the baseline. Enumerate the required non-optional jobs in the recipe; skipped, cancelled, unrelated, fork, PR-only, stale, or still-running results do not satisfy the gate. The recipe must account for every AGENTS.md CI requirement. Gates whose workflow is path-filtered run as explicit blocking release-validation jobs when there is no eligible exact-commit result. A tag on an older reviewed commit is eligible only if that commit's required checks can still be established or rerun through the trusted validation path. +Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, `tsjs-inventory.json` and `SHA256SUMS`. Each executable has `.spdx.json`, `.provenance.sigstore.json`, `.sbom.sigstore.json`, `.build-evidence.json`, `.build-recipe.json` and `.rebuild-check.json`. The index MUST name and digest companions and the JS inventory. It MUST NOT include its own digest/bundle; those identities are external. `SHA256SUMS` MUST cover every other published asset, never itself, and is only a convenience until authenticated by release membership. No `release-inputs.tar.zst` is promised here. -The workflow invokes local `.github/workflows/release-build.yml` at the same source revision. The reusable workflow owns input preparation, the fixed reviewed matrix for the selected cohort with separate A/B build jobs, comparison and attestation jobs, and its own index collection/signing job. Signing jobs consume validated current-run outputs only after every required reproducibility comparison succeeds; build/lifecycle-script execution receives no signing permissions. The caller publication job does not sign the index. The reusable workflow does not accept arbitrary shell commands, script paths, artifact paths, repository overrides, or caller-generated provenance predicates. +| Document | Version-1 field contract / status | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `release-cohort.json` | Producer policy: `schema_version` integer 1; `cohort` fixed string; `eligible_branches` unique exact branch strings; `artifacts` exact schema artifact combinations and required profiles. Source-controlled, never selected by downloaded metadata | +| `release-tools.json` | Producer policy: `schema_version` 1; `tools` sorted records of name/version/SHA-256/acquisition identity; `environments` checked image/SDK identities; `recipes` script paths/digests and effective configuration | +| `tsjs-inventory.json` | `schema_version` 1; `source_commit` 40 lowercase hex; `modules` sorted unique records with `id`, `filename`, `size` nonnegative integer, `sha256` 64 lowercase hex and contribution records; tool/material records | +| `.build-evidence.json` | `schema_version` 1; `source_commit`, `target`, `variant`, `artifact_sha256`; `js_inventory_sha256`; `prepared_modules` sorted input records and `compatibility_tests` records of actual OS/architecture/environment/outcome; `embedded_modules` sorted unique records of `id`, `filename`, nonnegative integer `size` and 64-character lowercase hex `sha256`, observed in the final executable and matched to the shared inventory; absence after linking is explicitly recorded, while `OUT_DIR` copies are prepared input evidence; `inputs`, `rust_units`, `js_contributors`, `native_components`, `external_requirements`, `tools` arrays containing identity/digest/role evidence from R-INPUTS/R-SBOM | +| `.build-recipe.json` | Informational: version 1; recipe source commit/script/tool identities and selected effective settings. MUST NOT be executed as a supplied program | +| `.rebuild-check.json` | Informational to consumers: version 1; profile, recipe/source/JS digest; A/B run/job/artifact IDs and transfer digests; environments, final size/SHA-256 or bounded failure status; outcome enum `matched`, `mismatched`, `failed`, `unavailable` | +| Operation record/receipt | Private lifecycle observation under R-RECOVERY, not a public claim schema. #161 owns any future authenticated receipt use; no normative `verified-publication.json` | -The initial supported assurance is SLSA Build Level 2. Level 3 requires a separately reviewed isolation design in which build execution cannot subvert provenance generation; moving steps into a reusable workflow alone does not satisfy that requirement. The early reusable boundary keeps that upgrade separate from runtime and publisher interfaces. +These JSON files MUST use UTF-8, reject duplicate keys/nonfinite numbers and use safe relative asset names. Digests bind exact distributed file bytes; verifiers MUST NOT reserialize before hashing. Arrays with set semantics MUST have deterministic order. Producer tooling and the Trusted Server wrapper MUST implement strict parsed SBOM-document equality (object key order ignored, array order significant, duplicate keys rejected); it is not substituted for file-byte digest checks. gh verifies a predicate and returns it; it supplies no SBOM-file comparison operation. Duplicate keys MUST be rejected before signing as well as before consumer comparison. Informational descriptors/measurements remain authenticated companions, but consumers MUST NOT execute them or infer independent trust from their contents. -All external actions are pinned by full commit SHA with a readable version comment. Binary tools are pinned and downloaded with verified identity/checksum. Build runners are GitHub-hosted. PR workflows, forks, and arbitrary manual-dispatch refs cannot publish official artifacts. +**R-EVIDENCE.** Every successful verification MUST establish all equalities below: -| Job responsibility | Permissions | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Eligibility and validation | `contents: read`; `checks: read` and `actions: read` only where needed to verify exact-commit CI results | -| Settings capability preflights | `contents: read` for `GITHUB_TOKEN`; isolated repository administration-read credential described below | -| Prepare inputs and clean builds A/B | `contents: read`; no OIDC or attestation writes | -| Compare and attest validated builds | `contents: read`, `id-token: write`, `attestations: write`, `artifact-metadata: write` | -| Collect, validate, and attest index | Same attestation permissions; only authenticated artifacts from the current release run | -| Draft preparation and final publish | Separate jobs with `contents: write`; no OIDC signing, settings credential or deployment credentials | +| Edge | Required equality/check | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Local policy → index | Repository, exact tag/source commit, cohort, package/kind/role/target/variant/profile/capability equal the approved local contract; recipe/cohort revisions equal approved source, and producer policy identity matches the locally computed current-policy identity or the trusted historical-policy allowlist | +| Index provenance → index | Exact index subject SHA-256; approved certificate/source/builder/caller constraints | +| Index → executable/companions/JS inventory | Exact names, lengths and SHA-256; no unlisted code or conflicting identity | +| Artifact provenance → executable/metadata | Expected executable, SBOM, build evidence, recipe and rebuild-check all present as exact subjects; no extra unapproved subjects | +| SBOM attestation → executable/document | Executable subject SHA-256; SPDX predicate equals published parsed SPDX; SPDX root checksum equals executable | +| Build evidence → JS inventory | Prepared input digest/table equals shared inventory; each claimed final retained module matches that inventory and per-artifact final-byte evidence | +| GitHub release evidence → distribution | Verified release statement binds repository/tag/target and consumed signed subject names/digests; signed index sizes equal actual bytes and REST asset sizes (release subjects have no size field) | +| Consumer → action | Approved source, local operation and service/environment/context before constructing the action-scoped verified value | -Declare read-only defaults and grant writes per job. Reusable-workflow callers grant only the permissions required by the called jobs. No platform credentials, operator configuration, or publisher secrets enter the release workflow. +The recipe and rebuild records are authenticated for audit, but consumers do not rerun a project-controlled comparison or treat its JSON outcome as an independent authorization authority. The reviewed producer workflow enforces the profile; consumers enforce its identity and selected profile. -For each artifact, invoke SHA-pinned `actions/attest` twice after the comparison gate: once for SLSA provenance with explicit canonical-artifact/SBOM/build-evidence/recipe/rebuild-check subjects, and once with the canonical artifact subject plus `sbom-path`. Supplying `sbom-path` selects SBOM mode; it does not also generate the separate provenance claim. Keep the returned Sigstore bundles as named release assets. +## 9. Fixed verifier policy, bootstrap and online commands -Before publication, the collection job verifies all provenance/SBOM bundles with the expected reusable signer, source ref and commit, and hosted-runner policy. It verifies that each signed SPDX predicate corresponds to the published SBOM's parsed document, that all artifact/build-evidence/SBOM/recipe/rebuild subject digests match, and that the full matrix has successful consistent rebuild records. It then creates and attests the release index and retained input archive. +**R-POLICY.** `crates/trusted-server-cli/release-policy.json` MUST be a tracked, embedded policy with `policy_version` integer and an external SHA-256 of its exact bytes. Only a reviewed CLI release MAY update it. Index `policy_version`/`policy_sha256` identify the producer policy tracked at `source_commit`, not a downloadable replacement for the consumer policy. The consumer MUST match that pair to independently trusted source-policy bytes, then accept either its own policy version and externally computed exact-byte digest, or a differing producer identity in a separately embedded reviewed historical allowlist. The current policy MUST NOT contain its own SHA-256 or be required to list itself; this avoids a self-hash cycle. Its own acceptance constraints always remain in force. New CLI policies MUST explicitly retain or retire historical identities; the index cannot pick its verifier policy by declaring a hash. Downloaded indexes, operator configuration, roots or arbitrary forwarded gh flags MUST NOT redefine it. The policy MUST fix GitHub host/repository numeric IDs, certificate identities/issuer, source/builder/caller semantics, supported artifact/profile/action combinations and resource bounds. Automation MUST also approve a source commit/tag; an index cannot approve its own source. -The draft preparation job creates a draft, uploads the complete expected asset set and checksums, re-downloads the draft assets, and validates their digests/evidence. A repeat settings preflight depends on successful draft preparation, and a separate final publication job depends on that preflight. Only then does it publish. Immutable releases must already be enabled; the initial settings preflight checks the repository capability before builds begin and the publication job confirms the resulting release is immutable afterward. If the setting cannot be established, the workflow stops without publishing. +The initial verified tool contract is GitHub CLI **2.97.0** and `actions/attest` **4.2.2** (SHA `1e69f48acb82d1966a394da916b4c1698aa569d6`). Implementation MUST record platform gh binary digests in `release-tools.json` and qualify this exact version. Other versions MUST fail with a compatibility diagnostic until a reviewed policy/tools update qualifies them. This is an exact supported version, not an unspecified minimum. Checking the version string is not proof that an executable is trustworthy. -The immutable-release settings API requires repository administration-read access, which the ordinary `GITHUB_TOKEN` permission set above does not provide. Configure a repository-scoped read-only settings credential as `RELEASE_SETTINGS_READ_TOKEN`, with administration-read and no write permissions. It is available only to isolated settings preflight jobs that execute reviewed workflow logic without checkout/build scripts; build, signing and publication jobs never receive it. The initial preflight and a repeat preflight immediately before publication require a successful settings response with `enabled: true`; missing credentials, denied access, disabled state and indeterminate responses fail closed. Record the read result without recording the token. This workflow does not enable or disable immutability itself. A post-publication immutable-state check detects a settings change racing the last preflight and reports it as a release incident, not a successful immutable publication. +| Authenticated expectation | Required value | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| Repository / owner IDs | `950860906` / `82903538`, with `IABTechLab/trusted-server` on `github.com` | +| Certificate SAN / provenance builder ID | `https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/` | +| Certificate issuer | `https://token.actions.githubusercontent.com` | +| Source ref / source digest | Exact `refs/tags/` / independently approved 40-character commit | +| Build signer digest | Same source commit for this same-revision local reusable workflow | +| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | | +| Caller / external parameters | Exactly `workflow: {ref: "refs/tags/", repository: "https://github.com/IABTechLab/trusted-server", path: ".github/workflows/release.yml"}`; no arbitrary inputs/commands | +| Source resolved dependency | `git+https://github.com/IABTechLab/trusted-server@refs/tags/` with `gitCommit` equal to the approved commit | +| Event / runner | `push` / `github-hosted` | +| Invocation | Repository Actions run/attempt identity consistent across the collected evidence; no cross-run substitution | -Use a tag-scoped concurrency group with no cancellation of a publishing run. Re-running an already published tag refuses publication. A partial draft may be resumed only when its tag/source and every existing asset digest match the verified intended set; conflicting bytes require explicit draft recovery, never silent replacement or movement of a published tag. All mandatory matrix failures leave an unpublished draft or no release. An API uncertainty after publication is reconciled by reading release state, not by creating another release. +Certificate-backed fields MUST be checked using the pinned gh verifier. Build-type/caller/external-parameter/source-material/subject semantics MUST additionally be checked in successfully verified provenance, not merely inferred from certificate success. Unexpected required structures or missing constraints MUST fail. Policy fixtures MUST use actual output from the pinned action/toolkit; upgrading it requires explicit fixture review. A future separately protected Level 3 builder changes the signer identity: a reviewed policy update MUST define which CLI versions and releases accept it, document the migration and retain verification of supported historical identities. There is no distinct builder/source revision in today's local workflow layout. -Administrative prerequisites are part of rollout: immutable releases enabled; active `v*` tag rules limiting creation/update/deletion and bypass actors; approved workflow changes; and maintainers responsible for release authority under project governance. These settings are verified evidence, not effects implied by committing workflow YAML. +For both provenance and SPDX proofs, post-success gh JSON certificate checks MUST require `sourceRepositoryIdentifier`, `sourceRepositoryOwnerIdentifier`, `buildConfigURI`, `buildConfigDigest`, `buildTrigger` and `runInvocationURI` to match the numeric repository IDs, exact caller `release.yml@refs/tags/`, approved commit, `push` event and allowed shared run/attempt above. These fields are flattened under `verificationResult.signature.certificate` in the pinned verifier. Provenance `runDetails.metadata.invocationId` MUST equal the certificate run URI. SPDX has no SLSA workflow fields; it uses these certificate constraints plus subject/document checks, not an invented provenance predicate. -### 8. Online verification policy and commands +**R-BOOTSTRAP.** Consumers MUST obtain gh through a trusted installation channel, use it plus their approved policy to verify the downloaded `ts` executable before first execution, then let verified `ts` drive gh. The gh executable MUST be resolved once from trusted PATH (or an explicitly configured trusted absolute path) and retained as the invocation target. A source-built CLI can run checks but has no official binary/policy assurance until its bytes independently verify. A compromised operator PATH/host remains outside the guarantee. -The trusted source repository is `IABTechLab/trusted-server`. The signer is `IABTechLab/trusted-server/.github/workflows/release-build.yml`, because the reusable workflow contains the attestation action. The source ref is the exact selected `refs/tags/` and the source digest is its reviewed peeled commit. The expected GitHub OIDC issuer is `https://token.actions.githubusercontent.com`. +Online operations require GitHub authentication even for public artifacts. The integrated verifier MUST accept only an explicitly supplied `GH_TOKEN` or, as fallback, `GITHUB_TOKEN`, for a read-only public-repository request. It MUST pass the selected credential only as `GH_TOKEN` to the child, clear `GH_HOST`, enterprise tokens, inherited `GH_CONFIG_DIR` and other gh overrides, and set isolated private `GH_CONFIG_DIR` and `XDG_STATE_HOME` directories, a reviewed nonempty `HOME` and disabled prompts. Configuration isolation alone does not isolate gh telemetry/device state; an omitted home/state path can write relative to the current directory. Stored `gh auth login` credentials are not silently inherited; the diagnostic MUST explain the explicit-token requirement. Provider credentials MUST NOT reach gh. Normal direct gh commands can use their own trusted login configuration, but integrated behavior follows this narrower contract. The child environment MUST otherwise use reviewed OS/runtime variables and no dynamic-loader injection settings. -The CLI's release policy fixes GitHub host `github.com`, repository, signer, issuer, supported adapter/product roles, artifact kinds, targets/variants, runtime contracts, consumption capabilities, predicate types, and hosted-runner requirement. Operator configuration cannot weaken those values. The checked subprocess uses explicit host/repository selection, fixed verifier arguments, and an approved GitHub CLI executable/version; it clears unapproved host/configuration overrides and accepts no forwarded verifier arguments. A future reviewed builder revision policy may additionally pin `--signer-digest`; it must distinguish the reusable workflow revision from the application source revision. +**R-BOUNDS.** Each gh child MUST have a 120-second wall-clock deadline, at most 128 MiB of JSON stdout and 1 MiB of stderr; an integrated verification attempt MUST have a 15-minute overall deadline. Before parsing/invoking gh, index/policy/inventory files MUST be at most 1 MiB, each bundle/build companion at most 32 MiB, SPDX SBOM at most the pinned action's 16 MiB limit, and trusted-root snapshot at most 1 MiB, and each executable at most 512 MiB. The integrated verifier MUST verify one retained, membership-checked bundle per invocation and accept exactly one matching attestation result; JSON output includes both bundle and parsed predicate, so its bound is larger than the input bound. Metadata nesting MUST be limited to 64 levels and the cohort to four executables with their declared companions. Timeout, cancellation, oversize input/output, parser ambiguity, failed exit or incomplete JSON MUST fail, terminate/reap children and construct no `VerifiedArtifact`. Bounds can change only through reviewed policy. Diagnostic output MUST exclude credentials and secret context. -The release index is authenticated before its target/variant/digest fields are trusted. Online consumers validate immutable release membership, expected tag/source identity, index provenance, artifact provenance, the artifact's SPDX attestation, and authenticated consistent recipe/rebuild records. The project workflow's rebuild record is observed comparison evidence, not an independent witness endorsement. Operator reconstruction additionally verifies the retained input archive before extraction. `SHA256SUMS` is a convenience check only until authenticated through the release or independently signed evidence. +**R-PUBLICATION.** Online verification MUST combine pinned gh signature verification with explicit REST and verified-statement policy checks; successful gh exit statuses alone are insufficient. `release verify-asset` matches the file digest even when its local name differs, does not check raw tag type or the API immutable flag, and does not authenticate an asset-size field. The wrapper MUST compare signed subject names/digests, independently approved source/type, REST immutable state and index/actual/API lengths. Online verification MUST verify the GitHub immutable release, confirm its lightweight tag object is a commit, match the signed release target to the approved commit, authenticate index provenance before using its fields, verify R-EVIDENCE, and verify immutable membership for every consumed asset. Successful verification of one predicate MUST NOT stand in for all predicates. Only then, after local action/context authorization, MAY the library construct private `VerifiedArtifact` with retained bytes. No provider write, secret/config creation or build hook is allowed during verification. -The following uses a fictional future release tag. The reviewed commit is obtained from the verified immutable release record and the consumer's release approval policy; a value read from an unauthenticated JSON index is insufficient. +The following fictional release example shows the primitive commands. They require the approved gh version and authentication. `` comes from trusted consumer approval checked against the signed lightweight release target, not from the downloaded index. Direct `--signer-workflow` remains compatible with #1235's basic evidence check, but its prefix semantics do not meet this contract's exact-identity policy; `--cert-identity` is used instead. These flags are mutually exclusive. ```sh RELEASE_TAG=v1.2.0 REVIEWED_COMMIT='' +SIGNER="https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/$RELEASE_TAG" gh release download "$RELEASE_TAG" \ -R github.com/IABTechLab/trusted-server \ --pattern 'trusted-server-fastly.wasm*' \ - --pattern 'release-index*' \ - --pattern SHA256SUMS + --pattern 'release-index*' --pattern tsjs-inventory.json --pattern SHA256SUMS gh release verify "$RELEASE_TAG" -R github.com/IABTechLab/trusted-server -gh release verify-asset "$RELEASE_TAG" release-index.json \ - -R github.com/IABTechLab/trusted-server -gh attestation verify release-index.json \ - -R IABTechLab/trusted-server \ - --hostname github.com \ - --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ - --source-ref "refs/tags/$RELEASE_TAG" \ - --source-digest "$REVIEWED_COMMIT" \ - --deny-self-hosted-runners +verify_build() { + gh attestation verify "$1" \ + -R IABTechLab/trusted-server --hostname github.com \ + --cert-identity "$SIGNER" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 --format json # allow-domain: slsa.dev +} -gh attestation verify trusted-server-fastly.wasm \ - -R IABTechLab/trusted-server \ - --hostname github.com \ - --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ - --source-ref "refs/tags/$RELEASE_TAG" \ - --source-digest "$REVIEWED_COMMIT" \ - --deny-self-hosted-runners +verify_build release-index.json +verify_build trusted-server-fastly.wasm gh attestation verify trusted-server-fastly.wasm \ - -R IABTechLab/trusted-server \ - --hostname github.com \ - --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ - --source-ref "refs/tags/$RELEASE_TAG" \ - --source-digest "$REVIEWED_COMMIT" \ - --deny-self-hosted-runners \ - --predicate-type https://spdx.dev/Document/v2.3 # allow-domain: spdx.dev + -R IABTechLab/trusted-server --hostname github.com \ + --cert-identity "$SIGNER" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ + --predicate-type https://spdx.dev/Document/v2.3 --format json # allow-domain: spdx.dev +gh release verify-asset "$RELEASE_TAG" release-index.json \ + -R github.com/IABTechLab/trusted-server gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm \ -R github.com/IABTechLab/trusted-server gh release verify-asset "$RELEASE_TAG" trusted-server-fastly.wasm.spdx.json \ -R github.com/IABTechLab/trusted-server ``` -The same checks apply to each named CLI executable before installing or executing it. The integrated verifier also checks index fields and verifies every referenced companion before consuming it. A successful command checking one predicate is not treated as successful verification of all required evidence. - -`gh --format json` results are interpreted only after a successful exit. Bind source and signer constraints through certificate-backed verifier flags; do not elevate arbitrary predicate fields into independently trusted identity. Validate subject digests and expected predicate/document relationships explicitly. - -The implementation pins a supported GitHub CLI version in the release tools manifest and documents that version as the minimum tested verifier. Missing commands, incompatible JSON output, failed network access, or partial results fail closed; they do not fall back to checksums alone. - -### 9. Offline verification - -An online preparation step saves the release assets, their bundles, and a trusted-root snapshot acquired through GitHub CLI's trusted-root mechanism. Trust roots and the consumer's approved release tag/commit policy must be transported securely; roots included by an arbitrary artifact supplier are not authoritative. - -Provide a read-only `ts release verify --asset --output-evidence ` export using the same fixed verification library as deployment. It performs the complete online policy without provider credentials or writes. Only after every check succeeds does it save the verified assets/bundles, roots and version-1 `verified-publication.json`, and report that record's SHA-256 plus the verified source commit. Its record fields are the publication bindings listed below; it is a consumer observation rather than another release asset or publisher claim. Partial exports cannot be mistaken for successful verification. The consumer approves and transfers the resulting record pin and roots through its trusted channel. - -For the proposed export interface: - -```sh -ts release verify v1.2.0 --asset trusted-server-fastly.wasm \ - --output-evidence ./release-evidence -``` - -This export command is new work, not available at the baseline. Its output enables the offline deployment example below without requiring operators to hand-author a record asserting that checks passed. +The full R-EVIDENCE checks, JSON Schema validation and membership checks for remaining companions/JS inventory are still required; these commands alone do not construct a verified capability. The implementation MUST provide `ts release verify --asset --expected-source-commit ` as a read-only complete online policy check, using the same verifier library as deployment. It MUST report policy version/digest, source, asset digest and required profile, and distinguish optional comparison outcomes. It does not export an offline publication-authorization record. -The native GitHub CLI can also prepare a trusted-root snapshot: +## 10. Offline evidence limits -```sh -gh attestation trusted-root --hostname github.com > trusted_root.jsonl -``` - -The published bundles avoid dependence on the GitHub attestation API during offline verification. `gh attestation download` is an alternative online export, not an offline retrieval step. +**R-OFFLINE.** Offline build verification MUST use securely transported roots, an independently approved source commit and artifact SHA-256, and published provenance/SBOM bundles. Roots or digest pins supplied alongside arbitrary evidence MUST NOT become trust inputs. Preparation MAY obtain a root snapshot with `gh attestation trusted-root --hostname github.com`; artifacts/bundles are downloaded online. A file-mode bundle verification does not need GitHub authentication. Verification MUST work with network access denied and apply the same fixed certificate/source/predicate bounds. ```sh RELEASE_TAG=v1.2.0 REVIEWED_COMMIT='' - -gh attestation verify release-index.json \ - -R IABTechLab/trusted-server \ - --hostname github.com \ - --bundle release-index.provenance.sigstore.json \ - --custom-trusted-root trusted_root.jsonl \ - --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ - --source-ref "refs/tags/$RELEASE_TAG" \ - --source-digest "$REVIEWED_COMMIT" \ - --deny-self-hosted-runners +SIGNER="https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/$RELEASE_TAG" gh attestation verify trusted-server-fastly.wasm \ - -R IABTechLab/trusted-server \ - --hostname github.com \ + -R IABTechLab/trusted-server --hostname github.com \ --bundle trusted-server-fastly.wasm.provenance.sigstore.json \ --custom-trusted-root trusted_root.jsonl \ - --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ - --source-ref "refs/tags/$RELEASE_TAG" \ - --source-digest "$REVIEWED_COMMIT" \ - --deny-self-hosted-runners - -gh attestation verify trusted-server-fastly.wasm \ - -R IABTechLab/trusted-server \ - --hostname github.com \ - --bundle trusted-server-fastly.wasm.sbom.sigstore.json \ - --custom-trusted-root trusted_root.jsonl \ - --signer-workflow IABTechLab/trusted-server/.github/workflows/release-build.yml \ - --source-ref "refs/tags/$RELEASE_TAG" \ - --source-digest "$REVIEWED_COMMIT" \ - --deny-self-hosted-runners \ - --predicate-type https://spdx.dev/Document/v2.3 # allow-domain: spdx.dev -``` - -Offline index verification and digest checks bind the artifact/companions to the signed distribution index. A signed build index can also exist before publication, so it alone does not establish official release membership. Official offline `ts deploy --release` additionally requires a securely transferred record of previously successful online immutable-release and asset verification. That record binds the repository, tag, peeled source commit, release-index digest, selected artifact and companion digests, verification policy, and observation time. It is an explicit trust input from the consumer's approved online preparation process, protected by that process's authenticated evidence-transfer channel. Store its bytes as `verified-publication.json` in the evidence directory and require `--publication-record-sha256` supplied independently through that approved channel; the verifier matches the record's exact bytes to this pin before trusting its fields. A checksum copied from the supplied evidence directory is insufficient. Do not describe this consumer-approved record as an offline-verifiable GitHub release attestation unless a supported verifier actually verifies that evidence. - -The offline profile does not claim a fresh GitHub release-state lookup or fresh revocation information. Online `gh release verify-asset` has no assumed offline replacement in this design. Standalone bundle verification without the transferred publication record remains useful build/distribution verification, but cannot produce an official-release `VerifiedArtifact` for deployment. The consumer's policy determines how old a transferred record may be; securely supplied approval and roots remain required. - -Offline artifact verification also does not make platform deployment offline: uploading still requires provider access. The automated offline verifier is tested with network access denied to establish that no hidden attestation/root lookup is required. - -### 10. Verified Fastly artifact deployment - -#### Operator interface - -Add release-artifact selection to the existing `ts deploy` command with a Trusted Server argument wrapper that flattens the existing EdgeZero deployment arguments and adds the release/evidence/receipt flags. Source deployment still delegates to the existing EdgeZero path; release deployment calls the new typed prebuilt-package path after Trusted Server verification. The proposed public flow is: - -```sh -ts deploy --adapter fastly --service-id '' \ - --release v1.2.0 --receipt deployment-receipt.json - -ts deploy --adapter fastly --service-id '' \ - --release v1.2.0 --staging --receipt staged-deployment-receipt.json - -ts deploy --adapter fastly --service-id '' \ - --release v1.2.0 --artifact ./trusted-server-fastly.wasm \ - --offline --evidence-dir ./release-evidence \ - --expected-source-commit '' \ - --publication-record-sha256 '' \ - --trusted-root ./trusted_root.jsonl --receipt deployment-receipt.json + --cert-identity "$SIGNER" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ + --predicate-type https://slsa.dev/provenance/v1 --format json # allow-domain: slsa.dev ``` -These are new interfaces to implement, not commands available at the baseline. - -`--release` enables the fixed official-release policy. `--artifact` chooses local bytes but requires that policy and the selected release. `--offline` requires the local artifact, evidence directory, securely supplied root, approved source commit, and the trusted previously verified publication record described above with an independently approved `--publication-record-sha256` pin. `--evidence-dir` contains the authenticated index, all required companions, and that record when offline. The offline record and its approved pin must arrive through the consumer's approved evidence-transfer process. Online deployment resolves and verifies the immutable release record; automation may also supply `--expected-source-commit` to pin its approved revision. - -Reject unsupported adapters, missing evidence, unsupported targets/variants, conflicting deployment inputs, and artifact-related flags without `--release`. Arbitrary passthrough flags that override package, directory, source-build behavior, verification, service/version selection, or provider safety are forbidden on this route. Artifact deployment bypasses manifest build/deploy overrides; it does not run `fastly compute publish` or an operator-supplied shell command. +Apply equivalent checks to index and SBOM bundles, use SPDX predicate type for the latter, compare the independent executable pin and all authenticated companion digests, and compare the SPDX document. Offline success establishes build evidence and approved byte identity only. It does not re-establish GitHub release membership, current release state or fresh revocation. `gh release verify-asset` requires the API; `gh attestation verify` is not an offline verifier for GitHub's release-attestation certificate profile. Automated offline official deployment and a `verified-publication.json` protocol are deferred; `ts deploy --release --offline` MUST be rejected in this cohort. -Existing source-build commands remain available for development and custom distributions, with a clear diagnostic that the resulting bytes have not been established as the canonical release. The supported attested production path uses release artifacts. A local build is not equivalent merely because its source commit or compiled version string matches. +## 11. Fastly verified-byte consumption and recovery -A local build following the published reproduction recipe is eligible only when its complete digest matches the selected official artifact and the existing release/evidence checks succeed. It uses the same retained-byte/package path, never a special bypass for locally compiled code. Arbitrary local builds retain the diagnostic above. +**R-DEPLOY.** Canonical identity is SHA-256 of final `trusted-server-fastly.wasm`, not a publisher-specific tarball. The new `ts deploy --adapter fastly --release ` path MUST verify before side effects, retain the verified bytes in private owned storage and bypass source-build and manifest deploy overrides. Artifact flags without `--release`, unsupported variants/adapters, conflicting package/source arguments, arbitrary passthrough hooks and offline deployment MUST fail. A local `--artifact ` MAY be used only when its retained bytes equal the authenticated official asset and all online checks succeed. Matching source/version strings alone are insufficient. -#### Interface ownership +A delayed EdgeZero API MUST NOT block raw release publication or documented manual verified-byte production deployment. Integrated production/staging is separate completion item AC-8 below, not a prerequisite to build/sign/publish. Source deployment remains available for development/custom distributions, with an explicit diagnostic that canonical identity has not been established. -Trusted Server owns official release acquisition/verification and the `VerifiedArtifact` value. Its fields are private; callers cannot construct one by supplying a path and an asserted checksum. It owns retained bytes, SHA-256, validated release/source identity, adapter/product role, canonical kind, target/variant, runtime contract, validated member inventory where applicable, verification policy version and evidence references. Consumption requires a compatible adapter/action; a CLI executable cannot be passed as an Axum server merely because its native triple matches. Provider code cannot replace its bytes through passthrough arguments. +**R-PACK.** Packing MUST use a validated deployment-only manifest derived from the selected publisher-owned manifest and retained verified wasm in a fresh private directory. The sanitized package name MUST be nonempty and MUST NOT equal `package`; that reserved name collides with the generic archive. With pinned Fastly CLI 15.1.0 and an accepted name, `compute pack --wasm-binary` writes `pkg/.tar.gz` and also an unrelated `pkg/package.tar.gz`; it can sweep pre-existing package contents. The chosen archive MUST have exactly `/`, `/bin/`, `/bin/main.wasm` and `/fastly.toml`. Inspection MUST reject links, traversal, duplicates, extra entries or mismatched manifest/wasm bytes, with bounded extraction. The archive MUST be selected by validated name and contents, not a guessed generic filename. Recheck retained archive/member digests immediately before submission. The deployment-only manifest MUST exclude `[local_server]`, inline fixture settings/secrets, active app-config payloads and build hooks; preserve only reviewed provider envelope/resource-reference fields. The original operator manifest is not silently edited. Fastly pack and `compute validate` can succeed on non-wasm bytes and neither strips local fixtures, so they MUST NOT be used as proof of wasm validity or privacy. Producer validation MUST parse the actual final core module with a pinned validator and execute an application request under pinned Viceroy; `/health` alone bypasses app settings. Live provider/runtime validation remains separate. Tar metadata is nondeterministic: package SHA-256 identifies this operation's envelope, not the canonical release. -EdgeZero owns an additive typed artifact deployment request/result and platform lifecycle. Its request accepts an explicitly prepared package and expected digest with adapter/service/staging parameters and validated deployment context: selected platform manifest, declared logical config-store IDs, physical resource mappings, and any allowed version comment. Trusted Server checks that the context includes the configuration logical ID expected by the canonical artifact before mutation. An absent config declaration must not take EdgeZero's generic no-config staging branch and silently retain the production selector. The package and lifecycle use the same resolved context; manifest shell overrides cannot replace it. +**R-CONTEXT.** Before any provider mutation, the integrated route MUST resolve an existing service, selected base version, approved logical config ID, physical resource mapping and runtime selectors, and compare declarations with observed links/selectors on that exact version. Existing-service `[setup]` declarations alone do not provision or update bindings. New-service provisioning MUST be a separate supported operation, otherwise fail read-only. Store/config/secrets push remains separately authorized and MUST NOT be hidden in verification. -Its result reports exact observed service/version and activation or staging state. Its structured failure reports whether a provider mutation was attempted, any known service/version, the last confirmed phase, and an explicit unknown outcome when a timeout or unparseable response prevents confirmation. An error never implies that no deployment occurred. Uncertain uploads or activation require reconciliation before retry, rather than guessing a version or repeating the write. +After clone/upload, the route MUST validate actual bindings and selectors on the exact returned draft version before staging or activation. Production MUST preserve intended config mappings; staging MUST use a verified isolated config mapping and selectors rather than silently retain inherited production links. Missing logical IDs, absent links, contradictory observed selectors or incomplete isolation MUST fail before activation/stage. Staging isolation here covers app-config mappings/selectors, not automatic isolation of KV/Secret Stores or external side effects. The approved context MUST explicitly declare shared versus isolated resources, and secret validation MUST use references/presence/metadata, not pretend write-only secret plaintext can be read back. Participating operations MUST serialize at service/shared-resource scope; an environment-only lock does not cover shared production/staging resources or other operators. Mutable context must be revalidated at the applicable phase; this is not a transaction or a guarantee against concurrent provider changes. -This is additive to source deployment, not a rewrite of all `AdapterAction` execution. EdgeZero treats the Trusted Server verifier as its trusted caller; it is not assigned responsibility for recognizing IAB release policy. Official release flags, evidence acquisition and signer constraints remain in Trusted Server's argument wrapper, not in generic EdgeZero `DeployArgs`. +**R-EDGEZERO.** The pinned EdgeZero adapter interface is `execute(AdapterAction, &[String]) -> Result<(), String>`; context is string tokens and outcomes are logs. Implementing `deploy_prebuilt` requires a new additive request/result API and reviewed upstream work. Its initial scope MUST be Fastly only. The request MUST contain retained package/path/size/SHA-256, existing service ID, selected base version, production/staging mode and resolved manifest/store context. The result MUST contain service ID, exact version if known, last confirmed phase, whether mutation was attempted and known/unknown outcomes. -The implementation first lands the upstream capability and pins a reviewed EdgeZero revision. It must not duplicate staging cloning/selector logic in Trusted Server to work around an unconditional upstream build. If upstream support is delayed, the documented native route below permits use of released bytes, but integrated `ts deploy --release` acceptance remains incomplete. +EdgeZero MUST own concrete clone/upload/context-validation/stage/activation operations and payload integrity without implementing official release policy. It MUST NOT run `compute build`, `compute publish`, `compute update --autoclone --version=active` as a combined guessed-version path, or infer the uploaded version from whichever version is now active/highest. It MUST inspect the exact selected version and capture the version returned by its own clone/upload operation. `compute deploy` activates internally and cannot expose the required draft-binding validation boundary, so the integrated API cannot be implemented by merely wrapping its exit status. -#### Ordering and byte preservation +**R-RECOVERY.** Before first mutation, the integrated route MUST durably persist a restricted operation journal plus retained package/evidence and original resolved context. The journal MUST bind a unique operation ID, policy/source/artifact/package identities, intended service/base version/mode/mappings, previous active version when observed, last confirmed phase and mutation/uncertainty state. It MUST exclude credentials, resolved secrets and active configuration payloads. Immediately before every clone, upload, selector/link write, stage or activation request, the journal MUST atomically persist and flush a pending/attempted marker and explicit uncertainty. A crash between request transmission and response MUST NOT leave a false no-mutation claim. Confirm responses in atomic durable updates through `Prepared → VersionAllocated → Uploaded → ContextBound → Staged/Activated`; allocated version and uploaded package are distinct observations. An abrupt interruption MUST leave enough durable intent/state for inspection. -1. Parse all arguments and validate provider/manifest selection without writes. -2. Acquire the release index, binary, SBOM, build evidence and bundles into a private working directory. -3. Verify identities, required predicates, release membership when online, target/variant, sizes, and all referenced digests. -4. Retain verified bytes in that directory; do not later reopen an arbitrary operator path as the upload source. -5. Pack those bytes with the selected publisher-owned Fastly manifest using `compute pack --wasm-binary` or an equivalent byte-preserving package implementation. -6. Inspect the completed archive safely and require exactly one expected regular `bin/main.wasm` member with the verified digest. Reject symlinks, traversal, duplicate members, unexpected executable payloads, and manifest/package mismatches. -7. Hash the complete selected archive, retain it privately, and recheck it immediately before provider upload. -8. Only now perform provider writes using the explicit package. Capture the exact provider service/version result. -9. For staging, preserve current version comments and isolated config selectors before marking the exact version staged, without activating production. For production, activate the exact uploaded version according to the existing deployment behavior. -10. Return a structured outcome and write the requested receipt atomically. Health checks and rollback remain explicit lifecycle commands or orchestration follow-ups; `ts deploy` does not automatically perform them at the baseline. A production rollback uses a version captured before deployment, never a guessed current active version. +After any attempted write, retained recovery inputs MUST survive temporary-directory cleanup, a failed final receipt write and process termination until explicit reconciliation or abandonment. Before the first write, failed preparation MAY clean up. The consumer MUST serialize operations for the same service and any shared resources across environments; no distributed lock is promised. EdgeZero owns read-only reconciliation of exact observed/allocated versions and uncertain writes. The operator/orchestrator owns the retry, explicit rollback or abandonment decision. Recovery MUST NOT rebuild from today's edited manifest, blindly retry activation/upload or guess a rollback target. If the first clone commits but its response is lost before the allocated version is known, reconciliation MAY remain unresolved: the endpoint exposes no documented caller operation ID/idempotency key. Candidate versions, timestamps or matching packages are not unique ownership proof. Recovery MUST preserve unknown and MUST NOT adopt, activate, delete or roll back a guessed candidate. The local operation ID is not a provider transaction ID. Local receipt output MAY summarize observations but is non-normative for #161 and proves neither publisher authority nor live execution. -No build, wasm annotation, stripping, signing, or post-build hook runs between artifact verification and upload. A Fastly provider package hash is not assumed to equal raw wasm SHA-256. Packaging verification establishes which module was submitted; it does not establish execution at every edge node. +The proposed integrated interface is new work: -Failure before step 8 makes no provider writes, configuration pushes, selector changes, or service provisioning. Failure after provider mutation reports the exact known outcome and preserves enough evidence for recovery; receipt-writing failure must not be presented as proof that no deployment occurred. +```sh +ts deploy --adapter fastly --service-id '' \ + --release v1.2.0 --expected-source-commit '' \ + --receipt deployment-receipt.json -Operators/orchestrators serialize mutating deployment and rollback operations per service, including staging. EdgeZero's staging selector twin is mutable and shared by staged versions of one service; concurrent reconciliation can race, and a selector link is not an immutable configuration snapshot. Production rollback's active-version guard is not an atomic provider precondition. Preserve the current guards and documented serialization requirement; this change does not add a distributed lock or solve external actors bypassing that serialization. +ts deploy --adapter fastly --service-id '' --staging \ + --release v1.2.0 --expected-source-commit '' \ + --receipt staged-deployment-receipt.json +``` -#### Native Fastly route during rollout +### Manual production route during integration -The pinned Fastly 15.1.0 commands support: +**R-MANUAL.** Operator guidance MUST provide verification, private-directory packing, R-PACK inspection, observed existing-service binding checks and an explicit production submission command before the first release. A lower-level verified-byte transport can use: ```sh fastly compute pack --wasm-binary ./trusted-server-fastly.wasm -fastly compute deploy --package '' \ - --service-id '' +fastly compute deploy --package './pkg/.tar.gz' \ + --service-id '' --version '' --no-default-domain ``` -Use the verification procedures first, then inspect/hash the packed wasm and selected package before the second command. Do not select an archive merely by a guessed name or assume the pack command's success proves its contents. Do not substitute `compute publish --package`; at the pinned version it still builds. `compute build --metadata-disable` is also not a byte-preserving packaging route. - -This native route does not automatically provide the integrated verifier, staging guarantees, or structured receipts. Its documentation makes those limits explicit. - -### 11. Build facts and publisher attestation groundwork - -#### Immutable build facts - -Add a small `BuildInfo` surface in core containing release version, Cargo package version, source commit, target triple, and build variant. Release recipes supply validated `TS_RELEASE_VERSION`, `TS_SOURCE_COMMIT`, `TS_BUILD_TARGET`, and `TS_BUILD_VARIANT`; build scripts track them. Missing/malformed values fail an official release. Ordinary local builds identify missing release metadata as unverified/local; they do not masquerade as an official release. - -`BuildInfo` is platform-independent static data. It imports no GitHub/Sigstore client and no publisher identity/key types. Future #161 code may report these fields, but their presence does not prove the identity of the responding binary. The embedded JS module table supplies available module IDs/digests separately from the runtime-enabled integration set. - -A wasm cannot hash its own deployed bytes through the current platform abstractions. Embedding its final whole-file checksum would create a circular digest dependency. The final digest stays in external attestations/index/deployment records. The Fastly service version remains a distinct platform identifier; existing `X-TS-Version` must not be relabeled as the release artifact identity. - -#### Deployment observations - -A requested receipt is a versioned local JSON observation record, not a publisher signature or a platform attestation. Version 1 contains: - -- Release repository/tag/source commit, adapter/product role, canonical artifact kind/target/variant and SHA-256; final module/member digests where the kind requires them. -- Verification policy version, online/offline profile, and evidence bundle digests. -- Prepared deployment-envelope/package SHA-256 and observed canonical member digests when packaging occurs; these fields are explicitly inapplicable for direct native launch. -- CLI and platform tool versions. -- A tagged adapter observation: Fastly service/version; Cloudflare account/Worker/environment, version ID and deployment ID; Spin local application/process or OCI repository/manifest digest and actual provider revision only when observed; Axum local process/listener. Record the operation/mode, known/unknown phase outcomes and observation timestamp without fabricating provider identifiers. -- Health status `not_performed` for a deployment-only operation; a later explicit check can produce its own observed result. Unknown provider state remains unknown, never inferred from command failure. -- Previous deployment version when actually captured. - -It contains no tokens, resolved secrets, complete publisher manifest, configuration document, or claimed publisher authorization. It does not assert a config digest unless the deployment operation actually observed and verified one; config push is a separate operation. Exact provider identifiers come from the corresponding operation responses, not a racy lookup of whatever revision is now active. Local-launch observations distinguish spawn, readiness, exit and signal outcomes; a PID/listener is neither a provider deployment revision nor remote execution proof. - -Store receipts with restricted local permissions and explicit caller-selected paths. A future publisher signer can reference the record's defined bytes/digest, but #161 must define its authority, validation and freshness policy. A local receipt alone is not independent evidence against the operator producing it. - -#### Future configuration and identity boundaries - -Preserve the following distinctions when #161 adds its implementation: - -| Identity | Defined meaning | Future ownership | -| --------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- | -| Artifact digest | SHA-256 of the exact canonical distribution; module identities recorded separately | Release/deployment tooling | -| Raw configuration-document digest | Exact serialized bytes, where a protocol explicitly selects those bytes | Config-attestation design | -| Canonical unresolved-data digest | Existing versioned EdgeZero envelope data hash, including secret references | Config loader plus future snapshot metadata | -| Effective settings identity | A future explicit projection of defaults, policy, selections and secret-version treatment | #161 and permission design | -| Publisher identity | Externally authorized publisher/domain/key relationship | Publisher trust design | -| Public claim digest | Exact payload bytes under a chosen signed claim protocol | #161 | - -The existing configuration envelope verifies integrity, not publisher authorization, expiry, rollback protection, or domain ownership. Its `generated_at` value is outside the canonical data digest. Do not assign it stronger semantics merely because it accompanies a hash. - -The current loader verifies the envelope and then discards envelope metadata while returning resolved `Settings`. Future #161 work should carry the verified unresolved identity alongside the actually loaded settings snapshot. This specification documents that seam but does not change settings-loading APIs or introduce a public configuration digest now. - -Fastly reusable instances retain settings for their lifetime, and Axum retains them from startup. Runtime claims must describe the snapshot used for the relevant operation, not a freshly fetched store value that the instance has not adopted. Staging selectors, physical bindings, and future permission policy are also runtime/deployment inputs, not artifact inputs. - -Do not reuse `template_fingerprint` as public configuration identity. It hashes build/cache inputs, embedded JS, and resolved settings. `Redacted` redacts formatting but serializes transparently, so its digest input includes secret values. Public secret-bearing digests can enable guessing or correlation and conflate config changes with secret rotation. - -Existing request signatures cover version, key ID, host, scheme, request ID, and timestamp. They do not bind the artifact, configuration, attestation document, or request body. A future attestation reference must be explicitly included in a versioned signed payload; adding an unsigned extension is insufficient. Future publisher/runtime signing should define key purpose and domain separation rather than making request-signing keys an implicit release or publisher authority. - -The integration registry can provide an enabled capability inventory. It is not vendor endorsement or a permission sandbox. A vendor's later endorsement must name a precise code/configuration/policy scope and be verified under the vendor's authority. - -No generic attestation framework, arbitrary predicate registry, publisher endpoint, or empty publisher implementation is added for these future requirements. The release/deployment interfaces above provide the useful groundwork without committing to those protocols. - -#### Assumptions and decisions deferred to publisher work - -| Foundation selected now | Consequence for future claims | Decision still open | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Artifact identity is external SHA-256 of final bytes | A publisher claim can reference an independently verified release subject | How a provider or other authority establishes that those bytes execute remotely | -| Publisher authorization is separate from release signing | A valid build signature does not authorize a publisher/domain | Enrollment, ownership verification, delegation, trust roots, key rotation and revocation | -| Configuration is loaded at runtime | Claims must bind the snapshot actually used, including explicit policy/secret-version treatment | Public projection, canonicalization, config authorization, freshness and rollback policy | -| Deployment receipts are local observations | Later signing must preserve their limited assurance and known/unknown states | Which authority signs, what it independently observes, and which consumers trust it | -| Claim types may evolve independently | Release index/build facts do not become a universal publisher schema | Transport, DSSE/JWT/EAT or another format, discovery, versioning and purpose-specific keys | -| Runtime state and request context can change | Static artifact identity cannot imply a fresh request/config claim | Nonces/timestamps, replay windows, request/body binding and verifier appraisal policy | -| Vendor approval names its own scope | An enabled integration is not evidence of endorsement | Code/config/policy scope, vendor signing authority and permission enforcement | - -Future designs may revise these integration seams through reviewed versions. No assumption here requires a central publisher registry, reuse of deployment request-signing keys, a particular hosting provider, or a hardware attestation mechanism. - -### 12. All-adapter artifact and execution contracts - -The following contracts are part of this design, not unspecified future artifact definitions. Their rollout is gated by adapter-specific implementation evidence. The trust policy, strict JS mode, no-operator-configuration requirement, recipe/input authentication, A/B comparisons, provenance/SBOM/index checks and online/offline publication policy apply to every enabled artifact. An adapter does not inherit another adapter's digest, SBOM, compatibility result or reproducibility proof. - -#### Release cohorts and full matrix - -A tracked, reviewed release manifest selects the cumulative cohort before tag execution. Phase 1 has the four assets in §4. Phase 2 adds five assets below, making nine canonical distributions across all four server adapters plus the CLI. The manifest records supported adapter, artifact kind, role, target/variant, recipe and operation capabilities; the authenticated release index includes its revision and selected cohort. Matrix assembly and verification use an exact expected set. A failed/missing selected artifact blocks the entire release; the workflow cannot downgrade the cohort after a failure. Historical Phase 1 releases do not acquire Phase 2 claims retroactively. An older verifier rejects unsupported kinds/capabilities rather than weakening checks. - -| Phase 2 asset | Canonical kind / package | Target / builder | Supported initial consumption | -| ---------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -| `trusted-server-cloudflare.tar.zst` | Deterministic final Worker module set / `trusted-server-adapter-cloudflare` | `wasm32-unknown-unknown`, feature `cloudflare`; pinned Linux image | Prepared Worker version upload and explicit activation | -| `trusted-server-spin.wasm` | Validated final Wasm component / `trusted-server-adapter-spin` | `wasm32-wasip2`, feature `spin`; pinned Linux image | Verified local Spin execution and verified OCI publication; remote activation only for an explicitly supported provider | -| `trusted-server-axum-x86_64-unknown-linux-gnu` | Native executable / `trusted-server-adapter-axum`, bin `trusted-server-axum` | Linux x86-64 recipe and baseline from §4 | Verified local development-server launch | -| `trusted-server-axum-aarch64-apple-darwin` | Same native executable | macOS ARM64 recipe and baseline from §4 | Verified local development-server launch | -| `trusted-server-axum-x86_64-apple-darwin` | Same native executable | macOS Intel recipe and baseline from §4 | Verified local development-server launch | - -Each receives its own standard companions from §4 and inclusion in the cumulative index/input inventory. Axum shares environment pins with the CLI, not its composition results. Cloudflare's archive itself is the attested SBOM/provenance subject, with final executable members explicitly identified and related inside the SPDX document. Recipes and rebuild records compare the complete archive and each member. Spin's subject is the final component actually submitted, including any approved release-time adaptation; raw precursor output is build material. - -This phase selection distinguishes defined architectural scope from shipping prerequisites. Phase 1 can ship before Phase 2, while Phase 2 cannot be advertised as supported merely because a matrix row exists. Phase 2 enablement requires all five additions, all shared gates and the operation-specific proofs below. A different phased split requires a reviewed manifest/spec change. - -#### Cloudflare: final module set and prepared API submission - -**Build and identity.** Release tooling bypasses the developer `build.sh`, which sources local environment files and installs tools. Run the locked SDK transformation pipeline in the controlled Linux recipe: Cargo output, matching worker-build/wasm-bindgen, SDK glue/metadata, wasm optimization, shim handling, bundling/minification and import rewriting. At the baseline, `worker` is 0.8.5 and wasm-bindgen is 0.2.126; actual release tool pins/digests come from the selected lockfile and reviewed tools manifest. Record worker-build's internal binaries and matching versions rather than assuming Cargo's toolchain covers them. Prohibit undeclared shim, optimizer/bundler, minification, execution-model and panic/nightly overrides. Prepare all required tools before the network-denied build phase; runtime tool downloads fail. - -The canonical archive contains `modules.json` and the final JS/WASM executable files. Inventory version 1 records the main module, each archive path/provider module name/media type/length/SHA-256, declared platform-provided imports and a runtime compatibility contract. Preserve names/import relationships produced by the approved recipe; do not hardcode an assumed output layout instead of validating it. Reject undeclared executable members, duplicate names, links, path traversal and missing imports. Archive order, modes, ownership, timestamps and compression are normalized by the pinned recipe. No provider account, Worker name, physical KV ID, route, secret or active app configuration is included. - -Compatibility date/flags are source-selected runtime requirements, separate from physical deployment bindings. The baseline requires `2024-09-23`, `nodejs_compat` and `cache_option_enabled`; the recipe inventory declares its supported settings and deployment rejects conflicting overrides. Changing them requires a reviewed compatibility contract, even where executable bytes remain identical. The SBOM covers final Rust/WASM, generated SDK/wasm-bindgen glue, shim/snippet or polyfill contributors, and embedded Trusted Server JS. Transformation tools retain build roles; a raw Cargo-wasm inventory alone is insufficient. - -**Verified submission.** Trusted Server verifies the archive and each retained member before constructing a `PreparedCloudflareModules` request. EdgeZero uses the established script version-upload API: `POST /accounts/{account_id}/workers/scripts/{script_name}/versions`, with explicit multipart module parts and metadata. Validate the resolved account/Worker/environment, main-module name, bindings and compatibility settings; compare actual outgoing executable parts' names/types/digests to the authenticated inventory before sending. Multipart boundaries and publisher binding metadata are deployment-envelope data, not canonical code identity. Explicit binding inheritance must fail when unresolved, rather than silently adopting missing/unknown bindings. Existing configuration push remains a separate authorized operation. - -Upload success records the returned Worker version ID. Activation is a separate request to the deployments API referencing that exact version with an explicit traffic allocation; record the returned deployment ID independently. Initial production mode selects that version at 100 percent. Do not infer activation from upload success or a later lookup of whichever version is active. Routes/domains/triggers require their own validated operation; this artifact path does not silently provision them. Reject Fastly-style `--staging` unless a separately implemented Cloudflare mode has a defined isolation contract. Serialize writes for the same Worker/environment and preserve previous deployment observations for explicit recovery. - -The selected route bypasses Wrangler build/discovery/bundling. `--no-bundle` alone does not prevent a Wrangler custom build. A later Wrangler transport may be added only after pinned-tool tests prove isolated configuration without build hooks and exact outgoing module equality; it cannot replace the chosen route based on dry-run success alone. The Beta Worker-ID API is not silently substituted for the script-name API. Receipt fields describe submitted module hashes, account/Worker/environment, observed version/deployment IDs and known/pending/unknown activation state, not independent remote execution proof. - -#### Spin: component ABI, local execution and OCI transport - -**Build migration and identity.** Phase 2 selects `wasm32-wasip2` with feature `spin` and a validated component subject. The current WASIp1 manifest/commands must be migrated together with `rust-toolchain.toml`, Cargo aliases, CI, operator examples and affected AGENTS.md commands. Align the locked Spin SDK, imports/exports/world, target and a pinned compatible Spin runtime before enablement. Pinned EdgeZero already uses WASIp2 while repository overrides use WASIp1; reconcile that owning build interface rather than relying on override precedence. Old local core-module output is not evidence that the new component contract passes. - -Each clean build validates Wasm component format and the expected SDK ABI, then tests it in the pinned runtime with the actual Trusted Server configuration/KV/secrets and a meaningful publisher request. Any componentization, adaptation, dependency composition or optimization needed before submission is an explicit release recipe step performed inside both clean builds; compare and attest the final bytes. Added component dependencies or a changed ABI require a reviewed variant, new SBOM and recipe. Do not invent a generic conversion command for incompatible SDK/target output. The SBOM includes actual linked/adapted components and embedded JS, with compiler/adapter tools separate. - -**Verified local execution.** Add `ts serve --adapter spin --release ` through the shared verifier and typed local-run context. Generate a private Spin application manifest selecting the retained verified component under the intended component ID, with explicit runtime variables, KV stores and outbound permissions. Omit build sections; prohibit watch/build flags and sanitize inherited `SPIN_ALWAYS_BUILD` and plugin selectors. Pin and directly invoke Spin with this prepared manifest. Keep operator configuration/secrets/permissions outside canonical component bytes; do not use bare-Wasm launch where it loses the required app context. Record child lifecycle and runtime readiness separately. Spin's reserved host health route is insufficient proof that Trusted Server loaded valid settings; require an application request fixture for adapter acceptance. - -**OCI publication and optional remote activation.** Prepare a Spin-compatible OCI layout locally from the retained component and a separately validated publishable app envelope using pinned tooling. The private local-run context is not directly publishable: allow unresolved secret references and runtime binding declarations, but reject resolved secret defaults/environment, active app-config documents, KV/SQLite snapshots and mounted configuration/secret files throughout the OCI config, layers, annotations and manifests. Provision those values separately through the selected runtime/provider context. Validate the entire descriptor graph with bounded parsing/extraction and reject undeclared data layers as well as executable members; no secret scan alone substitutes for this allowlist. - -Inspect all descriptors and the selected component's decompressed layer bytes before registry writes; reject undeclared dependency composition, extra executable layers or altered component bytes. Publish explicit content-addressed descriptors, capture the resulting manifest digest, and verify retrieved layer content against the canonical component before any provider activation. An OCI tag is a lookup convenience, not an immutable identity; deployment uses the captured manifest digest. Registry/app-manifest digests differ from the component digest because deployment context varies. - -`spin registry push` can build through an environment setting and can compose dependencies; omission of `--build` is not sufficient proof. The EdgeZero transport must preserve the prepared-layout contract, whether it reuses a validated Spin library or a proven pinned CLI packaging/export path. Unknown transformations reject the operation. - -`spin deploy` dispatches to a plugin; it is not a universal cloud API. Remote support requires an explicit provider endpoint, pinned plugin/tool identity, credential/context policy and capability declaration for upload, activation, stage, inspection and rollback. A supported provider must return its actual app/resource/revision identity with known/unknown phase outcomes. Unselected plugins and unsupported modes fail before registry/provider writes; never silently switch providers or activate production for a staging request. Phase 2 requires verified local run and OCI publication. Remote provider activation is a separately enabled capability with its own tests, not a prerequisite that invents a generic provider contract. - -#### Axum: native development distribution and verified local launch - -Build the named `trusted-server-axum` binary using explicit package/bin/target, production profile and `--frozen`, with strict fresh JS. Each native target independently satisfies the §3 path/time/native signature controls, A/B gate, SBOM composition and clean-runtime smoke tests. The release profile does not turn the existing development server into a supported production hosting product. Remote orchestration/container delivery remains separate. - -Add `ts serve --adapter axum --release --port ` with a Trusted Server wrapper around the current serve arguments and the shared evidence/receipt inputs. Select only an Axum executable matching the host architecture and compatibility baseline; an operator CLI executable is not an Axum payload. Verify before any child process start, retain the file privately, restore executable permission and recheck its bytes before direct execution. Bypass Cargo and manifest shell hooks. Reject inherited dynamic-loader injection and unsupported remote deployment/staging arguments; use a controlled working directory and child environment. - -Make the current runtime configuration bridge explicit: resolve the declared logical config store, selected physical name and key; read/verify the already-pushed local envelope; supply it through the existing `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` interface and explicitly selected secret environment values. Set matching runtime store-name/key selectors and secret-store mappings in the controlled child environment; the application resolves these independently from the envelope variable name. Reject inconsistent selections rather than silently loading defaults. The native process does not automatically read EdgeZero's local JSON file. No automatic config push, secret creation or new configuration protocol is introduced. Set validated `PORT`; the baseline executable binds loopback and does not consume the source launcher's adapter-port variable or parse `axum.toml` as its comment suggests. - -Report process spawn, application readiness and exit separately, forward signals and clean up the owned child after startup failure. When readiness is requested, require application `/health` success within the explicit timeout; a bound port or living process is insufficient because missing configuration/secrets currently produces a 500 router rather than process exit. Adapter acceptance additionally exercises a real publisher request and existing unsupported runtime capabilities. Receipts contain local launch identity/PID/listener/outcome, never a fabricated cloud service version, and never secret values. - -#### Shared EdgeZero seam and publisher handoffs - -Extend the Phase 1 prepared-artifact seam with concrete supported payload types: Fastly package, Cloudflare module set, Spin application/component or OCI layout, and Axum executable. Keep provider-specific contexts and outcomes typed. A closed adapter/action/payload compatibility table is sufficient; no arbitrary predicate/plugin registry or universal cloud deployment framework is required. Add cases when their implementation lands, not empty runtime variants in Phase 1. Trusted Server owns official release policy and verified artifact construction; EdgeZero owns retained payload integrity, provider transport/local process lifecycle and structured known/unknown results. Unknown kinds/actions are typed capability errors before mutation or process start. - -| Adapter | Initial supported action | Observed execution/deployment identity | Unsupported operation handling | -| ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Fastly | Verified package deployment, production/staging | Service ID and exact integer service version, activation/staging state | Existing explicit lifecycle safeguards; no arbitrary source-build passthrough | -| Cloudflare | Prepared version upload, explicit production activation | Account/Worker/environment, Worker version ID and deployment ID separately | Reject staging/unimplemented routing or rollout modes | -| Spin | Verified local run and OCI publication | Local child/runtime instance or registry manifest digest; provider app/revision only when supported and observed | Reject unselected plugins and unsupported provider modes | -| Axum | Verified local development-server launch | Local launch instance/PID/listener and lifecycle state | Remote deploy, staging and provider rollback remain unsupported | - -Publisher/runtime claims reference the appropriate artifact and observed identity without equating either with publisher authorization. All adapters preserve runtime app-config, physical bindings, secrets and variable permission policy outside canonical artifacts. Future #161 snapshot work must bind what the operation actually loaded: Fastly reusable sandbox state, Axum startup state, Spin's operation-loaded KV settings, and Cloudflare's stored binding/environment snapshot. Cloudflare route construction per request does not imply its stored `OnceCell` configuration has refreshed. No receipt asserts a configuration digest merely from a current store lookup or hashes resolved secret values. - -Standalone tsjs and publisher-selected external Prebid remain separate distributions. Embedded tsjs is covered by each containing artifact, while enabled/deferred integration selection is runtime behavior. External Prebid requires its own future bundle/configuration binding and does not inherit the adapter's SBOM claim. - -## Test plan +Run in a private directory with exactly the validated deployment-only `fastly.toml`; inspect the package before deployment. At pinned 15.1.0, `compute deploy` clones an active/locked version and activates production, has no staging mode, and can successfully skip deployment when the selected version already has the same package hash. No upload/new version/activation can be inferred from exit zero. It reads a working-directory manifest before the packaged one and otherwise may create a random default domain; explicit service selection, the inspected integer `--version` and `--no-default-domain` are required here. The operator MUST serialize manual changes to that service and its shared resources across environments and recheck the selected version's bindings; omitting `--version` resolves active at execution time and falls back to latest if no active version exists, which can select a different version from the inspected one. `[setup]` stores are applied only for new services. Built-in availability checks do not establish application correctness. -### Build and configuration independence +This manual route preserves inspected wasm bytes but does not supply the integrated preactivation binding check, staging isolation or durable structured outcome. Operators MUST document that limit, record the version actually reported, and independently inspect binding/context rather than claim the integrated contract passed. The proposed integrated route is the eventual default; this documented verified-byte production route satisfies #1235's initial artifact-deployment choice while upstream integration is pending. -- Fresh strict-mode build succeeds with pinned tools and locks. -- Missing npm/Node, failed `npm ci`, failed bundling, absent manifest/lockfile, and any `TSJS_SKIP_BUILD` presence reject the release even with populated old `dist`/`node_modules`. -- Failed explicitly requested JS tests stop the build. -- Missing core/integration output, empty or unexpected output, and bundle/evidence digest mismatches reject release generation. -- Changing strict-mode environment after a permissive build cannot reuse the permissive Cargo result. -- Operator configuration presence/content and physical resource mappings do not change canonical bytes under the same controlled recipe; prohibited operator build variables reject release preflight. -- Workflow preflight rejects skip-flag presence before sanitization; unreviewed compiler wrappers, Cargo configuration layers and tool startup overrides cannot replace the selected recipe. -- Editing repository `edgezero.toml` changes the recorded input/template digest and is rejected when it conflicts with the selected source snapshot. -- Build metadata rejects malformed/missing release facts and distinguishes local builds, package version, release version, and service version. -- First-artifact feature selection matches current production defaults and does not enable reusable sandbox or test utilities. +## 12. Follow-up ownership and stable adapter invariants -### SBOM composition +**R-FOLLOWUP.** Additional adapter support MUST preserve configuration outside canonical bytes, external complete-byte SHA-256 identity, verification and action authorization before provider mutation/process start, and artifact-specific provenance/SBOM, reproducibility policy, runtime/binding, readiness and recovery proof. It MUST be selected in a reviewed cohort/policy before execution. One adapter's successful proof MUST NOT qualify another. These invariants do not make additional adapters part of #1235's acceptance or require a cumulative nine-artifact launch. -- Validate SPDX 2.3 schema and the root canonical-artifact SHA-256 for all selected matrix artifacts; validate Cloudflare archive/member relationships and Spin final-component identity. -- Target-specific normal dependencies and host-built proc macros/build dependencies retain correct roles and per-unit features, including a package compiled in both contexts. Compiler-trace mappings and exact source joins are fixture-tested. -- Dev-only and unrelated workspace packages do not become artifact runtime components. -- Native/vendor/sysroot components and external library requirements are represented according to observed build declarations. -- Contributing third-party JS appears with package identity and integrity metadata. Later minifier elimination is represented conservatively and does not produce an exact final-code reachability claim. -- Prebid's type-only shim import does not imply full external Prebid.js is embedded. -- Tool-generated/virtual JS and packages declared as npm development dependencies retain their correct roles. -- Unknown contributing package ownership, missing graph coverage, broken SPDX relationships, and oversized predicates reject publication. -- Published SBOM data matches the verified SPDX predicate and separately attested SBOM-file digest. +The following are investigation inputs for separate specs, not approved transports/ABIs in this contract: -### Reproducibility and operator reconstruction +| Follow-up | Questions and proof to resolve | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reproducible operator rebuild | Retained authenticated source/dependency/tool inputs, legally obtainable SDKs, separately bootstrapped replay and offline official-release policy. Consider a supported library verification of GitHub-signed release attestations rather than a consumer-authored publication record. Promote native comparison gates individually | +| Cloudflare | Define final executable modules and inventory subjects (prefer raw module assets plus `modules.json` unless an archive is justified). The Worker reads `TRUSTED_SERVER_CONFIG` and caches settings, while current EdgeZero config push writes KV; these channels MUST be reconciled in the adapter spec with explicit config-envelope/secret references and positive/negative publisher-request fixtures. Define prepared version upload and explicit activation identities | +| Cloudflare build/transport | Verify/preseed worker-build 0.8.5 tools: wasm-bindgen matching locked 0.2.126, Binaryen 130 and esbuild 0.28.1 (distinct from the JS lockfile). Account for `WASM_BINDGEN_BIN`, `WASM_OPT_BIN`, `ESBUILD_BIN`, cache/download fallbacks, `CUSTOM_SHIM`, `RUN_TO_COMPLETION`, coredump switches, minification, optimization, panic-unwind and producers metadata. Current `build.sh` installs without `--locked` and loads operator env. For inheritance, investigate `bindings_inherit=strict`. Ordinary script/Upload Version inheritance is latest-uploaded-only; an explicit non-latest `version_id` requires the beta Workers API Versions endpoints and a separately reviewed API contract. Do not prescribe that beta field for the ordinary endpoint; latest uploaded can differ from deployed | +| Spin | `spin-sdk` 6.0.0 locks `wasip3` with WASI `0.3.0-rc-2026-03-15`. A `wasm32-wasip2` compiler target alone does not define a compatible ABI. Pin/test that exact world and runtime, migrate all owning targets/manifests/CI/commands together, and reassess RC support lifetime. Existing secret `default = ""` fields serialize into published locked-app data; define unresolved secret references. Default OCI composition and current-time annotations must not silently transform the canonical component; registry manifest identity is observed separately | +| Axum | Define direct verified local launch and a tested bridge from EdgeZero's local config envelope to runtime selectors/config variables and secret references. The binary uses `PORT` and loopback, not the source launcher's adapter port or `axum.toml`; a living error router is not readiness. Require an application request, child cleanup and signal handling. No generic remote deployment implied | +| #161 | Publisher authority, actual loaded snapshot, claims/freshness, remote execution limits and optional authenticated deployment observations. Fastly default is per-request; reusable Fastly/Axum/Cloudflare/Spin have distinct snapshot lifetimes | -- Demonstrate complete byte equality of A/B builds for Fastly, Linux CLI, macOS ARM64 CLI and macOS Intel CLI before the first release. There is no per-target warning-only fallback. -- Use separate clean jobs and newly materialized source/dependencies/outputs; prove Build B cannot consume A's executable, compiled cache or generated JS/code. -- Compare source, recipe, platform/toolchain inputs and embedded module digests as well as final size/SHA-256 and byte equality. Inject a one-byte executable change, missing build, wrong recipe/SDK and incorrect source epoch; each blocks publication. -- Replay every selected published recipe on a clean Linux container host for Linux-built artifacts and on a separate compatible Mac for each native Apple artifact. Reconstruct from authenticated retained inputs without workflow/provider credentials or compilation networking. -- Vary external checkout directory, fresh dependency materialization and execution time while honoring the declared canonical internal environment. Test permitted parallelism variations where the recipe claims they do not affect bytes. -- Inspect final files for leaked developer checkout paths; test the release CLI's disabled compiled-path fallback and retained explicit/current-checkout resource behavior. -- Validate native build IDs, Apple OSO paths/times, UUID and ad-hoc signature settings; compare the actual distributed signed bytes without removing sections for comparison. -- Verify input-archive identity and safe bounded extraction; reject source contents/modes/link targets inconsistent with the independently obtained approved Git tree. Missing archives/tools, incompatible platforms, prohibited fetches or changed inputs fail with a clear diagnostic. -- A locally reconstructed matching wasm passes ordinary release verification before provider writes; a mismatching one fails even when compiled version/source facts match. -- Recipe/rebuild/input-archive identities remain consistent with provenance/index/offline publication records. Runtime claims never interpret repeatability as proof of remote execution or independent compiler trust. -- Before Phase 2 enablement, independently demonstrate A/B equality and operator replay for all five additional artifacts. Cloudflare proves exact final archive/member bytes after all JS/WASM transforms; Spin proves final component bytes after all approved adaptation/composition; each Axum target proves its own final executable and native compatibility. Phase 1 evidence cannot satisfy these gates. +The Cloudflare configuration mismatch and inaccurate configuration comments predate this spec. Runtime repair is separate work; no issue is automatically opened by this document. -### Verification and publication +## Feasibility evidence and implementation dependencies -- Verify both provenance and SPDX predicates for every selected canonical artifact with fixed signer/ref/commit and adapter/kind/capability constraints. -- Reject altered binaries, bundles, index entries and companions; wrong repository/signer/issuer/ref/commit; unsupported target/variant; and self-hosted-runner provenance. -- A provenance-only, SBOM-only, or checksum-only result cannot produce a `VerifiedArtifact`. -- Exercise missing/incompatible GitHub CLI, network failure, malformed JSON and partially successful command sequences. -- Verify saved bundles/roots with networking disabled; untrusted or absent roots fail. -- Read-only online export applies the complete deployment verification policy, rejects unsupported assets, performs no provider writes, and emits no successful publication record after a partial failure. -- Official offline deployment rejects absent/untrusted publication records, missing or mismatched independently approved record pins, and records inconsistent with tag/source/index/artifact identities, even when all build bundles verify. A signed index from an unpublished build is insufficient. -- Inherited GitHub host/configuration overrides cannot redirect the official verifier's authority or weaken fixed signer/issuer policy. -- Validate index schema/paths, duplicate assets, expected matrix completeness, and companion provenance subjects. -- Fail CI eligibility when successful checks are absent for the exact source commit, not merely for current `main`. -- Exercise failed matrix jobs, partial draft recovery, conflicting drafts, concurrent runs, already published tags, and uncertain publication responses. -- Use a disposable public test repository to prove draft upload, immutable publication, release attestation and `gh release verify-asset` for all assets. Test denied/missing administration-read credentials and disabled immutability before any publication. Publishing an official test tag requires maintainer release authorization; unprivileged CI does not use production release authority. +The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) covers every requirement and identifies concrete implementation boundaries. Local JS/wasm/native builds, application execution and pinned gh primitives were exercised. Hosted production recipes, full real-artifact SBOM coverage, GNU/Intel qualification, actual project signing/publication and live Fastly submission remain unproven release gates. Approval of prose does not waive them. The audit records the known unavailable old-macOS matrix, final-CLI JS distinction, primitive verifier limits, manifest fixture leakage and irreducible first-clone uncertainty corrected here. -### Deployment and recovery +## Test plan and requirement traceability -- Verification precedes all provider writes, including staged version cloning and selector updates. -- Use a fake provider/recorded command runner to prove release deployment never invokes source build, `compute publish`, wasm annotation or manifest shell overrides. -- Inspect real pinned Fastly packed fixtures and require exact verified module bytes; reject archive traversal, symlinks, duplicate wasm members and altered archives. -- Replacing the original operator artifact path after verification cannot substitute upload bytes; changing retained/package bytes is detected before upload. -- Production and staging submit the explicitly verified package and return exact service/version results. -- Staged config selectors and physical resource mappings keep current isolation behavior. -- Missing/incompatible manifest or config logical-ID context rejects Trusted Server staging before mutation; the prepared package and lifecycle share the same validated context. -- Partial upload/stage/activation, uncertain responses and receipt-write failure preserve known/unknown outcomes and require reconciliation before retry. Deployment-only receipts report no health check performed. -- Separate health-check and rollback commands can consume the exact returned version; orchestration captures production's previous version before mutation and serializes lifecycle writes per service. -- Readiness of `/health` or a reported version is never asserted to be cryptographic artifact verification. -- Native CLI startup/help/config-init work outside the source tree; repository-dependent commands retain explicit prerequisites. -- Native linkage inspection and clean Ubuntu 24.04 runtime smoke establish the advertised Linux baseline independently of build-runner provisioning. +These are implementation acceptance tests, not tests claimed to pass in this specification PR. Every normative requirement maps below to a test family and acceptance item. D1–D8 refer to the eight “Done when” bullets of #1235 in their original order. -### All-adapter enablement and publisher handoffs +| Requirements | Test family and essential positive/negative cases | Acceptance | #1235 Done when | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------- | +| R-COHORT, R-PLATFORM, R-VARIANT | T-1 exact four assets/profiles; no downgrade on required failure; CLI B mismatch reported; Fastly failure blocks; ELF symbol/link/runtime checks; macOS 15 ARM/Intel startup; old-OS claims remain unqualified; reject unsupported reuse | AC-1, AC-2 | D1, D3 | +| R-CONFIG, R-INPUTS, R-ENV | T-2 build with differing fictional publisher inputs gives identical permitted bytes; operator values absent; unchanged repository manifest; inventory covers embedded roots/generated assets/native inputs; prohibited flags/files/env fail | AC-2 | D2, D5 | +| R-JS | T-3 #1214 private prebuilt path; skip absent/empty/0/1; missing npm, failed install/test, stale/empty/extra module; inventory tampering; two JS builds differ; cross-artifact digests differ; missing esbuild with network denied | AC-3 | D5 | +| R-BUILD, R-REPEAT, R-NATIVE | T-4 separate run/job/ID/digest binding; wrong-run/artifact substitution; fresh outputs; varied declared ambient inputs; final wasm equality/no normalized comparison; native A/B outcomes and signature/path observations; enforced socket-denial controls | AC-3 | D3, D6 | +| R-SBOM | T-5 actual target and host proc-macro/build roles, dual-role package, dev exclusion, real JS contribution, Prebid type-only exclusion, plugin helper, AWS-LC/SQLite/ring native coverage and forbidden system selection, SPDX relationship/root/predicate equality and size limit | AC-4 | D3, D4 | +| R-FACTS | T-6 target/feature ownership; malformed/missing commit fails without git fallback; source header vs service version; two tags on same commit do not change bytes; no final checksum/config/run timestamp embedded | AC-5 | D2 | +| R-SOURCE, R-SIGN, R-PUBLISH | T-7 approved main/RC tag; unauthorized RC and annotated tag rejected; pending/cancelled/skipped/path-filtered/wrong-commit checks; timeout; untrusted caller; permission separation; matching/conflicting draft reruns; immutable/mutable/unknown publish state | AC-6 | D3, D7 | +| R-INDEX, R-EVIDENCE, R-POLICY | T-8 schema positive fixture and unknown enums/version/extra field/duplicate keys/names/traversal; tampered subjects/companions; policy cannot self-select; wrong caller/build type/material/source/builder/prefix lookalike, trailing control characters, actual size limits; no extra unapproved subjects | AC-4, AC-7 | D3, D4 | +| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | T-9 verified CLI first-use fixture; absent token; isolated gh config/host; unsupported tool; hangs/cancel/oversize/nesting/partial JSON; unpublished or mutable artifact; source approval mismatch; all fail before provider mutation | AC-7 | D4, D6 | +| R-OFFLINE | T-10 pinned roots/source/digest with networking denied; artifact/predicate tampering; hostile transferred roots; offline build success cannot construct official-deploy capability; offline deployment rejected | AC-7 | D4 (specified offline limits) | +| R-DEPLOY, R-PACK, R-CONTEXT, R-EDGEZERO, R-RECOVERY | T-11 retained-file substitution/package/member mutation; all four Fastly pack members plus extra manifest-only archive; observed links disagree; exact draft mismatch prevents activation; staging isolation; no source hooks; exact/unknown outcomes; interrupted selector writes, first-clone/send-before-response crashes including indistinguishable candidate allocations, secret/reference privacy, shared-resource isolation boundaries and final receipt failure; read-only reconciliation | AC-8 | D6 (integrated enhancement) | +| R-MANUAL | T-12 provider-free pack/member equality; documented native production limits including skipped upload and manifest precedence; authorized disposable-service production test with submitted wasm equality | AC-9 | D6 | +| R-FOLLOWUP | T-13 docs/policy reject unsupported adapters/actions; #1235 criteria contain no adapter migration, operator replay or offline deploy gate; January supersession link | AC-10 | D1, D8 | -- Exact cohort membership is checked before signing and again before publication. Missing Phase 2 outputs cannot trigger a Phase 1 fallback; old consumers reject unsupported kinds/capabilities. Every artifact has consistent recipes, evidence, SBOM, attestations and publication records. -- Cloudflare fixtures cover SDK glue, JS snippets/polyfills and final module ownership; safe deterministic archive extraction; module names/types/import resolution; the compatibility contract; binding inheritance; and equality of actual multipart executable parts before writes. Reject implicit custom builds, bundling/discovery, unreviewed compatibility overrides and unsupported staged/routing operations. Upload and activation failures preserve version/deployment IDs and uncertainty without activating a different version. -- Spin fixtures build fresh WASIp2 output, validate the selected component ABI/world against the locked SDK/runtime, and execute an application request through its required KV/config/permission context. Host-reserved readiness alone is insufficient. Reject unapproved composition, build/watch hooks and ambient `SPIN_ALWAYS_BUILD` or deployment-plugin overrides. Verify local OCI layout descriptors and actual component layer bytes before push; inject secret variable defaults, app-config blobs, state snapshots and mounted files into config/layers/annotations and require rejection before any registry write. Validate retrieved digest/content before any supported remote activation. Mutable tags, changed layers, unsupported plugins and ambiguous outcomes cannot bypass the capability contract. -- Axum fixtures launch the verified native executable outside a checkout, using the declared `PORT`, local envelope bridge and selected secret references. Exercise nondefault physical store names, config keys and secret-store selectors, including mismatches between envelope variable names and runtime selectors. Prove source-build hooks and loader overrides cannot execute, missing config/secrets cannot pass readiness via a listening 500-error router, and a positive application request succeeds. Exercise startup failure, signal forwarding and owned-child cleanup; reject remote deploy/staging/rollback before spawning or writing. -- Prepared payloads cannot cross adapter/action boundaries. Receipts retain distinct artifact, packaging, provider and local-process identities; unsupported or unknown observations are not fabricated. Test startup-held Axum settings, cached Cloudflare settings, reused Fastly settings and operation-loaded Spin settings so future claims cannot substitute freshly fetched config for the snapshot actually used. - -### Required repository checks - -Implementation verification uses the target-matched tests/lints for changed core, JS, CLI and adapter behavior, plus upstream EdgeZero tests for the additive deploy interface. Before implementation PR handoff, run the complete AGENTS.md CI gate list, including reusable Fastly tests, parity tests, CLI host tests, native template-build-digest tests, JS build/tests/format, and documentation/Markdown formatting. - -Phase 2 Spin migration updates those target-matched aliases/gates and their documentation together. Passing today's WASIp1 command list does not establish the new WASIp2 SDK/runtime contract; the reviewed migrated commands and positive component/application fixtures are mandatory. - -The specification-only PR needs Markdown formatting, relative-reference validation, and design review. It does not claim runtime tests establish behavior that has not been implemented. +Implementation changes MUST run appropriate target-matched tests/lints after each behavioral change and the complete AGENTS.md CI gates before PR handoff, plus upstream EdgeZero API/lifecycle tests. No real customer/domain/secret fixture data is permitted. A specification-only PR requires document/schema formatting, schema positive/negative fixture validation, reference/traceability checks and design review; unchanged runtime tests do not demonstrate future behavior. ## Rollout -1. **Approve the contract.** Land this specification in its own PR. Record maintainer approval; implementation PRs link it. Confirm the permission work accepts the configuration separation requirement. -2. **Prove composition, reproducibility and upstream capability.** Validate the selected SBOM pipeline fixtures, demonstrate exact-byte clean builds and operator replay for all four target recipes, and land EdgeZero's additive prebuilt production/staging request/result. Pin the reviewed upstream revision before adopting it. An unresolved target blocks the selected first-release matrix; any reduced matrix requires a reviewed specification change. -3. **Implement release build support.** Add strict JS mode, composition evidence, build facts, deterministic recipes/tools/environment pins, retained inputs, A/B comparison gates, index generation, provenance/SBOM signing and publication workflows. Reconcile obsolete CI comments claiming configuration is baked into wasm. -4. **Implement verified deployment.** Add the fixed release policy, online/offline evidence handling, retained artifact/package verification, provider sequencing and optional local receipts. Document the native Fastly route as a transitional/manual path. -5. **Prepare release authority.** Verify immutable releases and tag rulesets, provision the isolated repository-scoped read-only settings credential, audit bypass permissions and action/tool pins, and prove publication in a disposable public repository. Do not backfill historical tags with claims that their old operator builds were attested. -6. **Publish the first eligible Phase 1 tag.** Complete its four-artifact matrix, verify immutable membership and all predicates, and exercise an explicitly authorized test deployment through the supported artifact route. -7. **Move operator guidance to verified bytes.** Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document the default download/deploy route and exact operator rebuild recipe, expected digest checks, environment/input acquisition, supported targets, installation prerequisites, identity policy, offline limits, staging and rollback. Distinguish ordinary custom builds from verified reconstructed artifacts. -8. **Implement and prove Phase 2.** Land the paired Spin WASIp2 migration, Cloudflare final-module recipe/prepared API transport, Spin verified local-run/OCI publication and Axum native recipes/direct-launch bridge. Add concrete EdgeZero payload/context/result cases and capability errors. Prove composition, every additional recipe's A/B equality/operator replay and each declared operation before enabling the cumulative cohort. This work can proceed alongside Phase 1 without changing a published release. -9. **Enable the cumulative nine-artifact cohort on a new tag.** Review the tracked manifest, run all shared and adapter-specific gates, and update verification/operation guidance and changelog for the newly supported kinds. Never attach new assets or support claims to an already immutable Phase 1 release. -10. **Continue publisher and assurance work separately.** #161 designs publisher trust, loaded-config metadata, claim signing/freshness and runtime transport. Later work can add independently reviewed provider capabilities, independent rebuild witnesses, broader environment portability and stronger builder isolation without changing the artifact/deployment identity boundary. - -A delayed EdgeZero change does not justify rebuilding inside the verified deployment path. Releases may be distributed with the manual packing instructions during rollout, but #1235's integrated deployment acceptance is not marked complete until the declared route is available and verified. - -Published immutable releases are never repaired by overwriting assets or moving tags. A defective release is documented and superseded by a new version; maintainers publish any consumer-facing withdrawal/advisory policy separately. Existing historical attestations remain verifiable evidence of their original claims, not a guarantee of ongoing recommendation. - -## Alternatives considered - -| Approach | Benefit | Reason for the selected order | -| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Verify and deploy release bytes | Direct connection between attested subject and submitted module | Selected default operator route; operators need not rebuild to deploy verified bytes | -| Reproducible executable recipes plus verified deployment | Operators can reconstruct and compare while retaining direct verified deployment | Selected together: all selected cohort artifacts must pass clean-build comparisons and replay fixtures before publication | -| Require every operator to rebuild before deployment | Every deployment starts with local reconstruction | Unnecessary operational prerequisite; reconstruction is supported, while mandatory release comparisons establish recipe repeatability | -| Publish evidence but keep unconditional local deployment builds | Small CI-only change | Rejected: operators do not necessarily submit the attested subject | -| Attest the Fastly package instead of wasm | Identifies one complete provider archive | Rejected for the canonical artifact: publisher manifests/resource identities vary; retain a separate deployment package digest | -| SBOM from whole dependency graph or bare wasm scan | Simple existing tool invocation | Rejected as sufficient composition evidence: target/dev/build roles and embedded JS are misrepresented or missed | -| Rust-native SBOM tooling or `cargo-auditable` | Reduces custom Rust dependency tooling | Cargo's precise SBOM precursor is currently unstable; stable cargo-auditable uses metadata fallback, modifies executable bytes and does not replace JS/native composition. Revisit when a maintained pipeline proves the selected contract | -| Implement publisher/runtime attestation now | Delivers more claim types at once | Deferred: identity authority, schema, snapshot/freshness and independent execution assurance need #161's reviewed threat model | -| Require all adapters in the first release | One initial full-platform launch | Not selected: Cloudflare postprocessing/transport and the Spin target migration are independent prerequisites. Define all contracts now, ship an explicitly limited Phase 1 cohort, then enable the complete Phase 2 cohort only after its proof gates | - -Composition tooling, cross-platform reproducibility and the provider lifecycle interface are the largest implementation costs. Keep the adapter limited to observed selection, metadata joins, relationships and validation; reuse Syft and maintained parsers rather than writing a new package scanner. Composition/replay fixture proofs precede production workflow work so feasibility failures can change the approved contract early. Recipe reproducibility complements provenance and verified deployment; independent witnesses and stronger builder isolation add further assurance later. +1. Approve and merge this contract independently; implementation links the approved version. Reconcile #1214/#1213 and permission-config separation. +2. Prototype Fastly A/B equality, shared JS output, SBOM native/host/JS fixtures and CLI compatibility baselines with throwaway recipes. Record feasibility and review any cohort change before production work; this step does not promise replay of nonexistent retained inputs. +3. Implement reviewed release recipes/tools/cohort, JS inventory, composition generator, facts, validation, measurements, schema/policy, signing and immutable publication. Remove obsolete claims that configuration is baked into wasm from CI workflows, the integration-test setup action, adapter comments/tests and integration helpers, not just `test.yml`. +4. Prepare governance/settings/tag rules, trusted gh/CLI bootstrap and documentation. Exercise publication and verification in a disposable public repository using policy fixtures for that repository; no production-policy override is added. Document/validate the manual verified-byte Fastly production route. Publish the first eligible four-artifact release after all its selected requirements pass. +5. Implement the separate Fastly-only EdgeZero prebuilt request/result and recovery lifecycle, review/pin that upstream revision, then adopt integrated online production/staging in `ts`. Upstream delay does not block step 4, and MUST NOT cause the artifact path to rebuild. +6. Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document platform/tool/token prerequisites, policy identities, manual/integrated distinctions, offline limits and optional comparison results. Historical source builds MUST NOT be relabeled attested. +7. Follow-up specs independently qualify operator reconstruction, offline deployment, each other adapter and stronger builder isolation. #161 continues publisher/config/runtime work; none gates #1235 closure. ## Acceptance criteria -- [ ] This draft is approved and merged independently; implementation links the approved version and updates it for material design changes. -- [ ] The no-configuration requirement and compile-time inventory are enforced for every selected cohort artifact. -- [ ] Protected eligible `v*` tags publish the complete immutable artifact/SBOM/bundle/index/checksum distribution. -- [ ] All selected canonical artifacts pass provenance, SBOM and immutable-release asset verification with the documented adapter/kind/capability policy. -- [ ] Strict JS release builds reject missing tools, installation failure, skip flags and stale/incomplete output. -- [ ] Every selected canonical artifact passes separate clean A/B builds with exact complete-byte equality; any missing or mismatched artifact blocks publication, including Cloudflare archive/member bytes and final Spin component bytes. -- [ ] Authenticated recipes and retained inputs support demonstrated operator replay for every selected artifact without release credentials or compilation networking; Phase 1 has four artifacts and cumulative Phase 2 has nine. -- [ ] Locally reconstructed wasm must match the official digest and pass ordinary evidence verification before deployment; version/source metadata alone never suffices. -- [ ] SBOM fixtures prove target/build-host selection and embedded JS scope, including the external Prebid exclusion. -- [ ] Both production and staging artifact deployment preserve verified wasm bytes and perform no provider writes on verification failure. -- [ ] Read-only online export produces verified offline evidence; official offline deployment requires the previously verified publication record and its independently approved digest pin, works without verification network access, and documents its freshness limits. -- [ ] Build facts distinguish release/package/source/target/variant from service version and final binary checksum. -- [ ] Deployment observations preserve exact known results without claiming publisher authorization or remote execution proof. -- [ ] Immutable-release settings, active tag rules and release authority are verified and documented. -- [ ] CLI/operator verification guidance and changelog are updated; historical source builds are not relabeled as attested. -- [ ] Publisher/configuration/runtime/vendor protocols remain separate, with the necessary handoff contracts documented for #161. -- [ ] Phase 2 enablement proves Cloudflare's final module archive and exact prepared upload/activation, Spin's migrated validated component and local-run/OCI path, and all three Axum executables with verified local launch. Unimplemented remote-provider modes fail before side effects. -- [ ] Typed EdgeZero payload/context/results preserve canonical bytes and adapter-specific identities across the four supported adapters; future publisher claims bind the actual loaded snapshot and retain the limits of local observations. - -## Supersession and references - -Upon approval, this specification replaces the binary-attestation, canonical build, release publication and artifact-deployment sections of [the January attestation proposal](2026-01-15-attestation-design.md). Its runtime/configuration examples remain historical reference for #161, not approved implementation contracts. In particular, the embedded-config baseline, embedded final-binary digest, and assumption that signatures independently prove remote execution are not adopted. - -Authoritative implementation references: - -- [GitHub artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations). -- [Reusable workflow verification and SLSA guidance](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/increase-security-rating). -- [GitHub immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). -- [GitHub release verification](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/verify-release-integrity). -- [Immutable-release settings API](https://docs.github.com/en/rest/repos/repos#check-if-immutable-releases-are-enabled-for-a-repository). -- [GitHub CLI attestation verification](https://cli.github.com/manual/gh_attestation_verify), [bundle download](https://cli.github.com/manual/gh_attestation_download), and [trusted roots](https://cli.github.com/manual/gh_attestation_trusted-root). -- [Attestation action modes and outputs](https://github.com/actions/attest). -- [SLSA build requirements](https://slsa.dev/spec/v1.2/build-requirements). -- [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/). -- [IETF RATS architecture](https://www.rfc-editor.org/rfc/rfc9334.html). -- [Reproducible build definition](https://reproducible-builds.org/docs/definition/). -- [Build-environment definition](https://reproducible-builds.org/docs/perimeter/) and [SOURCE_DATE_EPOCH](https://reproducible-builds.org/docs/source-date-epoch/). -- [Rust source-path remapping](https://doc.rust-lang.org/rustc/remap-source-paths.html), [Cargo vendoring](https://doc.rust-lang.org/cargo/commands/cargo-vendor.html), and [Apple target/SDK selection](https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html). -- [npm clean installation](https://docs.npmjs.com/cli/v11/commands/npm-ci/). -- [Apple build UUID guidance](https://developer.apple.com/documentation/technotes/tn3178-checking-for-and-resolving-build-uuid-problems) and [Apple linker options](https://github.com/apple-oss-distributions/ld64/blob/main/doc/man/man1/ld-classic.1). -- [Cargo SBOM precursor](https://doc.rust-lang.org/cargo/reference/unstable.html#sbom) and [cargo-auditable's metadata/precursor selection](https://github.com/rust-secure-code/cargo-auditable/blob/master/cargo-auditable/src/collect_audit_data.rs). -- [Syft package catalogers](https://oss.anchore.com/docs/guides/sbom/catalogers/) and [Rust capabilities](https://oss.anchore.com/docs/capabilities/rust/). -- [Rollup output hooks](https://rollupjs.org/plugin-development/). -- [Cargo build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html) and [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html). -- [GitHub-hosted runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). -- Pinned Fastly v15.1.0 [publish](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/publish.go#L119), [wasm annotation](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/build.go#L208), and [precompiled wasm packing](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go#L87). -- Pinned Cloudflare worker-build v0.8.5 [transformation pipeline](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/main.rs) and [tool versions](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/versions.rs). -- Cloudflare [script version upload](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/versions/methods/create/), [multipart metadata](https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/), [deployment API](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/deployments/) and [Wrangler bundling](https://developers.cloudflare.com/workers/wrangler/bundling/). -- Spin v4 [Rust component targets](https://spinframework.dev/v4/rust-components), [local execution](https://spinframework.dev/v4/running-apps), [registry push and deploy commands](https://spinframework.dev/v4/cli-reference) and [reserved HTTP routes](https://spinframework.dev/v4/http-trigger#reserved-routes). -- Existing [Axum operator configuration bridge](../../guide/axum-dev.md) describes the development launcher being adapted; it is not already a verified-artifact launch path. - -Tool/action versions and hosted-runner availability are validated when implementation pins are selected. A later incompatible tool/API change requires a reviewed recipe or specification update, not a silent change to verification policy. +- [ ] **AC-1:** Spec approved/merged independently; implementation links it and records the selected cohort/profiles before execution. +- [ ] **AC-2:** Configuration independence, complete input inventory, selected variant and qualified CLI compatibility hold for every selected Build A. +- [ ] **AC-3:** Fresh locked shared JS passes byte equality; Fastly A/B matches complete bytes; native measurements have honest terminal outcomes and cannot waive required failures. +- [ ] **AC-4:** Per-artifact provenance/SPDX/bundles/index/checksums and composition coverage pass their exact evidence and schema checks. +- [ ] **AC-5:** Source facts reconcile #1213, derive target/variant correctly and keep release tags, service versions and final digests distinct. +- [ ] **AC-6:** Approved main/RC lightweight `v*` tags run same-commit gates and publish the exact immutable verified distribution with least-privilege authority. +- [ ] **AC-7:** Trusted bootstrap, complete online verification, bounded subprocesses and offline build-evidence checks pass; unsupported offline official deployment fails before writes. +- [ ] **AC-8 (integration milestone):** New reviewed/pinned EdgeZero Fastly API supports byte-preserving online production/staging, observed context validation and durable recovery. This item tracks the subsequent integrated enhancement and MUST NOT keep #1235 open when AC-9 satisfies its deployment choice. +- [ ] **AC-9:** Operator verification/CLI/changelog guidance and the manual verified-byte production route are implemented and validated; ordinary source builds cannot be mistaken for canonical bytes. +- [ ] **AC-10:** January binary-attestation guidance is superseded; #161 and deferred adapter/reconstruction scopes are explicit and unsupported actions fail. + +#1235 can close when AC-1 through AC-7, AC-9 and AC-10 satisfy its D1–D8 criteria. AC-8 remains a separately tracked integration milestone if upstream work is delayed. There is no requirement to implement Cloudflare, Spin, Axum, native CLI reproducibility, retained archives or operator replay to close #1235. + +## Appendix A. Review disposition and rationale + +This table accounts for all 32 inline comments in [review 5484754857](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857). It records proposed spec resolution; GitHub conversations are not automatically marked resolved and maintainer acceptance is still required. + +| Review concern | Resolution / requirement | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reusable sandbox (4243031371) | R-VARIANT retains current production default and plans qualified attested reuse; R-FACTS corrects snapshot lifetime; removes nonexistent test feature | +| All-artifact reproducibility gate (4243031376) | R-COHORT/R-REPEAT select Fastly equality, native observation; reconstruction/offline deploy deferred | +| Repository-root scaffold (4243031380) | R-INPUTS names the external-to-CLI-crate repository-root input | +| Ambient variation and job output binding (4243031389) | R-REPEAT varies declared-insensitive inputs and binds artifact IDs/digests to job outputs | +| Distinct assurance dimensions (4243031391) | Profiles chosen before execution; claim/threat tables retain independent limits | +| CLI host baselines (4243031395) | R-PLATFORM keeps glibc 2.34 as a qualification target not yet demonstrated; macOS loader floor 13 with initial hosted-15 qualification, tested cross-build only and Intel lifecycle plan | +| Index/schema and informational records (4243031397) | R-INDEX links closed JSON Schema and field contracts; recipe/rebuild consumer semantics informational; receipts private observations | +| #1214 fit (4243031404) | R-JS adds release delta to private prebuilt interface, no competing strict mode | +| One identical release-wide tsjs set (4243031409) | R-JS twice-builds one shared set, checks prepared inputs and separately checks modules actually retained after linking | +| Compiler wrapper/Syft cost (4243031418) | R-SBOM uses Cargo/lock/Rollup evidence and a repository-owned SPDX generator | +| Native and esbuild coverage (4243031423) | R-SBOM declares/validates actual native selection; R-JS tests denied fallback download | +| Release branches and in-progress CI (4243031426) | R-SOURCE approves exact main/RC policy and runs bounded same-commit release validation | +| Local signer/source revision (4243031429) | R-POLICY pins equal signer/source revisions and defines future identity migration | +| Attestation permission (4243031433) | R-SIGN removes `artifact-metadata: write` for file subjects | +| Settings token (4243031442) | R-PUBLISH uses governance plus actual immutable release check/recovery; no settings-read secret | +| gh prerequisites and bootstrap (4243031449) | R-BOOTSTRAP specifies token/env/path/trust chain; exact qualified gh version | +| Normative evidence graph (4243031456) | R-EVIDENCE/R-POLICY fix all digest/source/builder/caller/action constraints | +| Annotated tag commit confusion (4243031459) | R-SOURCE requires lightweight tags and explicit commit-object validation | +| Prefix signer match (4243031464) | R-POLICY/commands use exact SAN `--cert-identity` | +| Resource bounds (4243031467) | R-BOUNDS supplies concrete limits/deadlines, failure and child cleanup | +| Offline observation protocol cost (4243031470) | R-OFFLINE retains bundle/pin checks; offline official deployment/record deferred | +| Actual EdgeZero surface (4243031476) | R-EDGEZERO sizes new Fastly-only API and bypasses existing build/log/version fallbacks | +| Observed binding validation (4243031479) | R-CONTEXT checks existing base version and exact resulting draft before stage/activation | +| Pack archive facts (4243031481) | R-PACK specifies four prefixed entries, fresh directory and separate manifest-only archive | +| Durable recovery ownership (4243031485) | R-RECOVERY persists before mutation, survives failures/crashes and assigns reconciliation/retry ownership | +| Native deploy limits (4243031488) | R-MANUAL documents production activation, skip, domain creation and manifest precedence | +| Derived facts / #1213 (4243031494) | R-FACTS uses established name, source-only override, Cargo/adapter derivation, external release tag | +| Cloudflare config mismatch (4243031498) | §12 preserves concrete binding-channel investigation and fixture prerequisites for separate adapter spec | +| Spin ABI and OCI facts (4243031502) | §12 records exact WASI 0.3 RC dependency, secret defaults and composition/annotation caveats; no premature migration gate | +| Rollout dependencies (4243031506) | Feasibility precedes recipes; publication/manual route precedes independent upstream integration | +| Stale config assertions beyond CI (4243031509) | Rollout step 3 explicitly covers workflows/action/adapter comments/tests/helpers | +| Phase 2 closure scope (4243031515) | R-FOLLOWUP/acceptance remove cumulative adapter gates from #1235 | + +Body-level findings are addressed by the threat model, BCP 14 requirements, requirement→test→acceptance→issue mapping, named producer-policy paths and the January supersession banner. Deferred Cloudflare notes are retained in §12 rather than lost during scope reduction. + +Verified-byte deployment is selected first because it directly preserves an attested subject without requiring an operator SDK/compiler. Fastly A/B adds affordable measured repeatability; native CLI reconstruction has separate linker/SDK/availability costs. Selected required profiles remain blocking, so this is a reviewed assurance split, not a fail-open release. A custom whole-lockfile SBOM or bare wasm scan misses target/host/JS/native selection; an extra compiler wrapper does not solve native library provenance. A complete-package attestation would vary with publisher metadata, so raw wasm identity and observed package identity stay separate. A future independent builder strengthens a different threat boundary than matching project-controlled builds. + +## Appendix B. Supersession and primary references + +This draft supersedes the binary-attestation direction in [the January proposal](2026-01-15-attestation-design.md); after approval it is the implementation contract for those sections. January runtime/configuration examples remain historical reference for #161, not approved claims, embedded active-config guidance or self-reported executable proof. + +- [Original issue](https://github.com/IABTechLab/trusted-server/issues/1235), [review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857), [JS PR #1214](https://github.com/IABTechLab/trusted-server/pull/1214), [version PR #1213](https://github.com/IABTechLab/trusted-server/pull/1213). +- GitHub [artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations), [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases), [release verification](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/verify-release-integrity), and [reusable workflow limits](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/increase-security-rating). +- gh 2.97.0 [exact/prefix identity policy](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/pkg/cmd/attestation/verify/policy.go) and [tag-target lookup](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/pkg/cmd/release/shared/fetch.go); [verifier manual](https://cli.github.com/manual/gh_attestation_verify), [bundle download](https://cli.github.com/manual/gh_attestation_download), [trusted roots](https://cli.github.com/manual/gh_attestation_trusted-root). +- Pinned [attest 4.2.2 dependency lock](https://github.com/actions/attest/blob/1e69f48acb82d1966a394da916b4c1698aa569d6/package-lock.json) and matching [toolkit provenance semantics](https://github.com/actions/toolkit/blob/7987771a2b727fe14b36351f6c877fa3a56b9c36/packages/attest/src/provenance.ts). +- Pinned [Sigstore certificate summary fields](https://github.com/sigstore/sigstore-go/blob/v1.2.2/pkg/fulcio/certificate/summarize.go). +- [SLSA build requirements](https://slsa.dev/spec/v1.2/build-requirements), [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/), [BCP 14 keyword clarification](https://www.rfc-editor.org/rfc/rfc8174.html). +- Cargo [build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html), [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html), [source remapping](https://doc.rust-lang.org/rustc/remap-source-paths.html) and [Apple targets](https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html); [Rollup output hooks](https://rollupjs.org/plugin-development/), [npm ci](https://docs.npmjs.com/cli/v11/commands/npm-ci/). +- Fastly 15.1.0 [packing](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go), [deployment](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/deploy.go), [build annotation](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/build.go). +- Pinned EdgeZero [adapter surface](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter/src/registry.rs), [Fastly lifecycle](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter-fastly/src/cli.rs), [CLI dispatch](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-cli/src/lib.rs). +- [Cloudflare binding inheritance](https://developers.cloudflare.com/workers/configuration/multipart-upload-metadata/), [worker-build pipeline](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/main.rs), [tool versions](https://github.com/cloudflare/workers-rs/blob/v0.8.5/worker-build/src/versions.rs); existing [Axum bridge guidance](../../guide/axum-dev.md). + +Tool availability and API semantics MUST be revalidated at implementation pin selection. Incompatible changes require reviewed policy/spec updates rather than silent fallback. diff --git a/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md b/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md new file mode 100644 index 0000000000..b36a546059 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md @@ -0,0 +1,130 @@ +# Release attestation feasibility and implementation dependencies + +**Date:** 2026-10-12. + +**Contract:** [Release artifact attestation and verified consumption](2026-10-09-1235-release-attestation-design.md), PR #1262, issue #1235. + +**Status:** Feasibility evidence for a proposed contract. The core build, verification and packaging mechanisms work in local prototypes. The complete four-target release is **not qualified**: hosted production recipes, full SBOM coverage, signing/publication rehearsal, Linux compatibility, Intel execution and live Fastly submission remain required evidence. + +Document/schema checks and independent design review establish consistency. They do not establish that a new workflow can publish, that every native target runs, or that provider operations preserve an intended runtime context. + +## Evidence scope + +The baseline is `7f6056bf83eb96aa4167efca64d43280299fba73`. Read-only PR checks identified #1214 head `e150780b4a922be0cd0dfbbe1167c72aa076d3e8` and #1213 head `959e503b9e35b0aecd42439edc85ad744bbb92f2`. Both were open during the audit. Isolated JS/interface probes overlaid #1214 files onto the baseline; its old `build-all.mjs` diff requires reconciliation with the current refactor. These synthetic snapshots are not an approved release commit. + +Local tools were Rust 1.95.0, Node 24.12.0/npm 11.6.2, macOS 26.6.2 ARM64 with SDK 15.5, Fastly CLI 15.1.0, Viceroy 0.17.0 and downloaded GitHub CLI 2.97.0. The Fastly CLI separately reports a bundled Viceroy 0.21.1; that was not substituted for the pinned runtime probe. Compilation outputs were fresh; Rust source caches/compiler were shared. Prototype epoch input was fixed rather than produced by the final commit-derived recipe. Source-fact #1213 changes and CLI checkout-fallback removal were not applied to the full artifact builds. + +Build outputs and application fixtures stayed in temporary directories. A gh probe with omitted `HOME`/`XDG_STATE_HOME` briefly created anonymous device state relative to the repository; the behavior was reproduced, the exact generated file and empty directories were removed, and R-BOOTSTRAP now requires private home/state handling. Provider operations, repositories, workflows, releases and attestations were not created or modified. Online probes read existing public GitHub release evidence. Runtime requests used localhost fixtures with fictional configuration and secrets. + +## What was executed + +| Probe | Measured result | What it proves | What it does not prove | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Fresh locked JS A/B | All 13 final bundles byte-identical; each test run passed 50 files and 1,208 tests | Current bundler can produce stable shared input under varied local paths/locale/timezone | Separate hosted workers, all package-origin joins or independent toolchain trust | +| #1214 prebuilt interface | Exact copied module set/hashes; succeeded with npm absent; 7 bundle-set tests passed | Rust can consume one prepared JS set without rerunning npm | Trusted expected-inventory input and a release presence-reject guard; upstream currently accepts skip alongside prebuilt | +| Missing esbuild binary | Network-denied bundle step exited 1 without fetching replacement | Selected Darwin bundler does not silently succeed without its prepared tool | Every installation-time/platform download fallback | +| Network confinement | Unconfined localhost socket succeeded; confined socket failed with `EPERM`; full Rust/JS builds ran inside that profile | Local macOS process-level network denial is available | Qualification of each actual hosted recipe or hostile-runner resistance | +| Full Fastly release A/B | Both complete files were 69,647,265 bytes and matched SHA-256 `2a740c43e93c47b31237861118e1bd75c5bd2940eb3daf1bb382d1104547c658`; no normalization | Fresh Cargo outputs can repeat across source/output paths, umask, locale/timezone and 4-versus-2 build jobs; all 13 JS strings remained | Final Linux hosted recipe, fresh independent dependency materialization, final source facts, publication or operator reconstruction | +| Actual Fastly runtime | Viceroy 0.17.0 `/health` returned 200; publisher `/` separately returned 200, origin fixture body and injected script | This complete prototype wasm parses/links and executes meaningful app behavior with the test envelope | Fastly upload/activation, PoP behavior, production credentials/config or remote execution proof | +| Full ARM64 CLI release | Network-denied `--frozen` release build succeeded; final file 37,977,296 bytes; outside-checkout help/version/config init succeeded | Current whole CLI graph builds/starts locally; scaffold bytes equal repository template | macOS 13/14/15, Intel or GNU startup; final release build-facts/version policy | +| CLI native inputs | Build messages/static archives identified `aws-lc-sys` 0.42.0 (vendored AWS-LC 5.1.0), ring 0.17.14 and `libsqlite3-sys` 0.30.1 (bundled SQLite 3.46.0); four native archives; loader floor 13.0, SDK 15.5 | Whole-CLI native evidence is observable and the explicit source-selection recipe works locally | Complete source/SDK/sysroot/license provenance, final native reachability or cross-platform determinism | +| Final JS byte membership | All 13 complete bundles present in Fastly, none present in the final CLI, despite JS crate compilation | OUT_DIR/compiled membership is not final embedded payload; per-artifact classification is necessary | Exact final function reachability; absence of every possible transformed fragment | +| CLI path literal | Compiled temporary checkout fallback survived Rust path remapping | Literal fallback removal needs code; remapping is insufficient | A qualified official CLI resource policy | +| Cargo dual-context fixture | Compiler JSON separated host/target units/features; metadata merged their feature sets | Effective unit features can be collected from messages; package metadata alone is insufficient | Exact compiler dependency DAG from metadata/tree | +| Rollup hook | Contributions collected for all 13 outputs without changing bundle bytes; current contributors were project modules, not full external Prebid.js | Hook-based contribution evidence is available | Full third-party/license joins, generated helper and elimination fixtures | +| Limited native SPDX prototype | Actual native-linked fixture ran; 30 packages/23 Rust units; 21,933-byte document passed official SPDX 2.3 schema | Selected-unit/native evidence can be serialized as valid SPDX | Production generator coverage, full CLI/Rust/sysroot/JS semantics or signed SBOM equality | +| Exact gh offline verification | gh 2.97.0 verified a signed reusable-workflow fixture under denied networking and absent token; wrong SAN/source/predicate rejected | Exact certificate/source flags, offline roots/bundle primitive and flattened certificate JSON work | Future action/workflow OIDC, official offline release membership or deployment | +| gh state isolation | An omitted home/state path created relative anonymous device state despite isolated gh config; reproduced and cleaned | `GH_CONFIG_DIR` alone does not isolate gh state | Production environment hygiene without explicit home/state paths | +| Live public release verification | gh 2.97.0 verified its immutable release and archive; independent REST checks reported immutable release and commit tag type | Current online release primitives work | This project's governance, RC permissions or new publication workflow | +| Membership negative probe | Renamed downloaded archive still passed `gh release verify-asset` | Command checks digest, not expected local/published filename; wrapper needs signed-name checks | Full release policy from exit zero | +| Pinned toolkit execution | Integrity-checked toolkit 3.2.0 emitted expected current build type/caller/builder/source/run shape with mocked OIDC claims | Current action's predicate construction matches the proposed semantics | Actual certificate issuance for the future hosted local reusable workflow | +| Fastly packaging negatives | Named archive retained exact bytes; generic archive lacked wasm; extra package file was swept in; local settings/secrets were copied; ASCII non-wasm passed both pack and validate | Fresh directory, nonreserved package name, strict inspection, deployment-only manifest and separate wasm/runtime validation are necessary | Deployability or confidentiality from pack/validate exit zero | + +These are measurements from this audit, not claims about a published release. The CLI result is a conservative byte check plus source-use analysis; unused JS must not be added to the CLI merely to make a cross-artifact inventory assertion true. + +## Corrections made to the contract + +1. **Remove unavailable macOS qualification gates.** macOS 13 hosted runners retired on December 4, 2025; macOS 14 retires November 2, 2026. A hosted-only 13/14/15 startup requirement cannot be treated as a routine supported matrix. The initial required matrix is macOS 15 on ARM64 and Intel. Deployment target 13.0 is a loader floor; older runtime support remains unqualified. Intel cross-build is an optional replacement after whole-project qualification, not an assumed first implementation. [macOS 13 retirement](https://github.blog/changelog/2025-09-19-github-actions-macos-13-runner-image-is-closing-down/), [macOS 14 retirement](https://github.blog/changelog/2026-10-01-github-actions-macos-14-runner-image-retirement/). +2. **Distinguish prepared JS from retained code.** One shared input set is feasible, but the current optimized CLI discards its complete bundles. Only final retained modules get an embedded-payload claim. Compiler inputs remain build material. +3. **Make native selection enforceable.** Target-suffixed AWS-LC variables can override a generic selector; SQLite pkg-config can override the bundled feature. The recipe must control both channels and validate actual archives/link directives. Effective Rust features come from compiler messages, not metadata unions. +4. **Implement release policy beyond gh exit statuses.** Additional REST/verified-statement checks are needed for raw tag type, immutability, subject names and source authority. Signed release subjects have names/digests but no size; lengths bind through the signed index, actual bytes and REST metadata. The successful renamed-file negative probe prevents treating `verify-asset` as a name check. +5. **Attribute SBOM comparison correctly.** gh returns the verified predicate; it does not compare a downloaded SPDX file. The strict duplicate-rejecting document comparison belongs to the new Trusted Server wrapper and producer validation. +6. **Match actual artifact-transfer identity.** Actions artifact metadata contains run/source identity but no independent producer-job/attempt field. Job binding comes from the expected upload outputs and workflow topology. Archive transfer hash is distinct from executable SHA-256; mismatch warnings must fail the collector. +7. **Close schema gaps.** Newline-suffixed tag/names matched regex end anchors. Explicit newline rejection now accompanies patterns. Inventory and companion declarations have role-specific size limits, including 16 MiB SBOM. Parser/download limits and cross-document checks still belong to the implementation, not JSON Schema alone. +8. **Prevent deployment-envelope fixture leakage.** Packing carries the entire manifest, including local test values. The production envelope must use an allowlisted deployment-only manifest, without local-server sections, active settings or secrets; such a minimal manifest packed successfully. Sanitized empty/`package` names are rejected to avoid archive selection collisions. Pack/validate do not establish valid wasm; actual core-module parsing and app execution are separate gates. +9. **Bound recovery and isolation claims.** The clone API has no documented caller idempotency key. A lost first-clone response can leave multiple indistinguishable candidate versions; reconciliation can remain unknown and must not mutate a guess. Config staging isolation is not automatic KV/secret/external-effect isolation. Secret values are write-only, so readback validates references/metadata rather than plaintext. [Fastly version API](https://www.fastly.com/documentation/reference/api/services/version/), [resource links](https://www.fastly.com/documentation/reference/api/services/resource/), [secret-store semantics](https://www.fastly.com/documentation/reference/api/services/resources/secret-store-secret/). +10. **Qualify Cloudflare inheritance by endpoint.** Ordinary upload inherits latest and rejects a different version ID. Explicit non-latest version inheritance belongs to beta Workers API Versions endpoints and its own reviewed adapter contract. [Cloudflare error 10057](https://developers.cloudflare.com/workers/observability/errors/). + +## Requirement feasibility register + +“Demonstrated” means only the stated local probe. “Supported” means pinned source/docs expose the needed primitive. “New code” is an implementable contract absent from the existing application. “Unqualified” means a mandatory shipping proof is missing. A composite row can have more than one status because a working primitive does not supply the full policy. + +| Requirement | Status and evidence | Implementation or qualification still needed | +| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| R-COHORT | New code; fixed profiles are structurally expressible | Source-controlled cohort/collector; all four canonical Build A qualification gates | +| R-PLATFORM | ARM build/run and loader floor demonstrated; GNU/Intel/hosted 15 unqualified | Actual glibc 2.34 builder and ELF symbols/UBI/Ubuntu/Debian tests; hosted ARM/Intel execution; review any baseline change before enablement | +| R-VARIANT | Supported by current Cargo/runtime source; default artifact ran locally | Release variant allowlist/diagnostic; reuse remains separate qualification | +| R-CONFIG | Source-supported separation; runtime fixture loaded independently of wasm | Whole-release differing-publisher-input negative fixture; permission-spec compatibility | +| R-INPUTS | Important inputs and native/script env evidence observed | Complete tracked source/dependency/sysroot/tool/native identities; new generated input coverage | +| R-ENV | Denied socket and selected fresh environments demonstrated | Checked release env whitelist, private gh home/state, all skip values, Cargo/config layers, Vite env suppression and source mutation guards | +| R-JS | Fresh A/B, tests, prebuilt handoff, Rollup/missing-tool probes demonstrated | Current merge reconciliation, trusted inventory binding, final retained classification, hosted transfer | +| R-BUILD | Full frozen network-denied local builds demonstrated | Digest-pinned hosted recipes; verified fresh material extraction; every selected confinement profile and native bootstrap | +| R-REPEAT | Complete Fastly equality demonstrated locally | Separate hosted Linux jobs, final facts/source, explicit upload outputs/transfer failure gate; optional-native terminal outcomes | +| R-NATIVE | Whole ARM CLI and static native evidence demonstrated | Effective tool/SDK pins, platform linker/signature policy; GNU/Intel measurements; no native equality shipping claim | +| R-SBOM | Limited selected-unit/native serialization prototype demonstrated | Full real artifact SPDX coverage, source/license/roles/sysroot/native/JS joins and negative fixtures; not a trivial 86-line production script | +| R-FACTS | Cargo inputs and exact #1213 helper semantics supported/probed | Strict full-commit release validation, no git fallback, owning adapter variant, typed facts, CLI source version and literal fallback removal | +| R-SOURCE | Existing branch/CI shape inspected; APIs supported | Protected main/approved-RC policy, trusted same-commit workflow-call validation and pending-check deadline; annotated-tag rejection | +| R-SIGN | Action inputs/toolkit predicate source-supported and shape executed | Real hosted exact-version provenance+SPDX certificates, caller permission propagation, least-privilege jobs and collector | +| R-PUBLISH | Existing public immutable release consumer path demonstrated | Authorized disposable publication rehearsal, repository settings/rules/bypass audit and RC workflow-difference/minimal-token test | +| R-INDEX | Schema and negative structural checks demonstrated | Producer and strict consumer parser; all semantic source/name/policy/digest/size joins | +| R-EVIDENCE | Existing signed bundle/release graphs observed | Full new index/executable/companion/SPDX relationship checker and actual project fixtures | +| R-POLICY | Exact tool flags/certificate JSON demonstrated | Embedded current/historical policy identity, action/caller/run checks, closed profile/action permissions | +| R-BOOTSTRAP | Auth requirement and isolated no-auth offline primitive demonstrated | Trusted gh installation, external first-use CLI verification procedure, explicit token/env/path handling | +| R-BOUNDS | Actual fixture outputs measured; limits expressible | Concurrent bounded-pipe reader, timeouts/cancel/kill/reap, strict JSON/depth/duplicate checks and negative tests | +| R-PUBLICATION | Primitive signatures work; missing name/type/immutable checks confirmed | REST plus verified-statement wrapper and source approval; cannot use command success alone | +| R-OFFLINE | Exact build proof with denied network and rejection cases demonstrated | Document transport trust/pins; official offline deployment remains intentionally unsupported | +| R-DEPLOY | Canonical byte check/source-supported transport | Shared verifier/retained handles/action validation; integrated upstream API is separate milestone | +| R-PACK | Byte-preserving pack and adversarial local envelope cases demonstrated | Allowlisted deployment-only manifest, strict bounded archive inspector and final real-module/runtime validation | +| R-CONTEXT | Exact links/selectors endpoints source-supported | Preflight/resulting-draft readbacks and explicit sharing policy; live propagation/readiness proof; secret plaintext cannot be read | +| R-EDGEZERO | Clone → exact draft update → context → stage/activate APIs supported | New typed upstream API and structured outcomes, no source/manifest hooks; current adapter does not implement it | +| R-RECOVERY | Filesystem journal design implementable; provider uncertainty boundary identified | Flush/atomic records before each write, exact-version inspection where known, unresolved clone handling/crash fixtures; no exactly-once guarantee | +| R-MANUAL | Pack/flags verified; actual wasm ran only locally | Complete verified policy/inspection instructions and separately authorized disposable Fastly production submission | +| R-FOLLOWUP | Existing adapter facts inspected with endpoint correction | Independent Cloudflare/Spin/Axum/reconstruction specs; no additional issue-closure gate here | + +## Executable implementation order + +The implementation must produce these concrete interfaces and evidence; this is a dependency analysis, not a claim that the named scripts already exist. + +1. **Reconcile inputs and facts.** Merge/reconcile #1214 and #1213; add exact expected JS inventory validation, strict release fact/skip/env handling and disable the compiled CLI checkout fallback. Preserve ordinary development behavior separately. Exercise these guards before adding signing. +2. **Qualify actual platform recipes.** `scripts/release/build.sh` owns source/material preparation, effective settings, confinement and final artifacts. Linux can use a digest-pinned OCI environment with no compile network. macOS needs a tested confinement/SDK/native profile on the selected hosted machines. Material preparation and network-denied compilation are separate. Establish GNU 2.34 and macOS 15 ARM/Intel startup before approving the fixed first cohort; a failed baseline requires a reviewed change, not an after-failure downgrade. +3. **Generate actual evidence and SPDX.** Collector inputs are Cargo JSON, selected metadata/tree, checked material identities, Rollup inventory, native output/link declarations, sysroot/tools and final files. Produce prepared and retained-module lists separately. Validate real Fastly and each CLI SBOM, not only a toy fixture. Then pin the tool/generator schema and negative coverage tests. +4. **Implement same-commit validation and transfers.** Refactor current CI into reviewed callable validation or equivalent explicit release jobs. Run required validations at the tag SHA, shared JS A/B, Fastly A/B and required native Build A with bounded optional comparisons. Upload selected raw outputs; pass artifact ID/archive hash through explicit job outputs. Fetch exact current-run ZIP bytes, fail transfer mismatch and inspect/extract only declared bounded members. Do not restore compiled state across the supposedly independent builds. +5. **Sign and publish through a tested boundary.** A separate signing/collector job authenticates source/input/output/evidence relationships before invoking the pinned action twice per artifact and signing the index. An existing lightweight tag avoids relying on automatic tag creation. Caller job permissions must allow the called signing jobs while build jobs explicitly remain read-only. The publication job handles draft/resume/upload/redownload/publish/reconcile with contents-write. Rehearse main and RC workflow divergence, partial drafts, reruns, optional failures and mutable/unknown publication before production enablement. [Release API permission behavior](https://docs.github.com/en/rest/releases/releases#create-a-release). +6. **Implement the online consumer.** A shared library performs strict schema/policy joins, invokes bounded pinned gh on retained bundles, checks all certificate/provenance/SPDX fields and verifies release API/statement semantics. Its private verified result retains selected bytes. `ts release verify` is read-only; manual packing uses that result plus a deployment-only envelope and archive inspector. Exact identity flags alone do not implement the evidence graph. +7. **Deliver the manual verified-byte production route.** Authorize a disposable provider test separately and retain the submitted-member/version observations. This route does not certify integrated staging, preactivation validation or remote execution. It can precede the upstream API. +8. **Implement the separate Fastly upstream milestone.** EdgeZero `deploy_prebuilt` takes retained payload identity, exact base/service/mode and resolved context. Use explicit clone, non-autoclone update of the returned draft, exact draft link/selector validation, stage/activate. Durable journal/pending markers and read-only reconciliation belong here. Lost first-clone identity remains unknown if the provider cannot uniquely establish it; do not invent an exactly-once contract. + +Future stronger builder isolation, independently reconstructed executables, offline official membership and additional adapters use separate proof boundaries. They are not shortcuts to close missing evidence in this initial pipeline. + +## Remaining qualification boundaries + +The local machine had no usable Docker daemon, old-macOS or Intel execution host. GNU compatibility was not inferred from Cargo metadata or an ARM native fixture. Actual hosted action/OIDC, artifact transfer, repository governance and RC release-token behavior require an authorized disposable GitHub rehearsal. Live package/bindings/stage/activation require an authorized disposable Fastly service. No such resources were mutated during this audit. + +These gaps do not establish impossibility of the narrowed architecture; their APIs and locally measured primitives support implementation. They do prevent claiming that all required release behavior is already feasible as a tested composition. Release enablement remains blocked until those selected qualifications and the new code pass. The known unavailable-host gate and overstated existing-tool/final-JS/recovery claims have been removed or corrected rather than passed to an implementation plan as assumptions. + +## Reproduction record + +Audit-session sources, scripts, signed public fixtures, logs and results are retained under the following temporary paths. They are supporting evidence for this session, not retained-input distribution promises; source hashes and limitations above identify what was tested. + +| Surface | Audit-session evidence | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Build investigation | `/tmp/pr1262-build-feasibility.md`; `/tmp/pr1262-build-js-a` and `-b`; `/tmp/pr1262-build-unit-fixture`; `/tmp/pr1262-build-native-fixture` | +| Full wasm | `/tmp/pr1262-fastly-build-probe.py`; `/tmp/pr1262-fastly-A-result.json`; `/tmp/pr1262-fastly-B-result.json`; `/tmp/pr1262-fastly-comparison.json`; `/tmp/pr1262-fastly-byte-membership.json` | +| App runtime | `/tmp/pr1262-fastly-runtime-probe.py`; `/tmp/pr1262-fastly-runtime-result.json`; generated test-only runtime fixture and logs | +| Full CLI | `/tmp/pr1262-cli-build-probe.py`; `/tmp/pr1262-cli-build-result.json`; `/tmp/pr1262-cli-byte-runtime-result.json`; `/tmp/pr1262-cli-native-composition.json`; `/tmp/pr1262-cli-init-result.json`; `/tmp/pr1262-cli-fallback-result.json` | +| Verifier/publication | `/tmp/pr1262-verifier-feasibility.md`; `/tmp/pr1262-verifier` pinned tool/source and offline/online/predicate/ambiguity probes | +| Provider interface/packing | `/tmp/pr1262-deploy-feasibility.md`; `/tmp/pr1262-fastly-source`; fresh pack negative fixtures referenced there | +| Confinement | `/tmp/pr1262-network-isolation-probe.py`; `/tmp/pr1262-network-isolation-result.json` | +| Structural validation | `/tmp/test-release-schema.py`; `/tmp/pr1262-valid-index.json`; temporary pinned JSON Schema validator | + +Pinned implementation references: [gh 2.97.0](https://github.com/cli/cli/tree/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55), [attest 4.2.2](https://github.com/actions/attest/tree/1e69f48acb82d1966a394da916b4c1698aa569d6), [toolkit provenance](https://github.com/actions/toolkit/blob/7987771a2b727fe14b36351f6c877fa3a56b9c36/packages/attest/src/provenance.ts), [Fastly 15.1.0 pack](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go), [validate](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/validate.go), [clone](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/service/version/clone.go), [EdgeZero adapter](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter/src/registry.rs). Cargo [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html) and [build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html), [SPDX 2.3 schema](https://github.com/spdx/spdx-spec/blob/v2.3/schemas/spdx-schema.json). diff --git a/docs/superpowers/specs/schemas/release-index-v1.schema.json b/docs/superpowers/specs/schemas/release-index-v1.schema.json new file mode 100644 index 0000000000..1adaa0d20f --- /dev/null +++ b/docs/superpowers/specs/schemas/release-index-v1.schema.json @@ -0,0 +1,581 @@ +{ + "$comment": "Evaluate explicitly with JSON Schema Draft 2020-12 as required by R-INDEX. This dialect declaration is part of the normative specification.", + "title": "Trusted Server release-index version 1", + "description": "Normative structural contract. The specification adds exact cohort membership, unique asset/companion names, companion naming, byte digest checks, source approval and evidence-policy checks.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "repository", + "tag", + "source_commit", + "recipe_source_commit", + "cohort_source_commit", + "cohort", + "policy_version", + "policy_sha256", + "tsjs_inventory", + "artifacts" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "repository": { + "const": "IABTechLab/trusted-server" + }, + "tag": { + "type": "string", + "maxLength": 128, + "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "recipe_source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "cohort_source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "cohort": { + "const": "phase1-fastly-cli" + }, + "policy_version": { + "type": "integer", + "minimum": 1, + "description": "Producer policy at source_commit. Must match an independently trusted accepted historical producer policy; does not select consumer policy." + }, + "policy_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 of producer policy bytes at source_commit; consumer acceptance is defined by the specification.", + "not": { + "pattern": "[\\r\\n]" + } + }, + "tsjs_inventory": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "name": { + "const": "tsjs-inventory.json" + }, + "size": { + "maximum": 1048576 + } + } + } + ] + }, + "artifacts": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/artifact" + }, + "allOf": [ + { + "contains": { + "properties": { + "name": { + "const": "trusted-server-fastly.wasm" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "name": { + "const": "ts-x86_64-unknown-linux-gnu" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "name": { + "const": "ts-aarch64-apple-darwin" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "name": { + "const": "ts-x86_64-apple-darwin" + } + }, + "required": ["name"] + }, + "minContains": 1, + "maxContains": 1 + } + ] + } + }, + "$defs": { + "file": { + "type": "object", + "additionalProperties": false, + "required": ["name", "size", "sha256"], + "properties": { + "name": { + "type": "string", + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 536870912 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "not": { + "pattern": "[\\r\\n]" + } + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "size", + "sha256", + "role", + "kind", + "package", + "target", + "variant", + "assurance_profile", + "capabilities", + "compatibility", + "js_inventory_sha256", + "companions" + ], + "properties": { + "name": { + "enum": [ + "trusted-server-fastly.wasm", + "ts-x86_64-unknown-linux-gnu", + "ts-aarch64-apple-darwin", + "ts-x86_64-apple-darwin" + ] + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 536870912 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "role": { + "enum": ["server", "operator-cli"] + }, + "kind": { + "enum": ["wasm-core", "native-executable"] + }, + "package": { + "enum": ["trusted-server-adapter-fastly", "trusted-server-cli"] + }, + "target": { + "enum": [ + "wasm32-wasip1", + "x86_64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ] + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "enum": ["provenance-sbom-repeatable", "provenance-sbom"] + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": ["fastly-production", "fastly-staging", "cli-execute"] + } + }, + "compatibility": { + "$ref": "#/$defs/compatibility" + }, + "js_inventory_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "not": { + "pattern": "[\\r\\n]" + } + }, + "companions": { + "type": "object", + "additionalProperties": false, + "required": [ + "sbom", + "provenance", + "sbom_attestation", + "build_evidence", + "build_recipe", + "rebuild_check" + ], + "properties": { + "sbom": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 16777216 + } + } + } + ] + }, + "provenance": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "sbom_attestation": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "build_evidence": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "build_recipe": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + }, + "rebuild_check": { + "allOf": [ + { + "$ref": "#/$defs/file" + }, + { + "properties": { + "size": { + "maximum": 33554432 + } + } + } + ] + } + } + } + }, + "oneOf": [ + { + "properties": { + "name": { + "const": "trusted-server-fastly.wasm" + }, + "role": { + "const": "server" + }, + "kind": { + "const": "wasm-core" + }, + "package": { + "const": "trusted-server-adapter-fastly" + }, + "target": { + "const": "wasm32-wasip1" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom-repeatable" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": ["fastly-production", "fastly-staging"] + }, + "contains": { + "const": "fastly-production" + } + }, + "compatibility": { + "properties": { + "runtime": { + "const": "fastly-compute" + }, + "architecture": { + "const": "wasm32" + } + } + } + } + }, + { + "properties": { + "name": { + "const": "ts-x86_64-unknown-linux-gnu" + }, + "role": { + "const": "operator-cli" + }, + "kind": { + "const": "native-executable" + }, + "package": { + "const": "trusted-server-cli" + }, + "target": { + "const": "x86_64-unknown-linux-gnu" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom" + }, + "capabilities": { + "const": ["cli-execute"] + }, + "compatibility": { + "properties": { + "runtime": { + "const": "linux-gnu" + }, + "architecture": { + "const": "x86_64" + } + } + } + } + }, + { + "properties": { + "name": { + "const": "ts-aarch64-apple-darwin" + }, + "role": { + "const": "operator-cli" + }, + "kind": { + "const": "native-executable" + }, + "package": { + "const": "trusted-server-cli" + }, + "target": { + "const": "aarch64-apple-darwin" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom" + }, + "capabilities": { + "const": ["cli-execute"] + }, + "compatibility": { + "properties": { + "runtime": { + "const": "macos" + }, + "architecture": { + "const": "aarch64" + } + } + } + } + }, + { + "properties": { + "name": { + "const": "ts-x86_64-apple-darwin" + }, + "role": { + "const": "operator-cli" + }, + "kind": { + "const": "native-executable" + }, + "package": { + "const": "trusted-server-cli" + }, + "target": { + "const": "x86_64-apple-darwin" + }, + "variant": { + "const": "default" + }, + "assurance_profile": { + "const": "provenance-sbom" + }, + "capabilities": { + "const": ["cli-execute"] + }, + "compatibility": { + "properties": { + "runtime": { + "const": "macos" + }, + "architecture": { + "const": "x86_64" + } + } + } + } + } + ] + }, + "compatibility": { + "type": "object", + "additionalProperties": false, + "required": ["runtime", "minimum_version", "architecture"], + "properties": { + "runtime": { + "enum": ["fastly-compute", "linux-gnu", "macos"] + }, + "minimum_version": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "architecture": { + "enum": ["wasm32", "x86_64", "aarch64"] + } + }, + "oneOf": [ + { + "properties": { + "runtime": { + "const": "fastly-compute" + }, + "minimum_version": { + "const": "wasip1" + }, + "architecture": { + "const": "wasm32" + } + } + }, + { + "properties": { + "runtime": { + "const": "linux-gnu" + }, + "minimum_version": { + "const": "glibc-2.34" + }, + "architecture": { + "const": "x86_64" + } + } + }, + { + "properties": { + "runtime": { + "const": "macos" + }, + "minimum_version": { + "const": "13.0" + }, + "architecture": { + "enum": ["x86_64", "aarch64"] + } + } + } + ] + } + } +} From 484d2c0845be35f006cd8ecafb54dd1fc41e9a9b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 18:01:49 +0530 Subject: [PATCH 006/153] Plan attested release implementation and integration milestones --- ...6-10-13-attested-release-implementation.md | 386 ++++++++++++++++++ ...3-cli-packaging-attestation-integration.md | 110 +++++ ...13-fastly-prebuilt-edgezero-integration.md | 124 ++++++ 3 files changed, 620 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-13-attested-release-implementation.md create mode 100644 docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md create mode 100644 docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md new file mode 100644 index 0000000000..c9f44e495c --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -0,0 +1,386 @@ +# Attested release binaries and verified consumption Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish and verify the four canonical Fastly/`ts` artifacts required by #1235, with complete evidence and a validated manual Fastly production route. + +**Architecture:** Controlled producer scripts build and describe bytes; a reusable workflow signs only validated outputs; one caller publishes the complete immutable distribution. A host-only Trusted Server verifier separately establishes build evidence, publication and consumer authorization before retaining verified bytes. Packaging and integrated EdgeZero deployment are separate milestones that consume these boundaries. + +**Tech Stack:** Rust 1.95.0/2024, existing error-stack/serde/serde_json/tokio/reqwest/tempfile, workspace sha2, Python unittest and pinned JSON Schema validation, Node 24.12.0/npm, Vite/Rollup, SPDX 2.3, GitHub CLI 2.97.0, actions/attest 4.2.2, Fastly CLI 15.1.0. + +--- + +## Authority, baseline and scope + +Planning baseline: `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52`, inspected 2026-10-13. The governing [contract](../specs/2026-10-09-1235-release-attestation-design.md), [feasibility register](../specs/2026-10-12-1235-release-attestation-feasibility.md) and [index schema](../specs/schemas/release-index-v1.schema.json) remain drafts awaiting maintainer approval. This plan does not approve their implementation, qualify a release, or authorize live publication/deployment. Task 1 establishes that prerequisite; writing/reviewing this plan can proceed now. + +#1214 and #1213 are present at this baseline. Extend their merged private JS output/prebuilt interface and source-version mechanism; do not reimplement the historical audit snapshots. Source deployment currently delegates to EdgeZero and no release workflow/verifier exists. + +The initial cohort is exactly: + +| Canonical asset | Required assurance | Blocking qualification | +| ----------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `trusted-server-fastly.wasm` | `provenance-sbom-repeatable`; default, no reusable sandbox | Final complete-byte A/B equality, real SBOM/runtime and verified manual submission | +| `ts-x86_64-unknown-linux-gnu` | `provenance-sbom` | glibc ceiling 2.34; clean UBI 9, Ubuntu 22.04, Debian 12 startup | +| `ts-aarch64-apple-darwin` | `provenance-sbom` | Explicit loader floor 13.0; actual macOS 15 ARM execution | +| `ts-x86_64-apple-darwin` | `provenance-sbom` | Same loader floor; actual macOS 15 Intel execution | + +Every selected canonical Build A, SBOM, signature and publication check is mandatory. CLI Build B is a bounded measurement, not a shipping equality requirement. No Linux ARM64, installer packages, other adapter releases, retained-input archive, operator reconstruction or official offline deployment is added by this plan. + +Two linked plans keep independent work out of these closure criteria: + +- [CLI packaging attestation integration](2026-10-13-cli-packaging-attestation-integration.md): reconcile #1292, package exact canonical bytes and authenticate member identities. Later releases only after the required spec/schema amendments are approved. +- [Fastly prebuilt EdgeZero integration](2026-10-13-fastly-prebuilt-edgezero-integration.md): upstream prepared payload, integrated online production/staging and durable recovery; AC-8 is separate from initial #1235 closure. + +## Work breakdown and ownership + +Use successive focused PRs: (1) input/fact/JS guards, (2) qualified recipes and composition, (3) collection/signing/publication, (4) verifier/manual route/docs. Each depends on its predecessor; tag enablement waits for all four. Build scripts can be proved locally before production YAML exists. No intermediate PR may enable an incomplete release trigger. + +| File group | Responsibility | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| `scripts/release/release-cohort.json`, `release-tools.json` | Reviewed fixed identities, profiles, approved RC branches and qualified environment pins | +| `scripts/release/common.py`, `preflight.py`, `prepare.py` | Strict bounded JSON, source/material authentication, explicit environment and preparation | +| `scripts/release/build-js.sh`, `collect-js.mjs` | Independent locked JS A/B and final contribution inventory | +| `scripts/release/build.sh`, `inspect-native.py`, `collect-composition.py` | Fixed artifact recipes, native qualification and observed build evidence | +| `scripts/release/generate-sbom.py`, `collect-builds.py`, `make-index.py` | SPDX coverage, exact transfer/comparison gates and distribution assembly | +| `.github/workflows/release-validation.yml`, `release-build.yml`, `release.yml` | Same-commit gates; isolated builds/signing; sole draft/publication owner | +| `crates/trusted-server-cli/src/release/` | Host-only reusable verifier: policy, strict documents, bounded gh, GitHub publication and evidence graph | +| `crates/trusted-server-cli/src/commands/release.rs` | Public read-only `ts release verify` adapter to the verifier | +| `scripts/release/inspect-fastly-package.py` | Trusted-source manual-route archive inspection, never a replacement verifier | +| `scripts/release/tests/`, CLI/core/JS tests | Negative producer fixtures, complete consumer fixtures and actual artifact qualification | + +Execute each listed negative fixture as a separate red/green microcycle: write one case, run its focused filter, implement the smallest relevant behavior and rerun it, then move to the next case. A task groups related work for review/commit; its fixture list is not one giant implementation step. + +New files below are proposed, not existing APIs. Keep security parsing and operation decisions in focused modules. Use concrete error-stack reports, top-level imports, descriptive assertions and project commit conventions. Reuse existing libraries; add host-only sha2 and a pinned schema validator only with dependency justification. An archive parser belongs to the manual helper initially, so do not add a Rust archive dependency merely for future deployment. + +### Task 1: Freeze approved scope and reconcile the packaging owner + +**Files:** Modify this plan and the governing spec only for approved corrections; reference PR #1292's `docs/superpowers/specs/2026-10-12-863-cli-release-packaging-design.md` and `docs/superpowers/plans/2026-10-12-cli-release-packaging.md` after their owning branch is available. + +- [ ] Read the merged/current spec revision; record its commit and maintainer approval before executing implementation tasks. +- [ ] Record one publication owner, `.github/workflows/release.yml`, and one signing identity, same-revision `release-build.yml`. #1292 must not independently publish an overlapping immutable tag while #1235 assets are still pending. +- [ ] Confirm the current four-artifact cohort and three CLI baselines. Initial #1235 delivery may precede packaging on a different new tag. Adding package records or ARM64 requires the linked plan's approved delta, never a covert schema expansion here. +- [ ] Settle one strict producer control channel: `TRUSTED_SERVER__RELEASE_BUILD=1`, injected by trusted recipe after environment sanitation, recorded as an input, rejected if present with another value. This is a fact/input guard, not the prohibited parallel JS fallback mode. Name the checked prebuilt inventory input `TSJS_PREBUILT_INVENTORY`. +- [ ] Confirm permission policy remains runtime-variable; conflicting #1045/#1084 code blocks release enablement. Record the owner and dependency rather than designing a new permission transport. +- [ ] Review milestone exits with maintainers. Commit any approved documentation delta separately with `Clarify release implementation prerequisites`. + +**Exit:** An engineer can identify approved source, scope, producer controls and the single release authority; no unspecified cross-PR ownership decision remains. + +### Task 2: Establish producer fixtures and strict document utilities + +**Create:** `scripts/release/common.py`, `scripts/release/tests/test_common.py`, `scripts/release/tests/fixtures/README.md`, `scripts/release/requirements.txt`. + +- [ ] Write unittest cases for duplicate keys, nonfinite JSON, trailing controls, unsafe names, depth 65, oversized input, negative lengths and ambiguous duplicate identities. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_common.py' -v`; expect failure because the module does not exist. +- [ ] Implement strict byte-first loading, bounded reads, SHA-256, safe flat asset names, deterministic output and atomic writes. Start duplicate handling with: + +```python +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result +``` + +Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_constant)` after size/depth checks. Hash original file bytes, not parsed/reserialized objects. + +- [ ] Qualify and pin the schema-validator distribution and hashes in `requirements.txt`; install through a private venv. Add explicit dependency installation to producer validation, rather than relying on ambient Python packages. +- [ ] Re-run the tests; expect all negatives rejected and deterministic positive bytes. Commit `Add strict release document utilities`. + +### Task 3: Encode cohort, tools and fail-closed release preflight + +**Create:** `scripts/release/release-cohort.json`, `release-tools.json`, `preflight.py`, `tests/test_preflight.py`, `crates/trusted-server-cli/release-policy.json`. + +- [ ] Write cases for missing/extra artifacts, profiles changed after failure, unknown target/feature, missing pins and every skip value (`""`, `"0"`, `"1"`). Add wrapper, Cargo config, NODE_OPTIONS, native selector and source mutation cases. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_preflight.py' -v`; expect missing module/behavior failures. +- [ ] Create the tracked consumer/producer policy bytes now, with the spec's concrete fixed identity/profile/action/bounds fields, so recipe/index assembly can bind their source identity before signing. Runtime enforcement is implemented in Tasks 17–20; this source-controlled contract is not a downloadable policy override. +- [ ] Map the four asset IDs to fixed package/bin/target/features. Never accept shell commands from JSON. Pin Rust/Node/npm, gh 2.97.0, attest 4.2.2/full SHA and Fastly 15.1.0; populate actual binary/image/SDK digests only from authenticated acquisition and Task 8 qualification. Missing qualified pins fail `--require-qualified`; no dummy digest is accepted. +- [ ] Inspect the inherited environment before clearing it. Reject skip presence and unreviewed override channels; create a fresh allowlisted child environment. Remove publisher prefixes and inject only approved commit/control facts. Derive epoch from the approved Git commit's committer timestamp. Check tracked bytes plus unexpected source-only files, modes and links before/after execution. +- [ ] Select both generic and target-suffixed AWS-LC selectors, bundled SQLite, Cargo config/home layers, sysroot/tool paths and Vite mode. Set `AWS_LC_SYS_USE_SYSTEM=0`, `LIBSQLITE3_SYS_USE_PKG_CONFIG=0`; reject alternate selectors rather than merely setting weaker generic defaults. +- [ ] Re-run fixtures, including a fictional operator-config independence case. Commit `Define fixed release inputs and preflight policy`. + +**Interface:** `python3 scripts/release/preflight.py --cohort scripts/release/release-cohort.json --source-commit --require-qualified`; exit 0 only for a complete selected contract, exit nonzero before preparation/signing otherwise. + +### Task 4: Reconcile immutable build facts and CLI source resources + +**Modify:** `crates/trusted-server-core/build.rs`, `build_support/git_version.rs`, `tests/git_version_resolve.rs`, `src/lib.rs`; `crates/trusted-server-cli/src/run.rs`, `src/prebid_bundle.rs`. +**Create:** core `src/build_info.rs`; CLI `build.rs`, `tests/release_build_facts.rs`. +**Modify when adding the owning facts surface:** `crates/trusted-server-adapter-fastly/src/main.rs`; retain existing version-header/service-version behavior. + +- [ ] Add strict full-commit tests: missing/malformed/branch/tag values fail in release mode without local-git fallback; development behavior stays covered. Add two external tags/same source yielding identical facts, owning-target/variant checks and compiled-path fallback rejection. +- [ ] Run `cargo test -p trusted-server-core --test git_version_resolve` and `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_build_facts`; expect the new behavior to fail. +- [ ] Extend the merged #1213 helper using the approved release control. Derive target from Cargo `TARGET`, package version from the owning product, and default/reuse variant at the Fastly owner. Expose typed immutable facts without importing verifier/publisher keys into core. Do not embed tag, run ID, time or final checksum. +- [ ] Set clap's version text to package version plus source identity. Official releases compile out `find_js_lib_dir`'s literal developer checkout fallback; preserve explicit/current-checkout resource discovery. Keep the tracked example config as a fictional scaffold, never active settings. +- [ ] Run core fact/template tests, CLI tests/clippy and Fastly version-header tests. Outside-checkout help/version/config-init must pass; Prebid source-dependent commands return documented prerequisites. Commit `Derive immutable release build facts`. + +### Task 5: Authenticate the existing prebuilt JS interface + +**Modify:** `crates/trusted-server-js/build.rs`, `build/bundle_set.rs`, `src/lib.rs` test inclusion. +**Create:** `crates/trusted-server-js/tests/release_prebuilt.rs`. + +- [ ] Write process/fixture tests for prebuilt mode plus any skip presence, absent inventory, wrong source/digest/size, duplicate IDs, unsafe filenames, missing/empty/extra modules and source changed during copy. +- [ ] Run `cargo test -p trusted-server-js --test release_prebuilt`; expect current prebuilt behavior to fail these guards. +- [ ] Extend the existing bundle-set validator; require `TSJS_PREBUILT_INVENTORY` in release mode and validate exact input and private copied bytes. Track both paths/control values with Cargo rerun directives. Reject skip before selecting prebuilt/source path; no TSJS_RELEASE_BUILD parallel mode and no read from old `dist/`. +- [ ] Preserve ordinary development's supported fresh source build. In release Rust jobs require prebuilt inputs and prohibit npm invocation; a fake failing npm proves it is never run. +- [ ] Run JS crate tests, Fastly/CLI target checks and prebuilt negatives. Commit `Bind release JS inputs to a checked inventory`. + +### Task 6: Collect JS contributions without changing bundle bytes + +**Modify:** `crates/trusted-server-js/lib/build-module-options.mjs`, `build-all.mjs`. +**Create:** `lib/test/release-composition.test.mjs`, `scripts/release/collect-js.mjs`. + +- [ ] Write temporary IIFE fixtures for type-only/external Prebid import, eliminated module, transitive npm contributor and plugin/virtual helper. Build with/without recording and assert identical final JS bytes. +- [ ] Run `cd crates/trusted-server-js/lib && npx vitest run test/release-composition.test.mjs`; expect missing recording behavior. +- [ ] Add an opt-in Rollup contribution hook at `moduleBuildOptions()`. Join source/package origins to the lockfile, retain installed/lifecycle/build-tool materials separately, and hash final files after Vite/esbuild. Suppress ambient `.env` loading through the reviewed release invocation; explicit production mode alone is not sufficient. +- [ ] Emit sorted version-1 inventory with source, module names/lengths/digests and origins; ambiguous contributing ownership fails. State conservative post-Rollup reachability limits. +- [ ] Run byte-preservation and ownership negatives plus the full JS test/format checks. Commit `Record release JS composition evidence`. + +### Task 7: Prepare and compare two independent JS builds + +**Create:** `scripts/release/build-js.sh`, `prepare.py`, `tests/test_prepare.py`, `tests/test_js_release.py`. + +- [ ] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_*release.py' -v`; expect missing orchestration. +- [ ] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Task 7 initially proves this with local fixtures; Task 8 must qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Bundle under qualified network denial; compilation-time downloads fail. +- [ ] Compare final core and all tracked integration files byte-for-byte. Publish only A plus inventory after equality. Run release JS tests regardless of a local test flag; verify source-only trees remained intact. +- [ ] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Commit `Build and compare shared release JS inputs`. + +### Task 8: Qualify real platform environments before fixing recipes + +**Create:** `scripts/release/qualification/qualify.py`, `socket_probe.py`, `results.schema.json`, `scripts/release/tests/test_qualification.py`; update `release-tools.json` with measured pins. + +- [ ] Write schema/preflight fixtures rejecting wrong architecture, missing SDK/tool digest, failed startup, permitted confined sockets and invented host compatibility. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_qualification.py' -v`; expect unimplemented qualification checks. +- [ ] Execute throwaway full recipes on the actual proposed GitHub-hosted environments: digest-pinned GNU builder <=2.34, matching macOS 15 ARM/Intel, explicit loader floor 13.0. Record host, rustup paths, compiler/linker/SDK/native identities. Docker absence on a developer Mac is not replaced by an inferred Linux result. +- [ ] On each recipe run an unconfined socket success control and a confined denied socket probe, then full compilation under the same qualified boundary. Linux container uses process network isolation; macOS profile must permit required filesystem/process/tool activity while denying sockets. Lifecycle preparation is separate and authenticated. +- [ ] Record actual startup/link evidence and complete Fastly runtime request, not only host health. Missing Intel execution or GNU baseline stops qualification. A different platform/cohort requires reviewed spec changes, not a silent downgrade. +- [ ] Pin authenticated observed digests/bootstrap data and add result validation; commit `Qualify controlled release build environments`. + +**Gate:** This task produces measured pins, not placeholder configuration. It can run only with the separately authorized disposable hosted resources. It does not publish official tags or deploy a real publisher service. + +### Task 9: Implement fixed fresh Rust recipes and native inspection + +**Create:** `scripts/release/build.sh`, `inspect-native.py`, `tests/test_recipe.py`, `tests/test_native.py`, `scripts/release/validate-fastly.py`, `tests/test_fastly_runtime.py`. + +- [ ] Test unknown asset/variant, reused target output, source/material mutation, unselected native library, GLIBC symbol ceiling violations, runner-only paths and Mach-O deployment-target mismatch. Add role-specific variation fixtures: missing selected B variation, normalized values misreported as varied, unsupported user variation claimed as passed and incorrect observed concurrency/umask. +- [ ] Run both new unittest modules; expect missing recipe/inspection behavior. +- [ ] Implement a closed interface: `build.sh --asset --role A|B --source-commit --js-dir --js-inventory --materials --output `. Select package/target/features from reviewed source; JSON never supplies executable commands. +- [ ] Implement checked A/B ambient profiles in the recipe and record them in `release-tools.json`: different external checkout roots, A umask 022/B umask 027, UTC versus a qualified alternate timezone, distinct qualified locale settings, different observed start times and A jobs 4/B jobs 2 within the qualified insensitive envelope. Vary execution user only where Task 8 proves support; otherwise record it unsupported without claiming it changed. Capture both ambient inputs and effective compiler values after sanitation/remapping; fixed internal locale/path/epoch values are normalized, not varied. No unreviewed caller overrides can choose profiles. Tests assert genuinely observed supported differences and reject dishonest/omitted records. +- [ ] Build explicit packages with `--release --frozen --message-format=json-render-diagnostics` after preparation. Keep `debug = 1`. Capture compiler/build-script output separately from diagnostic stderr; copy only final output after any reviewed transformations. Do not invoke Fastly source build/annotation. Never strip after evidence/signing. +- [ ] Inspect ELF/Mach-O libraries and paths, run clean Linux startup fixtures and real ARM/Intel macOS startup outside checkout. With differing fictional operator config removed by preflight, require identical sanitized effective source/tool/build inputs for every artifact and reject embedded operator sentinels/config fallback. Require complete executable equality for Fastly; preserve native nonmatching outcomes as measurements rather than adding a native repeatability shipping gate. Separately show runtime configuration can change without rebuilding. +- [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture must not invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. +- [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. + +### Task 10: Record selected Cargo units and native materials + +**Create:** `scripts/release/collect-composition.py`, `tests/test_composition.py`, `tests/fixtures/composition/`. + +- [ ] Add fixtures for host/target dual-role packages with different unit features, ambiguous source IDs, dev-only graph entries, unknown native origin, system substitution and prepared-but-discarded CLI JS. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_composition.py' -v`; expect absent collector. +- [ ] Join explicit-target compiler/build-script messages with metadata/tree and selected material identities; preserve each observed unit's features/context. Package union features cannot stand in for effective per-unit features. Retain raw observations for audit; do not claim an exact compiler dependency DAG. +- [ ] Capture AWS-LC/ring/SQLite native source and output archive hashes/link directives, Rust sysroot and external platform libraries. Validate generic/target-specific selection against the recipe. Distinguish embedded components, external requirements and build tools. +- [ ] Inspect final files conservatively for complete JS module strings. Record absence explicitly; Fastly and CLI do not need the same retained set. Do not force unused JS into CLI. Commit `Collect artifact-specific build composition` after negatives pass. + +### Task 11: Generate complete artifact-specific SPDX + +**Create:** `scripts/release/generate-sbom.py`, `tests/test_sbom.py`, `tests/fixtures/spdx/`. + +- [ ] Write cases for incorrect root digest, missing native coverage, ambiguous package identity, unknown license, duplicate SPDX IDs, dangling relationships, uncompiled dev dependencies, type-only/virtual JS and oversize predicates. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_sbom.py' -v`; expect missing generator. +- [ ] Emit SPDX 2.3 JSON from Task 10 normalized evidence. Root checksum is final executable SHA-256; contributors/materials/tools/external requirements have distinct relationships. Unknown licenses use NOASSERTION; missing contributing origin/coverage fails. Validator uses a pinned authoritative SPDX schema. +- [ ] Generate full real Fastly and all three CLI SBOMs; schema-valid toy fixtures are not sufficient. Validate size <=16 MiB and document equality semantics: reject duplicate keys, ignore object order, preserve array order. +- [ ] Re-run fixtures plus actual coverage inspection. Commit `Generate validated per-artifact SPDX evidence`. + +### Task 12: Bind transfers and enforce selected comparisons + +**Create:** `scripts/release/collect-builds.py`, `tests/test_collection.py`. + +- [ ] Test stale/wrong-run/source transfers, reused A/B artifact IDs, transfer hash warning, traversal/duplicate ZIP members, missing A, changed inventory, missing supported B variation or dishonest normalized/unsupported claims, absent final-byte runtime proof, and a one-byte final wasm difference. +- [ ] Run collection tests; expect missing validator. +- [ ] Accept only expected upload-step outputs: IDs/archive digests mapped by trusted workflow dependencies to job/attempt/role/recipe. Re-download exact current-run artifacts and validate REST repository/run/source plus transfer bytes. Artifact API does not independently identify the producing job/attempt. Verify bounded declared ZIP members before extraction. +- [ ] Match expected role-specific ambient profiles from the approved recipe against observed A/B records before signing. For mandatory Fastly A/B, all qualified supported dimensions must differ as selected; normalized values remain explicitly identified and omitted supported dimensions fail. Successful native comparisons use their selected observed profile and result binding, but a bounded native B failed/unavailable before environment setup records unavailable fields honestly and cannot be rejected for lacking observations it never produced. Add that exact nonblocking fixture; all native A qualification/composition remains mandatory. Verify final-byte core-format/publisher-request results for both Fastly outputs. Fresh-job identity alone cannot satisfy the variation or runtime contract. +- [ ] Compare complete final Fastly files with no normalized/excluded regions. For native B produce exactly matched/mismatched/failed/unavailable within its deadline; native A failures always block. Record ambient variations and normalized internal values honestly. +- [ ] Preserve A as canonical only after all mandatory requirements pass. Add explicit tests that optional B failure cannot hide any A/composition/JS failure. Commit `Validate release transfers and comparison outcomes`. + +### Task 13: Assemble and validate the exact evidence graph + +**Create:** `scripts/release/make-index.py`, `validate-distribution.py`, `tests/test_index.py`, `tests/fixtures/index/`. +**Modify only with approval:** the governing index schema. + +- [ ] Port positive and adversarial schema fixtures: exact four artifacts, unknown capabilities, duplicate names/keys, source/cohort/recipe mismatch, unsafe names/newlines, altered lengths/digests and role-specific limits. +- [ ] Run index tests; expect missing assembly/semantic checks. +- [ ] Assemble unsigned companions from successful observed outputs, then artifact provenance/SBOM bundles, then the signed index. Index names/hashes all companions and shared JS inventory; exclude own digest/bundle. Recipe/rebuild records are authenticated informational data, not executable programs or independent trust. +- [ ] Require SPDX parsed predicate/document equality plus separate file hashes. Producer verifies every exact expected provenance subject; no additional unapproved code subject. SHA256SUMS covers every other published asset and not itself. +- [ ] Cross-check schema and semantic joins on all real artifacts before signing/rehearsal. Commit `Assemble the authenticated release distribution`. + +### Task 14: Expose trusted same-commit validation + +**Create:** `.github/workflows/release-validation.yml`, `scripts/release/tests/test_workflow_contract.py`. +**Modify:** `.github/workflows/test.yml`, `format.yml` only to extract/reuse existing required jobs without losing existing triggers/gates. + +- [ ] Add static workflow fixtures for wrong SHA/event/ref, unauthorized RC branch, skipped/path-filtered gates, arbitrary commands, excessive permissions and any floating external action ref in the complete release/validation invocation. +- [ ] Run workflow contract tests; expect absent callable validation. +- [ ] SHA-pin every external action in release.yml, release-build.yml and all called release validation jobs, including checkout/setup/cache/upload/download and attest. Preserve readable version comments, audit dependencies and reject floating @v\*/main refs. Local workflow calls are allowed only at the caller's checked source revision. Existing test/format action refs must not be inherited unreviewed into this path. +- [ ] Expose reviewed workflow_call or explicit jobs running every current AGENTS gate at the tag SHA. Maintain PR/main tests; RC is permitted only by exact reviewed cohort branch entries. Validate strict SemVer/lightweight tag independently; annotated tags fail. +- [ ] Wait up to 90 minutes for this invocation's required terminal validation results; fail cancelled/skipped/timeout results with rerun guidance. Do not trust arbitrary named successful checks or unrelated main commits. +- [ ] Include JS build/tests/format, all Rust aliases/parity/CLI/build-digest, docs/Markdown and `scripts/lint-wasm-clock.sh`. Commit `Validate release source at the exact tag commit`. + +### Task 15: Orchestrate independent builds and isolated signing + +**Create:** `.github/workflows/release-build.yml`; extend workflow contract fixtures. + +- [ ] Test topology: shared JS A/B, separate fresh Fastly A/B, three mandatory native A jobs, bounded optional native B jobs, validated current-run collector and no signing credentials in executable/lifecycle jobs. Require job configurations to apply Task 9 role profiles and publish observed variation/runtime records; tests reject default-only A/B jobs or collectors that bypass these gates. +- [ ] Run workflow tests; expect absent graph. +- [ ] Call local reusable workflow at source revision. Build jobs are read-only. Collector uses Task 12 outputs; signing grants only contents-read/id-token-write/attestations-write, plus actions-read when needed. No artifact-metadata write for file attestations. Caller grants enough permissions for called signing jobs but its own build jobs explicitly narrow them. +- [ ] Invoke pinned attest 4.2.2 twice per canonical asset: exact executable/SBOM/evidence/recipe/rebuild provenance subjects, then executable subject + SPDX predicate. Save named bundles, validate all proofs/joins and sign index after collection. Do not construct caller predicates or sign source supplied commands. +- [ ] Rehearse actual certificates/toolkit fields in a disposable repository with test-only policy fixtures. Verify repository IDs, exact SAN, issuer, build/caller/source/event/run fields for both predicate types. Commit `Sign validated release outputs through the reusable builder`. + +### Task 16: Publish through one immutable-release owner + +**Create:** `.github/workflows/release.yml`, `scripts/release/publish.py`, `tests/test_publication.py`. + +- [ ] Add fake API cases for matching/conflicting draft, missing/altered asset, concurrent rerun, lost response, already published tag, mutable/unknown final state and permission differences on approved RC commits. +- [ ] Run publication/workflow tests; expect absent publication owner. +- [ ] Keep tag-scoped concurrency without cancellation. Draft, upload, re-download and validate the exact asset set before publication. Match source/bytes for draft resume; never overwrite published assets/tags. Publication has contents-write only, no OIDC/provider/settings token. +- [ ] Require maintainer governance approval and immutable/tag-rule setup before enabling tags; afterward read immutable=true and verify GitHub release statement/membership with the normal token. A confirmed mutable release fails publication, is removed only when the workflow release-write role authorizes that still-mutable cleanup, and produces an incident with the cleanup outcome. Never delete an immutable release. Unknown state produces an incident and read-only reconciliation; it must not trigger blind deletion. A lost publish response also triggers read-only reconciliation before another mutation. +- [ ] Exercise all failure paths in an authorized disposable public repository and record measured evidence. Production tag/permissions require separate release authorization. Commit `Publish complete immutable attested releases`. + +### Task 17: Implement strict consumer documents and embedded policy + +**Modify:** CLI `release-policy.json` from Task 3. +**Create:** CLI `src/release/mod.rs`, `error.rs`, `policy.rs`, `documents.rs`, `src/release/tests/mod.rs`, `src/release/tests/documents.rs`, `tests/fixtures/release/`. +**Modify:** CLI `src/lib.rs`, `Cargo.toml` (host-only sha2/schema validator with justification). + +- [ ] Test closed schema/kind/profile/action combinations, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. +- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::documents`; expect absent modules/behavior. +- [ ] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (trusted source snapshot or bounded official source API acquisition from that exact commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. +- [ ] Register these tests under `#[cfg(test)] mod tests` inside the private release module; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. +- [ ] Fix repository/owner IDs, supported combinations, exact gh/action contract and all R-BOUNDS limits. Re-run tests/clippy. Commit `Parse release evidence under embedded consumer policy`. + +### Task 18: Implement the bounded isolated gh runner + +**Create:** CLI `src/release/gh.rs`, `src/release/tests/gh.rs`. + +- [ ] Write fake-child tests for hangs/cancellation, simultaneous stdout/stderr saturation, malformed/truncated JSON, exit failure, multiple attestations, token precedence, host/config overrides and log redaction. +- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::gh`; expect absent runner. +- [ ] Resolve gh once through trusted PATH/explicit trusted installer configuration, require 2.97.0, and retain that executable target. Pass fixed argument vectors with exact certificate identity; never arbitrary forwarded verifier flags. Accept explicit GH_TOKEN then GITHUB_TOKEN, pass only GH_TOKEN, isolate home/config/state, disable prompts, reject loader injection and keep provider credentials out. +- [ ] Concurrently drain bounded pipes: 120 seconds per child, 15 minutes per attempt, 128 MiB stdout/1 MiB stderr. Kill/reap on either limit/cancellation. Use real OS fake-child fixtures, not mocks that omit pipe pressure. Failed exit never yields trusted parsed claims. +- [ ] Re-run runner negatives and an approved signed public fixture under the pinned gh binary. Commit `Bound and isolate release verification subprocesses`. + +### Task 19: Verify signatures, evidence joins and official publication + +**Create:** CLI `src/release/evidence.rs`, `github.rs`, `artifact.rs`, `src/release/tests/verification.rs`. + +- [ ] Add a valid graph and independently altered edges for index, all provenance subjects, SPDX subject/document/root, JS prepared/retained records, policy/source/run/caller identities, signed names, REST lengths/immutability and tag object type. +- [ ] Run verification tests; expect no implemented evidence graph. +- [ ] Verify index provenance before trusting fields. Obtain only approved official GitHub host/repository data with explicit read-only credential and bounded responses. Validate lightweight source target, source approval, independently acquired fixed-path producer policy bytes at that commit, exact signed release names/digests and actual/index/REST sizes. Primitive gh success is insufficient. +- [ ] Check verified certificate numeric IDs, exact SAN/issuer/build config/digest/trigger/run URI in both proofs, and provenance build type/caller/external parameters/resolved source/invocation. SPDX lacks SLSA caller fields; use its certificate plus subject/document constraints. +- [ ] Separate BuildEvidenceVerified, PublicationVerified and consumer ActionAuthorized states; constructors remain private. A retained verified-release-asset owns private bytes/handle and source/policy/role evidence. Read-only CLI verification returns a VerificationReport, not deployment authorization. The linked integration plan adds the private action-scoped VerifiedArtifact only after concrete context approval; an asserted path/hash cannot construct it. Stream bounded asset responses into retained files, enforcing each role limit while downloading; do not buffer a 512 MiB executable through the gh JSON pipe or check limits only after writing it. Rehash before later use and reject offline official-deploy construction. +- [ ] Exercise first-use verification using an independently trusted previous verifier or source-built verifier from independently approved source, never the downloaded executable verifying itself. Commit `Verify complete official release evidence before authorization`. + +### Task 20: Expose read-only CLI verification and bootstrap instructions + +**Create:** CLI `src/commands/release.rs`, `tests/release_cli.rs`, `src/release/offline.rs`, `src/release/tests/offline.rs`, `docs/guide/release-verification.md`. +**Modify:** CLI `src/commands/mod.rs`, `src/run.rs`; `docs/guide/cli.md`, `.vitepress/config.mts`. + +- [ ] Test the required parse path `ts release verify v1.2.0 --asset ts-aarch64-apple-darwin --expected-source-commit `, invalid source/asset/unsupported offline-deploy flags and no provider/config/build hooks on all failure paths. +- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_cli`; expect unknown command. +- [ ] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag must be documented and reviewed, not smuggled into an implementation example. +- [ ] Document trusted gh installation and independent source/previous-verifier bootstrap before first executing downloaded ts. A source-built verifier provides approved-source trust, not official binary provenance for itself. Describe exact policy checks after primitive gh signatures; do not instruct self-verification or checksum-only installation. +- [ ] Add an internal build-evidence-only offline verification path, reusing parser/policy/subject/SPDX joins and bounded gh runner with independently approved roots/source/digest. Test it with networking denied and token absent; its result cannot construct publication/deployment authorization. No initial public offline flag is added. Document the pinned primitive commands and the manual companion/document/policy checks needed beyond their exit status; offline success is build evidence only, no official release membership or offline deploy. Run CLI helper, docs lint/format/build and malformed/timeout/zero-provider-write fixtures. Commit `Expose official release verification and trusted bootstrap`. + +### Task 21: Validate and document manual Fastly production consumption + +**Create:** `scripts/release/inspect-fastly-package.py`, `tests/test_fastly_package.py`. +**Modify:** `docs/guide/release-verification.md`, `docs/guide/cli.md`, `CHANGELOG.md`; correct obsolete config-embedding comments in source/test/setup files identified below. + +- [ ] Test accepted four-member layout, generic manifest-only archive, swept-in files, reserved/empty package name, traversal, links, duplicates, wrong module/manifest, malformed/non-core wasm and max-size bounds. +- [ ] Run Fastly package tests; expect absent inspector. +- [ ] Use a fresh private directory, approved-source inspector, an allowlisted deployment-only manifest and verified wasm. `compute pack --wasm-binary` has two output archives; select named archive only after exact layout/content checks. Pack/validate accepting a file does not prove valid Wasm or application execution. Verify valid core format and positive publisher-request fixture separately. +- [ ] Inspector takes the approved expected executable digest and manifest identity from the successful verification/context procedure, rehashes both the retained archive and its actual module member immediately before submission, and never trusts a digest supplied by the archive. Document trusted-source acquisition for the helper; it is not a separately authenticated release asset. +- [ ] Document observed service/base-version bindings and explicit production command with --service-id, --version and --no-default-domain. Explain manifest precedence, automatic clone/activation, possible same-hash skip and inability to supply integrated staging/preactivation guarantees. Serialize manual operator changes. No compute publish/source build is permitted. +- [ ] Run a separately authorized disposable Fastly service submission; compare submitted member and actual returned version, including skip/uncertain cases. Record live proof before declaring AC-9 passed. No customer context or provider credentials enter producer jobs. +- [ ] Correct false config-embedding text in `.github/actions/setup-integration-test-env/action.yml`, `.github/workflows/test.yml`, integration runtime/framework/parity helpers, browser infra helper and Spin app/middleware/route comments; behavior changes remain separate. Commit `Document and validate verified-byte Fastly deployment`. + +### Task 22: Full validation, qualification evidence and release handoff + +**Modify:** the implementation PR's evidence record and docs; no automatic production mutation. + +- [ ] Run the complete command block below and collect exit statuses/log summaries. On local certificate/localhost sandbox failures rerun the same tests with needed access; do not weaken TLS or alter assertions. +- [ ] Rehearse hosted source authorization, shared JS, Fastly A/B, all three native A/SBOMs, optional B terminal failures, actual exact-action signatures, immutable draft/publication and full consumer verification together. Toy/synthetic component proofs cannot replace this complete composition. +- [ ] Recheck permission/config separation, governance/rules/bypass authority, bootstrap docs, actual platform evidence and live manual submission. Missing evidence blocks tag enablement; native B equality, packaging or upstream integration do not add hidden blockers. +- [ ] Review against AC-1–AC-7, AC-9–AC-10; keep AC-8 separate. Use @superpowers:requesting-code-review and @superpowers:verification-before-completion. Maintainer approval precedes any production tag. +- [ ] Commit validated documentation/evidence with `Record initial release qualification`; create implementation PRs through the repository PR creator only when their required checks pass. + +## Full implementation verification commands + +Run from the repository root with pinned tools and installed npm dependencies. Linux CI uses its host triple; macOS can use the CLI helper. Record each command independently so a later command cannot mask an earlier failure. + +```sh +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo clippy-cli +cargo clippy-codegen +cargo test-fastly +cargo test-fastly-reuse +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +./scripts/test-cli.sh +cargo test -p trusted-server-core --test template_build_digest +cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings +scripts/lint-wasm-clock.sh +python3 -m unittest discover -s scripts/release/tests -v +``` + +Run the remaining commands with the repository-pinned Node/npm selected explicitly (subdirectory shell startup can otherwise pick a different Node): + +```sh +(cd crates/trusted-server-js/lib && node build-all.mjs && npx vitest run && npm run format) +(cd docs && npm run lint && npm run format && npm run build) +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +RELEASE_HOST_TARGET="$(rustc -vV | awk '/host:/ { print $2 }')" +cargo run -p trusted-server-cli --target "$RELEASE_HOST_TARGET" -- dev lint domains --staged +git diff --check +git diff --cached --check +``` + +Stage the reviewed implementation files before the staged-domain check; each grouped command stops at its first failure. New producer/schema dependencies must be installed at their exact reviewed versions first. No bare workspace cargo test/build or global all-features clippy. + +## Traceability and milestone exits + +| Requirements | Tasks / principal negative proof | Acceptance | +| ------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------- | +| R-COHORT, R-PLATFORM, R-VARIANT | 1,3,8,9: exact cohort/profile, incompatible native runtime, unsupported reuse | AC-2, AC-6 | +| R-CONFIG, R-INPUTS, R-ENV | 3–5,7,9: operator inputs, unexpected source files, skips/overrides, source integrity | AC-2 | +| R-JS | 5–7,10: locked A/B, inventory joins, input versus retained bytes | AC-3, AC-4 | +| R-BUILD, R-REPEAT, R-NATIVE | 8–12: actual confinement, complete wasm equality, valid transfers, honest native outcome | AC-3 | +| R-SBOM | 10,11,13,19: real Rust/native/JS coverage and SPDX root/predicate equality | AC-4 | +| R-FACTS | 4: strict full source, owning target/variant, external tag and unchanged service identity | AC-5 | +| R-SOURCE, R-SIGN, R-PUBLISH | 14–16: approved lightweight main/RC, exact-commit gates, isolated signer, immutable reconciliation | AC-6 | +| R-INDEX, R-EVIDENCE, R-POLICY | 2,13,17,19: schema/semantic joins, subjects and independent policy identity | AC-4, AC-7 | +| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | 18–20: trustworthy first use, limits/pipe pressure, exact REST/signed membership | AC-7 | +| R-OFFLINE | 19,20: approved build proof only; offline official deploy rejected | AC-7 | +| R-DEPLOY, R-PACK, R-CONTEXT, R-MANUAL | 19,21: verified bytes before mutations, explicit manual limits and live submission | AC-9 | +| R-EDGEZERO, R-RECOVERY | Linked upstream plan, not first-release closure | AC-8 | +| R-FOLLOWUP | 1,20–22: explicit unsupported adapters/reconstruction/offline; January supersession | AC-10 | + +A completed task means its described tests pass and reviewed code exists, not merely that its checkbox was checked. Recipe pins/qualification are concrete outputs of Task 8, not assumed values. An unavailable required host blocks the selected contract; it does not authorize weakening it. For optional CLI B only, bounded unavailability is an honest nonblocking result. + +## Planning-only verification and handoff + +For these Markdown plans, validate formatting, links, shell syntax, requirement coverage, task dependencies and independent review. Do not claim that runtime tests verify implementation that has not been written. Preserve the current PR branch; the planning worktree is `/private/tmp/trusted-server-1235-plan-20261013`, branch `plan/1235-attested-release-implementation`. + +## Review record + +On 2026-10-13, independent whole-package review approved these three plans after three passes; separate producer and consumer/upstream reviews informed the corrections. Findings resolved: required Fastly B variation enforcement, final-byte application qualification, mutable-publication recovery, lightweight tagging, native comparison/profile boundaries, all-action SHA pins, approved-source policy-byte binding, lifecycle network denial, and noncircular staging qualification with complete durable outcomes. + +This is planning-completeness approval, not maintainer spec approval or proof of implementation/hosted qualification. Document verification covers formatting/domain rules, relative references, shell/Python example syntax and all 29 requirement mappings. diff --git a/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md b/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md new file mode 100644 index 0000000000..798e9b7a42 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md @@ -0,0 +1,110 @@ +# CLI package attestation integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make installable CLI packages verifiably contain the canonical attested executables, without creating a second release authority or weakening #1235 evidence. + +**Architecture:** Consume final canonical Build A executables and their qualified evidence; packaging never recompiles or alters those bytes. A separately signed package inventory binds outer package digests to exact executable members. One publication job uploads canonical and selected package evidence before publishing a new immutable tag. + +**Tech Stack:** Existing #1292 shell helpers/nfpm packaging design, Python strict metadata and bounded archive readers, SPDX canonical executable evidence, pinned GitHub attest/gh contracts from the primary plan. + +--- + +## Inputs and prerequisites + +Read the [primary release plan](2026-10-13-attested-release-implementation.md), [#1235 contract](../specs/2026-10-09-1235-release-attestation-design.md), PR #1292's packaging spec/plan and its final reviewed revision. At planning time #1292 is documentation only; do not assume its workflow/helper paths exist. Fetch or stack the owning changes before editing them. This plan supplements that plan rather than copying its independent publish job. + +The approved #1235 index v1 covers three raw CLI binaries plus Fastly, no package-inventory reference and no Linux ARM64. The package integration therefore requires a reviewed additive contract and new schema version. Task P1 must approve it before P2–P7 implementation. Neither package provenance alone nor SHA256SUMS satisfies the executable's SPDX/provenance graph. + +**Selected initial integration:** preserve the existing three qualified CLI targets. Produce three Linux x86_64 installer formats and two macOS archives (five packages). This deliberately does not complete #863's Linux ARM64 requirement. Task P8 independently extends canonical qualification/SBOM/policy to ARM64 before publishing its three packages. If maintainers require all eight installer assets in the first packaging release, P8 becomes a prerequisite before that release; no half-qualified ARM asset is slipped into the three-target cohort. + +Initial #1235 release can happen first; packaging appears on a later new tag once its approved contract is enabled. Never backfill packages into an immutable release. No implementation step may remove #1262's raw assets to make room for packages. + +### Task P1: Approve the cross-PR contract and schema migration + +**Modify after owning branch is available:** `docs/superpowers/specs/2026-10-12-863-cli-release-packaging-design.md`, `docs/superpowers/plans/2026-10-12-cli-release-packaging.md`, #1235 contract. +**Create:** `docs/superpowers/specs/schemas/release-index-v2.schema.json`, `cli-packages-v1.schema.json`; keep index v1 immutable for historical verification. + +- [ ] Write a concrete spec delta: one release.yml owner; reusable release-build.yml signer; canonical three CLI profiles plus Fastly unchanged; five initial wrappers; ARM enablement separately selected; package code/member evidence is additive. +- [ ] Agree on #1262's GNU/macOS baselines and real Intel startup. Do not treat Ubuntu 22.04/glibc 2.35 or Rosetta alone as satisfying GNU 2.34/Intel qualification. Keep package ecosystem smoke tests as additional evidence. +- [ ] Resolve version policy explicitly: packaging milestone chooses #1292's tag equals owning CLI Cargo version, with documented maintainer bump/changelog procedure. This is a reviewed narrower packaging prerequisite, not an attempt to embed external tag into binaries. Initial un-packaged #1235 tags need not retroactively adopt it. +- [ ] Approve index v2's optional `package_inventory` reference and its attestation references with exact name/length/digests. Enable it only in the reviewed `phase1-fastly-cli-packaged` cohort and updated consumer policy. Approve `ts release verify --package --expected-source-commit ` as the distinct package-selection interface; `--package` and `--asset` are mutually exclusive. Older v1 consumers reject unsupported v2, with an independently trusted verifier upgrade path documented. +- [ ] Define package inventory records: schema/source/cohort/recipe/policy identity, each package name/format/architecture/size/SHA-256, exact executable member path/mode/size/SHA-256 and canonical raw asset name. Refer to raw asset digests, not release-index digest: index → inventory → package/member must not form a self-hash cycle. Bundle references are in the index, not inside the inventory that the bundles authenticate. +- [ ] Replace #1292's signed/annotated `git tag -s` release instruction with `git tag v ` followed by the explicit tag push. Validate lightweight object type; a tag signature does not replace the approved source/signing policy. +- [ ] Get the delta approved/merged and update plan baseline. Commit `Align CLI packaging with canonical attestation`. + +**Exit:** No implementer chooses their own signer, platform floor, ARM assurance, archive relationship or versioning rule. + +### Task P2: Consume canonical executables without transformations + +**Modify/create according to #1292 ownership:** `scripts/release/package-linux.sh`, `package-macos.sh`, `packaging/nfpm.yaml`, `scripts/release/tests/test_package_inputs.py`. + +- [ ] Write tests rejecting wrong source/target/role, source recompilation, strip/sign hooks, unverified raw input, altered member and CLI B output substituted for A. +- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_package_inputs.py' -v`; expect missing integration behavior. +- [ ] Accept only Task 12/13 primary-plan validated A output and identity table. All final stripping/signature transformations occur in the canonical recipe before its digest/SBOM, never in packaging. Remove #1292's independent strip step and compiled-cache restoration from the canonical build path. +- [ ] Add packaging configuration for the qualified x86_64 Linux executable and both Apple executables. Preserve member bytes; set documented installation modes. nfpm/tool acquisition has reviewed version/digest verification, not a floating installer. +- [ ] Test canonical file unchanged before/after all package formats. Commit `Package the canonical CLI bytes without modification`. + +### Task P3: Inspect whole package structure and bind members + +**Create:** `scripts/release/inspect-cli-package.py`, `make-package-inventory.py`, `tests/test_package_inventory.py`, adversarial package fixtures. + +- [ ] Write tests for nested traversal, links/hardlinks, duplicate members, oversized compressed/decompressed content, extra executable members, wrong architecture/mode, substituted maintainer script, unsupported compression and altered ts bytes. +- [ ] Run package inventory tests; expect absent inspector. +- [ ] Implement bounded format-specific inspection: Debian ar/control/data archives, RPM payload/header, Arch tar and macOS tar. Use maintained parsers/pinned utilities where possible. Each adapter validates allowed installation paths, executable ts, license/readme and explicitly approved metadata/scripts; reject unknown code-bearing entries. A raw tar scan is insufficient for .deb/.rpm. +- [ ] Compare extracted executable bytes against canonical retained SHA-256/size, check 0755 installation mode, then hash the final outer package. Generate deterministic inventory only after every selected wrapper passes. Do not compare an archive digest directly to executable digest. +- [ ] Validate closed package schema and source/target/profile joins with the canonical index data. Reject inventory attempts to approve an unknown raw target. Commit `Bind installer packages to canonical CLI members`. + +### Task P4: Smoke-test installation and provenance-aware extraction + +**Modify:** #1292 smoke helpers/workflow jobs; `scripts/release/tests/test_package_smoke.py`. + +- [ ] Test that failed install/startup/version or member mismatch blocks the selected package cohort; metadata-only architecture checks do not count as execution qualification. +- [ ] Run smoke fixture tests; expect incomplete checks. +- [ ] Install Linux wrappers in reviewed clean Ubuntu/Fedora/Arch environments, run version/help/config-init outside a checkout, and compare installed /usr/bin/ts to canonical digest. Preserve the GNU compatibility qualification independently of package-manager tests. +- [ ] Extract/run macOS archives on actual qualified ARM/Intel hosts. Rosetta may be extra coverage, never the sole Intel gate. Restore executable permissions without altering bytes; verify loader requirements. +- [ ] Record exact container/tool/OS identities and outcomes. Smoke images must be pinned and qualified, not `latest`. Commit `Qualify installed CLI package behavior and identity`. + +### Task P5: Sign package evidence inside the existing trusted boundary + +**Modify:** `.github/workflows/release-build.yml`, `scripts/release/validate-distribution.py`, workflow contract tests. + +- [ ] Test package metadata cannot replace executable SPDX/provenance, wrong signer/action/run, modified outer bytes, inventory-member mismatch and unapproved package subject. +- [ ] Run workflow/graph fixtures; expect package evidence unsupported. +- [ ] Reuse exact same-revision signer and pinned attest 4.2.2; do not keep #1292's direct v2 provenance action in publish. Package provenance names each final package plus authenticated package inventory as exact subjects. Raw executable proofs still name only their approved executable/companions. +- [ ] Add a signed package-inventory proof and index v2 references for inventory/bundles. Root index is assembled after package proofs exist and covers them; no signature/checksum cycle. For the CLI's component composition, consumers use the executable's SPDX proof. Do not advertise an enclosing installer SBOM unless a separate container-level scope is actually generated. +- [ ] Verify actual pinned certificates/provenance and all wrapper/member edges before publication. Commit `Attest CLI installer and member evidence`. + +### Task P6: Extend consumer policy and bootstrap before v2 publication + +**Modify:** CLI `src/release/policy.rs`, `documents.rs`, `evidence.rs`, `release-policy.json`, release verification/CLI tests, `docs/guide/release-verification.md`, packaging install guide. + +- [ ] Add tests for v1 historical acceptance, v2 package selection, unknown package/version, altered inventory/member, verified outer package with unverified executable and the inverse. +- [ ] Run CLI release unit/process tests; expect unsupported v2. +- [ ] Implement the reviewed v2/member policy without letting a downloaded index select arbitrary package kinds or actions. Verify package signature/official membership, authenticate inventory, verify raw executable/SBOM graph and prove exact installed/extracted member equality before recommending installation/execution. +- [ ] Implement the exact package-selection syntax approved in P1; do not overload `--asset` silently to turn arbitrary archives into canonical executable subjects. Use the same internal verification library, with a distinct verified-package result and retained-member identity. +- [ ] Qualify independently trusted source/previous-verifier bootstrap for first v2 use. A CLI that only supports v1 cannot automatically authorize its own unverified upgrade. Commit `Verify packaged CLI members under reviewed v2 policy`. + +### Task P7: Integrate one complete immutable publication + +**Modify:** `.github/workflows/release.yml`, package release helpers, `CONTRIBUTING.md`, `CHANGELOG.md` and tests. + +- [ ] Test incomplete wrappers, mixed source versions, package-only draft, missing raw/SBOM assets, an existing immutable v1 tag and uncertain publish response. +- [ ] Run publication fixtures; expect package cohort unsupported. +- [ ] Publish exact canonical plus selected wrapper assets/evidence/checksums through the single owner. No independent #1292 publish job races or finalizes the tag first. Require all selected wrapper gates, complete downloads/joins and post-publication immutable checks. +- [ ] Rehearse the whole v2 release and trusted package install in an authorized disposable repo, then run all AGENTS gates and producer/consumer tests. Only a separately authorized new production tag follows. +- [ ] Commit `Publish canonical and packaged CLI evidence together`. + +### Task P8: Qualify Linux ARM64 before expanding packaging scope + +**Modify through a separate reviewed extension:** cohort, tools, versioned schema, consumer policy, native recipe/composition fixtures and package matrix. + +- [ ] Obtain approval for an aarch64-unknown-linux-gnu canonical executable, provenance-sbom profile and qualified compatibility floors. Until then it is unsupported, not a weaker undocumented extra asset. +- [ ] Build actual ARM64 A with fresh JS/materials, validate native/external components, generate full SPDX and attest it under the same policy. Native B remains a bounded optional measurement unless explicitly promoted. +- [ ] Run startup on actual ARM64 baseline hosts and each claimed package ecosystem. If an Arch ARM64 runtime cannot be qualified, either retain an explicit metadata-only distribution limitation approved in the packaging spec or defer that package; do not advertise fully smoke-tested execution. +- [ ] Approve the `phase1-fastly-cli-arm64-packaged` cohort with five canonical executables and eight wrappers, and update the versioned schema/producer/consumer expected sets before runs; repeat P2–P7 for ARM packages. A failed selected ARM A/SBOM/runtime gate blocks the expanded cohort, with no after-failure fallback. +- [ ] Commit `Qualify and attest the ARM64 CLI distribution`; close #863 only when its approved platform/package acceptance is satisfied. + +## Completion and review + +P1–P7 produce verifiable packages for the initially qualified targets on a new release. P8 completes the original all-four-platform packaging intent after qualification. Packaging does not weaken or retroactively expand existing #1235 releases. Run each new focused test first, then the primary plan's full AGENTS command list and package-manager/actual-host qualification. Review the complete evidence graph, source/tag/version consistency and first-use bootstrap before publication. diff --git a/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md new file mode 100644 index 0000000000..fdfb5a8498 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md @@ -0,0 +1,124 @@ +# Fastly prebuilt EdgeZero integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add byte-preserving online production/staging deployment of verified Fastly wasm through a typed EdgeZero API with durable recoverable observations. + +**Architecture:** Trusted Server owns official verification, operator authorization and a private retained artifact. EdgeZero owns Fastly packaging/transport, observed context and concrete lifecycle operations. A durable operation sink records intent before every write and response afterward; uncertain provider state is reconciled read-only before any retry. + +**Tech Stack:** Existing Rust/error-stack consumer, reviewed upstream EdgeZero/Fastly CLI 15.1.0 or qualified API transport, restricted atomic local journal, provider-free runner fixtures and authorized disposable Fastly qualification. + +--- + +## Authority and prerequisites + +Read the [primary plan](2026-10-13-attested-release-implementation.md) and [contract R-DEPLOY through R-RECOVERY](../specs/2026-10-09-1235-release-attestation-design.md). This is AC-8, a separate integration milestone, not an initial #1235 closure condition. It depends on the primary verifier/retained artifact, package inspector and public qualification; it may be prepared upstream in parallel but cannot ship an official release path without that verifier. + +Baseline EdgeZero revision is `683202c66948146ec360f490126f1d60f920288b`. Its generic adapter operation returns unit, source deploy can execute manifest shell commands, and Fastly staging builds/autoclones before staging. These are reference mechanisms, not an existing typed prebuilt seam. + +Use a separate upstream EdgeZero checkout/worktree; do not edit Cargo's shared cached checkout. Upstream relative paths below belong to that repo. After review, update Trusted Server's Cargo pin/lock rather than vendor-patching the cache. + +## Proposed API boundaries + +Keep small concrete APIs, with default unsupported behavior for other adapters. Do not add speculative Cloudflare/Spin/Axum payload variants. + +The owning upstream design must implement these concrete records (not empty adapter variants): + +| Record / interface | Required fields / behavior | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PreparedFastlyPackage` | Private retained archive handle/lifetime, checked archive length/SHA-256, canonical module length/SHA-256, deployment-only manifest SHA-256 and inspected package name | +| `PrebuiltFastlyRequest` | Strong service ID, exact base version, production/staging mode, prepared payload and resolved context/sharing policy | +| `WriteIntent` | Stable operation ID, monotonically increasing sequence, phase (`clone`, `upload`, `relink`, `selector-write`, `stage`, `activate`), exact resource/version where known, request identity digest | +| `WriteObservation` | Matching operation/sequence/phase, actual response resource/version, known/unknown result and sanitized evidence references | +| `OperationSink::before_write(&WriteIntent)` | Synchronous concrete fallible result; caller must flush durable intent and failure forbids the following mutation | +| `OperationSink::after_response(&WriteObservation)` | Synchronous concrete fallible result; failure stops subsequent writes while preserving already attempted effects | +| `PrebuiltOutcome` | Exact module/package/service identities, optional exact version, last confirmed phase, mutation-attempted flag and per-phase known/unknown results on success and error; no inferred active-version success | + +E1 follows the owning repository's AGENTS.md for concrete error conventions and visibility. A result-only deploy method is insufficient: it cannot let the caller flush a journal before hidden internal link/selector mutations. No publisher signing or universal attestation registry is introduced. + +### Task E1: Approve upstream API and operation-state contract + +**Upstream modify:** `crates/edgezero-adapter/src/registry.rs`, `crates/edgezero-cli/src/lib.rs`. +**Upstream create:** `crates/edgezero-adapter/src/prebuilt.rs`, `crates/edgezero-adapter-fastly/src/prebuilt.rs`. + +- [ ] Write fake adapter tests for unsupported adapter/action, payload/context mismatch, mutation attempts before authorization, unit-success ambiguity and failed durability sink, truncated payload, pre-send failure versus sent/no-response uncertainty and missing exact version. +- [ ] Run upstream targeted prebuilt tests; expect missing API. +- [ ] Define private retained payload identity, exact service/base version, action/mode, logical/physical mappings, staging selector identity and sharing policy. Return structured observed IDs/phase states and error outcomes with known/unknown state, not an active-version lookup. +- [ ] Define explicit phase enum and WriteIntent/WriteObservation with stable operation/sequence IDs; before_write must synchronously succeed before every provider/config mutation. Journal persistence errors halt further writes, even after earlier successful writes. +- [ ] Approve exact-source Fastly unsupported defaults and review public API/docs/error types. Commit `Add a typed prebuilt Fastly operation interface`. + +### Task E2: Prepare byte-preserving packages from retained inputs + +**Upstream modify/create:** Fastly `src/prebuilt.rs`, `src/prebuilt/package.rs`, provider-free package fixtures. + +- [ ] Write tests for retained-file/path replacement, changed package/member/manifest, generic archive selection, local-server config leakage, extra executable, traversal and unsafe names. +- [ ] Run package tests; expect no validated prepared-payload constructor. +- [ ] Adopt the primary plan's exact deployment-only manifest and bounded four-member inspection semantics. Rehash retained wasm, manifest and selected archive before transport. Never run source build, compute publish, annotation/strip/sign hooks or arbitrary manifest shell overrides. +- [ ] Keep module/archive digests distinct. Public callers cannot construct prepared identity from arbitrary asserted hashes; constructors validate owned bytes and context. +- [ ] Run byte-retention/adversarial fixtures against pinned real pack output. Commit `Prepare verified Fastly packages without rebuilding`. + +### Task E3: Resolve and validate actual deployment context + +**Upstream create:** Fastly `src/prebuilt/context.rs`; modify existing selector/link helper seams only as required. + +- [ ] Test declared logical IDs vs observed links mismatch, wrong selector/config key, unknown secret-reference metadata, conflicting shared staging selector and disallowed resource-sharing policy. +- [ ] Run context tests; expect absent observed-context validation. +- [ ] Resolve service/base version and fetch read-only provider links/selector state. Validate repository logical IDs, selected physical links/key, runtime selector entries and required secret-reference metadata. Secret values are write-only; do not claim plaintext readback or log/store resolved secrets. +- [ ] Declare which KV/secret/external-effect resources staging shares versus isolates. Config-selector isolation alone does not imply all resource isolation. Validate resulting exact draft again before stage/activation. +- [ ] Commit `Validate observed Fastly deployment bindings` after fake-provider negatives pass. + +### Task E4: Implement exact clone/upload/context/stage or activation + +**Upstream create:** Fastly `src/prebuilt/lifecycle.rs` and fake provider/command tests. + +- [ ] Write sequence tests: verify/package/context before first write; explicit clone returning draft ID; upload to that ID without autoclone; exact draft readback; stage or activate only that ID. Test same-hash skip, partial response and unsupported staged request. +- [ ] Run lifecycle tests; expect absent state machine. +- [ ] Invoke OperationSink before each mutation, including clone, upload, resource relinks, selector writes, stage and activation. Use explicit version update rather than compute deploy's automatic activation or combined update/autoclone. +- [ ] Capture returned IDs from operation responses. Require validated resulting context before activation; staged success reports staged, not activated. Never guess success from exit zero or whichever service revision is currently active. +- [ ] Stop on sink/provider uncertainty and preserve last known observation. Commit `Deploy prepared Fastly packages through exact lifecycle phases`. + +### Task E5: Implement durable journal and scoped serialization + +**Trusted Server create:** CLI `src/release/deployment/mod.rs`, `journal.rs`, `context.rs`; internal tests and `tests/release_deploy.rs`. + +- [ ] Write crash-injection tests before send/after send/before response, failed journal flush, receipt path replacement, process restart, shared-selector races and loss of temporary files. +- [ ] Run CLI deployment tests; expect absent journal integration. +- [ ] Implement persistent restricted storage, stable operation ID, private retained evidence/package/context and atomic state writes. Flush file plus directory at each durable intent/response boundary. A TempDir that disappears on process exit is not recovery storage. +- [ ] Lock by service and relevant shared-resource identity across processes/environments; define stale-lock inspection/recovery without blindly deleting locks. Preserve production previous-version observation before mutation. +- [ ] Record exact known/unknown state, policy/source/module/package hashes and sanitized context identities; no active config, token or resolved secrets. Deployment-only health is not_performed. Commit `Persist deployment intent and recovery observations`. + +### Task E6: Reconcile uncertainty without guessing or replaying mutations + +**Upstream create:** Fastly `src/prebuilt/reconcile.rs`; Trusted Server internal journal reconciliation tests. + +- [ ] Test known draft inspection, lost first-clone response, multiple indistinguishable candidate versions, changed selector ownership, unknown upload/activation and receipt failure after success. +- [ ] Run reconciliation tests; expect missing read-only recovery. +- [ ] Query exact known resources/versions where IDs exist; attach observations to the saved operation. A lost clone ID without provider idempotency cannot always be recovered. Keep it unknown; do not adopt a candidate by timestamp/package resemblance or mutate a guess. +- [ ] Require explicit operator-approved reconciliation before another write. Do not automatically rollback after uncertain state, bypass serialization, or replay create/clone on restart. Health and rollback remain separate operations consuming known exact identities. +- [ ] Review any public recovery command separately before adding it; internal support and runbook are required here. Commit `Reconcile uncertain Fastly operations without replay`. + +### Task E7: Review upstream revision and integrate release arguments + +**Trusted Server modify:** root `Cargo.toml`, `Cargo.lock`, CLI `src/run.rs`, `src/commands/mod.rs`. +**Create:** CLI `src/commands/deploy.rs`, `src/release/deployment/transport.rs`. + +- [ ] Land reviewed upstream tests/docs, pin its exact revision and rerun source deploy regression tests. Do not modify the shared Cargo cache or float a branch pin. +- [ ] Write command tests for source deployment preserved, release/passthrough/package/source conflicts, unsupported offline/adapter/variant, failed verification before journal/provider writes and unavailable prebuilt capability. +- [ ] Add wrapper flattening existing DeployArgs plus reviewed release/source/receipt inputs. Source mode delegates as before. Release mode selects a compatible retained VerifiedArtifact, validates local action/context and invokes only the typed prepared path. No fallback to a source build. +- [ ] Keep producer artifact capability and consumer implemented transport intersection: initially production only. Enable fastly-staging only after E8 qualification and reviewed cohort/policy change; old releases do not gain it by downloaded metadata. +- [ ] Run CLI source/release regressions and all AGENTS gates. Commit `Integrate verified release deployment with EdgeZero`. + +### Task E8: Qualify production/staging, recovery and operator guidance + +**Modify:** CLI/release guide, changelog and evidence records; upstream full validation. + +- [ ] Execute fake-provider coverage for all writes, crash boundaries, context mismatches, locks and unknown results; verify no provider writes occur on failed official evidence. +- [ ] First qualify direct upstream prepared-byte staging on a separately authorized disposable Fastly service, using source-built fixtures and no claim of an official staging-capable release. Compare actual module/context and shared/isolated bindings, plus separate health/rollback commands. The initial production-only CLI/release must continue to reject staging before writes. +- [ ] After that transport proof, approve the cohort and embedded policy capability change, then publish a separately authorized new staging-enabled release through the primary pipeline. Use the independently trusted verifier to qualify end-to-end `ts deploy --release --staging` against that new release. Test that historical production-only releases still reject staging; no downloaded metadata or test policy override enables it in the production CLI. +- [ ] Qualify durable recovery across process restart and uncertain responses without exposing secrets. An irreducibly unknown clone remains a documented manual resolution case, not a passed exactly-once guarantee. +- [ ] Record the already reviewed/qualified staging capability and historical producer/consumer compatibility explicitly. Document receipt limits and the actual loaded snapshot boundary for #161; unsigned operator observations are not publisher authority or remote execution proof. +- [ ] Run upstream tests and all Trusted Server AGENTS gates, independent staff review, then hand off for maintainer deployment approval. Commit `Qualify integrated Fastly artifact deployment and recovery`. + +## Exit criteria + +AC-8 is met only when reviewed upstream code is pinned, production/staging preserve canonical bytes, actual context is validated before finalization, durability precedes every write, exact/unknown outcomes survive failure and live disposable qualification is recorded. No other adapter, publisher claim signer, remote execution attestation or generic deploy framework is implied. From 58d2e7a8b6f81ef4e6b7546321ddc2e51846e415 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 18:18:45 +0530 Subject: [PATCH 007/153] Close spec-to-plan verification and qualification gaps --- ...6-10-13-attested-release-implementation.md | 46 +++++++++++++++++-- ...13-fastly-prebuilt-edgezero-integration.md | 1 + 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index c9f44e495c..1c264cb7ce 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -168,6 +168,7 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons - [ ] Run both new unittest modules; expect missing recipe/inspection behavior. - [ ] Implement a closed interface: `build.sh --asset --role A|B --source-commit --js-dir --js-inventory --materials --output `. Select package/target/features from reviewed source; JSON never supplies executable commands. - [ ] Implement checked A/B ambient profiles in the recipe and record them in `release-tools.json`: different external checkout roots, A umask 022/B umask 027, UTC versus a qualified alternate timezone, distinct qualified locale settings, different observed start times and A jobs 4/B jobs 2 within the qualified insensitive envelope. Vary execution user only where Task 8 proves support; otherwise record it unsupported without claiming it changed. Capture both ambient inputs and effective compiler values after sanitation/remapping; fixed internal locale/path/epoch values are normalized, not varied. No unreviewed caller overrides can choose profiles. Tests assert genuinely observed supported differences and reject dishonest/omitted records. +- [ ] Apply Task 8's qualified OS/container denial profile in the actual production `build.sh`, not just the qualification prototype. Every production invocation must record a successful unconfined socket control and denied confined socket probe, then execute Cargo, compiler, proc-macro, build-script and native-tool children inside that same denied boundary. Preparation alone may fetch materials. A build-script/lifecycle download-attempt fixture must fail, and a missing or ineffective confinement proof rejects the output; `--frozen` is not the boundary. - [ ] Build explicit packages with `--release --frozen --message-format=json-render-diagnostics` after preparation. Keep `debug = 1`. Capture compiler/build-script output separately from diagnostic stderr; copy only final output after any reviewed transformations. Do not invoke Fastly source build/annotation. Never strip after evidence/signing. - [ ] Inspect ELF/Mach-O libraries and paths, run clean Linux startup fixtures and real ARM/Intel macOS startup outside checkout. With differing fictional operator config removed by preflight, require identical sanitized effective source/tool/build inputs for every artifact and reject embedded operator sentinels/config fallback. Require complete executable equality for Fastly; preserve native nonmatching outcomes as measurements rather than adding a native repeatability shipping gate. Separately show runtime configuration can change without rebuilding. - [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture must not invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. @@ -197,7 +198,7 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** `scripts/release/collect-builds.py`, `tests/test_collection.py`. -- [ ] Test stale/wrong-run/source transfers, reused A/B artifact IDs, transfer hash warning, traversal/duplicate ZIP members, missing A, changed inventory, missing supported B variation or dishonest normalized/unsupported claims, absent final-byte runtime proof, and a one-byte final wasm difference. +- [ ] Test stale/wrong-run/source transfers, reused A/B artifact IDs, transfer hash warning, traversal/duplicate ZIP members, missing A, changed inventory, missing supported B variation or dishonest normalized/unsupported claims, absent final-byte runtime proof, missing/failed production socket controls or an unconfined build invocation, and a one-byte final wasm difference. - [ ] Run collection tests; expect missing validator. - [ ] Accept only expected upload-step outputs: IDs/archive digests mapped by trusted workflow dependencies to job/attempt/role/recipe. Re-download exact current-run artifacts and validate REST repository/run/source plus transfer bytes. Artifact API does not independently identify the producing job/attempt. Verify bounded declared ZIP members before extraction. - [ ] Match expected role-specific ambient profiles from the approved recipe against observed A/B records before signing. For mandatory Fastly A/B, all qualified supported dimensions must differ as selected; normalized values remain explicitly identified and omitted supported dimensions fail. Successful native comparisons use their selected observed profile and result binding, but a bounded native B failed/unavailable before environment setup records unavailable fields honestly and cannot be rejected for lacking observations it never produced. Add that exact nonblocking fixture; all native A qualification/composition remains mandatory. Verify final-byte core-format/publisher-request results for both Fastly outputs. Fresh-job identity alone cannot satisfy the variation or runtime contract. @@ -231,6 +232,7 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** `.github/workflows/release-build.yml`; extend workflow contract fixtures. +- [ ] Test never-started/queued or scheduler-cancelled optional native B jobs as well as pre-step failure: the trusted collector must emit a bounded failed/unavailable measurement with absent observations represented honestly and no fabricated upload identity. Confirm the workflow cannot wait indefinitely or hide a mandatory A failure. - [ ] Test topology: shared JS A/B, separate fresh Fastly A/B, three mandatory native A jobs, bounded optional native B jobs, validated current-run collector and no signing credentials in executable/lifecycle jobs. Require job configurations to apply Task 9 role profiles and publish observed variation/runtime records; tests reject default-only A/B jobs or collectors that bypass these gates. - [ ] Run workflow tests; expect absent graph. - [ ] Call local reusable workflow at source revision. Build jobs are read-only. Collector uses Task 12 outputs; signing grants only contents-read/id-token-write/attestations-write, plus actions-read when needed. No artifact-metadata write for file attestations. Caller grants enough permissions for called signing jobs but its own build jobs explicitly narrow them. @@ -253,16 +255,21 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** CLI `src/release/mod.rs`, `error.rs`, `policy.rs`, `documents.rs`, `src/release/tests/mod.rs`, `src/release/tests/documents.rs`, `tests/fixtures/release/`. **Modify:** CLI `src/lib.rs`, `Cargo.toml` (host-only sha2/schema validator with justification). +- [ ] Before the first filtered run, wire the host-only `mod release` in CLI `src/lib.rs`, the production child modules in `src/release/mod.rs`, `#[cfg(test)] mod tests` there, and `mod documents` in `src/release/tests/mod.rs`. Add the test cases before checking selection; Rust does not discover this directory automatically. Keep these internals private. - [ ] Test closed schema/kind/profile/action combinations, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. -- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::documents`; expect absent modules/behavior. +- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::documents`; expect the intended missing implementation or assertion failure. When it compiles, use the nonzero-selection guard below before accepting red or green; a successful run selecting zero tests is a failure of this plan, not a pass. - [ ] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (trusted source snapshot or bounded official source API acquisition from that exact commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. -- [ ] Register these tests under `#[cfg(test)] mod tests` inside the private release module; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. +- [ ] Keep the already registered tests active and require nonzero selected counts on every focused rerun; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. - [ ] Fix repository/owner IDs, supported combinations, exact gh/action contract and all R-BOUNDS limits. Re-run tests/clippy. Commit `Parse release evidence under embedded consumer policy`. ### Task 18: Implement the bounded isolated gh runner **Create:** CLI `src/release/gh.rs`, `src/release/tests/gh.rs`. +**Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. + +- [ ] Register the new gh production modules and `mod gh` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches must never report a passing security check. + - [ ] Write fake-child tests for hangs/cancellation, simultaneous stdout/stderr saturation, malformed/truncated JSON, exit failure, multiple attestations, token precedence, host/config overrides and log redaction. - [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::gh`; expect absent runner. - [ ] Resolve gh once through trusted PATH/explicit trusted installer configuration, require 2.97.0, and retain that executable target. Pass fixed argument vectors with exact certificate identity; never arbitrary forwarded verifier flags. Accept explicit GH_TOKEN then GITHUB_TOKEN, pass only GH_TOKEN, isolate home/config/state, disable prompts, reject loader injection and keep provider credentials out. @@ -273,6 +280,10 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** CLI `src/release/evidence.rs`, `github.rs`, `artifact.rs`, `src/release/tests/verification.rs`. +**Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. + +- [ ] Register the new evidence/github/artifact production modules and `mod verification` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches must never report a passing security check. + - [ ] Add a valid graph and independently altered edges for index, all provenance subjects, SPDX subject/document/root, JS prepared/retained records, policy/source/run/caller identities, signed names, REST lengths/immutability and tag object type. - [ ] Run verification tests; expect no implemented evidence graph. - [ ] Verify index provenance before trusting fields. Obtain only approved official GitHub host/repository data with explicit read-only credential and bounded responses. Validate lightweight source target, source approval, independently acquired fixed-path producer policy bytes at that commit, exact signed release names/digests and actual/index/REST sizes. Primitive gh success is insufficient. @@ -283,8 +294,9 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons ### Task 20: Expose read-only CLI verification and bootstrap instructions **Create:** CLI `src/commands/release.rs`, `tests/release_cli.rs`, `src/release/offline.rs`, `src/release/tests/offline.rs`, `docs/guide/release-verification.md`. -**Modify:** CLI `src/commands/mod.rs`, `src/run.rs`; `docs/guide/cli.md`, `.vitepress/config.mts`. +**Modify:** CLI `src/commands/mod.rs`, `src/run.rs`, `src/release/mod.rs`, `src/release/tests/mod.rs`; `docs/guide/cli.md`, `.vitepress/config.mts`. +- [ ] Register `mod offline` in both the private production and test roots before its first focused run. Register the public command module/dispatch before process tests; run `release::tests::offline` with the nonzero-selection guard in addition to the CLI process target. - [ ] Test the required parse path `ts release verify v1.2.0 --asset ts-aarch64-apple-darwin --expected-source-commit `, invalid source/asset/unsupported offline-deploy flags and no provider/config/build hooks on all failure paths. - [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_cli`; expect unknown command. - [ ] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag must be documented and reviewed, not smuggled into an implementation example. @@ -314,6 +326,30 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons - [ ] Review against AC-1–AC-7, AC-9–AC-10; keep AC-8 separate. Use @superpowers:requesting-code-review and @superpowers:verification-before-completion. Maintainer approval precedes any production tag. - [ ] Commit validated documentation/evidence with `Record initial release qualification`; create implementation PRs through the repository PR creator only when their required checks pass. +## Focused Rust test selection guard + +Before accepting any filtered private-module run, require an actual selected test. Once the target compiles, repeat this guard for `documents`, `gh`, `verification` and `offline`; registration belongs to the preceding task step, not to a later implementation step. An intended compile failure while adding missing behavior can establish the first red state, but a zero-test successful command never can. + +```sh +set -e +RELEASE_HOST_TARGET="$(rustc -vV | awk '/host:/ { print $2 }')" +RELEASE_TEST_FILTER=release::tests::documents +RELEASE_TEST_LIST="$(mktemp)" +trap 'rm -f "$RELEASE_TEST_LIST"' EXIT +cargo test -p trusted-server-cli --target "$RELEASE_HOST_TARGET" \ + "$RELEASE_TEST_FILTER" -- --list > "$RELEASE_TEST_LIST" +python3 - "$RELEASE_TEST_LIST" <<'PY' +import sys +from pathlib import Path +matches = [line for line in Path(sys.argv[1]).read_text().splitlines() + if line.endswith(": test")] +if not matches: + raise SystemExit("Focused release test filter selected zero tests") +print(f"Selected {len(matches)} release tests") +PY +cargo test -p trusted-server-cli --target "$RELEASE_HOST_TARGET" "$RELEASE_TEST_FILTER" +``` + ## Full implementation verification commands Run from the repository root with pinned tools and installed npm dependencies. Linux CI uses its host triple; macOS can use the CLI helper. Record each command independently so a later command cannot mask an earlier failure. @@ -384,3 +420,5 @@ For these Markdown plans, validate formatting, links, shell syntax, requirement On 2026-10-13, independent whole-package review approved these three plans after three passes; separate producer and consumer/upstream reviews informed the corrections. Findings resolved: required Fastly B variation enforcement, final-byte application qualification, mutable-publication recovery, lightweight tagging, native comparison/profile boundaries, all-action SHA pins, approved-source policy-byte binding, lifecycle network denial, and noncircular staging qualification with complete durable outcomes. This is planning-completeness approval, not maintainer spec approval or proof of implementation/hosted qualification. Document verification covers formatting/domain rules, relative references, shell/Python example syntax and all 29 requirement mappings. + +A subsequent fresh spec-to-plan review on 2026-10-13 found three execution-coverage gaps despite the earlier review: applying actual production network confinement, registering private Rust tests before filtered runs, and independently qualifying integrated production activation. All were corrected; the two fresh reviewers then reported clean primary and milestone alignment. The optional native scheduler-failure fixture was also strengthened. These reviews do not replace the future implementation and qualification gates. diff --git a/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md index fdfb5a8498..9fd79eb4c1 100644 --- a/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md +++ b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md @@ -113,6 +113,7 @@ E1 follows the owning repository's AGENTS.md for concrete error conventions and **Modify:** CLI/release guide, changelog and evidence records; upstream full validation. - [ ] Execute fake-provider coverage for all writes, crash boundaries, context mismatches, locks and unknown results; verify no provider writes occur on failed official evidence. +- [ ] Before enabling integrated production transport, execute end-to-end `ts deploy --release` production qualification on a separately authorized disposable Fastly service using an independently verified production-capable release. Assert canonical module bytes, explicit clone/upload to the exact returned draft, context validation before activation, the exact activated version, durable per-write observations and receipt/restart behavior. The primary manual compute-deploy rehearsal is a different transport and cannot satisfy this gate. - [ ] First qualify direct upstream prepared-byte staging on a separately authorized disposable Fastly service, using source-built fixtures and no claim of an official staging-capable release. Compare actual module/context and shared/isolated bindings, plus separate health/rollback commands. The initial production-only CLI/release must continue to reject staging before writes. - [ ] After that transport proof, approve the cohort and embedded policy capability change, then publish a separately authorized new staging-enabled release through the primary pipeline. Use the independently trusted verifier to qualify end-to-end `ts deploy --release --staging` against that new release. Test that historical production-only releases still reject staging; no downloaded metadata or test policy override enables it in the production CLI. - [ ] Qualify durable recovery across process restart and uncertain responses without exposing secrets. An irreducibly unknown clone remains a documented manual resolution case, not a passed exactly-once guarantee. From d157766fa4b9c13b9b4e666a8961009f1133857b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 18:37:23 +0530 Subject: [PATCH 008/153] Keep release spec and implementation on the existing PR --- .../2026-10-13-attested-release-implementation.md | 12 ++++++------ .../2026-10-09-1235-release-attestation-design.md | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 1c264cb7ce..5b6b0cf7be 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -12,7 +12,7 @@ ## Authority, baseline and scope -Planning baseline: `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52`, inspected 2026-10-13. The governing [contract](../specs/2026-10-09-1235-release-attestation-design.md), [feasibility register](../specs/2026-10-12-1235-release-attestation-feasibility.md) and [index schema](../specs/schemas/release-index-v1.schema.json) remain drafts awaiting maintainer approval. This plan does not approve their implementation, qualify a release, or authorize live publication/deployment. Task 1 establishes that prerequisite; writing/reviewing this plan can proceed now. +Planning baseline: `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52`, inspected 2026-10-13. The governing [contract](../specs/2026-10-09-1235-release-attestation-design.md), [feasibility register](../specs/2026-10-12-1235-release-attestation-feasibility.md) and [index schema](../specs/schemas/release-index-v1.schema.json) remain drafts awaiting maintainer approval. Delivery direction on 2026-10-13 is to develop/review the spec, plans and implementation together on `spec/1235-release-attestation` in PR #1262, with no new Trusted Server PR. The reviewed plan guides implementation here; maintainer contract/code approval remains required before merge, and the plan does not qualify a release or authorize live publication/deployment. Task 1 records this delivery contract and the unchanged qualification prerequisites. #1214 and #1213 are present at this baseline. Extend their merged private JS output/prebuilt interface and source-version mechanism; do not reimplement the historical audit snapshots. Source deployment currently delegates to EdgeZero and no release workflow/verifier exists. @@ -34,7 +34,7 @@ Two linked plans keep independent work out of these closure criteria: ## Work breakdown and ownership -Use successive focused PRs: (1) input/fact/JS guards, (2) qualified recipes and composition, (3) collection/signing/publication, (4) verifier/manual route/docs. Each depends on its predecessor; tag enablement waits for all four. Build scripts can be proved locally before production YAML exists. No intermediate PR may enable an incomplete release trigger. +Use successive focused commit/review groups within PR #1262: (1) input/fact/JS guards, (2) qualified recipes and composition, (3) collection/signing/publication, (4) verifier/manual route/docs. Each depends on its predecessor; tag enablement waits for all four. No new Trusted Server implementation PR is created. Build scripts can be proved locally before production YAML exists. No intermediate PR may enable an incomplete release trigger. | File group | Responsibility | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | @@ -57,12 +57,12 @@ New files below are proposed, not existing APIs. Keep security parsing and opera **Files:** Modify this plan and the governing spec only for approved corrections; reference PR #1292's `docs/superpowers/specs/2026-10-12-863-cli-release-packaging-design.md` and `docs/superpowers/plans/2026-10-12-cli-release-packaging.md` after their owning branch is available. -- [ ] Read the merged/current spec revision; record its commit and maintainer approval before executing implementation tasks. +- [ ] Read the current spec revision; record its commit and the same-branch/same-PR delivery direction. Work through reviewed tasks in PR #1262; obtain maintainer contract/code approval before merge. Do not require a separate spec merge or open another implementation PR. - [ ] Record one publication owner, `.github/workflows/release.yml`, and one signing identity, same-revision `release-build.yml`. #1292 must not independently publish an overlapping immutable tag while #1235 assets are still pending. - [ ] Confirm the current four-artifact cohort and three CLI baselines. Initial #1235 delivery may precede packaging on a different new tag. Adding package records or ARM64 requires the linked plan's approved delta, never a covert schema expansion here. - [ ] Settle one strict producer control channel: `TRUSTED_SERVER__RELEASE_BUILD=1`, injected by trusted recipe after environment sanitation, recorded as an input, rejected if present with another value. This is a fact/input guard, not the prohibited parallel JS fallback mode. Name the checked prebuilt inventory input `TSJS_PREBUILT_INVENTORY`. - [ ] Confirm permission policy remains runtime-variable; conflicting #1045/#1084 code blocks release enablement. Record the owner and dependency rather than designing a new permission transport. -- [ ] Review milestone exits with maintainers. Commit any approved documentation delta separately with `Clarify release implementation prerequisites`. +- [ ] Review milestone exits with maintainers in PR #1262. Commit any approved documentation delta as a focused commit on this branch with `Clarify release implementation prerequisites`. **Exit:** An engineer can identify approved source, scope, producer controls and the single release authority; no unspecified cross-PR ownership decision remains. @@ -324,7 +324,7 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons - [ ] Rehearse hosted source authorization, shared JS, Fastly A/B, all three native A/SBOMs, optional B terminal failures, actual exact-action signatures, immutable draft/publication and full consumer verification together. Toy/synthetic component proofs cannot replace this complete composition. - [ ] Recheck permission/config separation, governance/rules/bypass authority, bootstrap docs, actual platform evidence and live manual submission. Missing evidence blocks tag enablement; native B equality, packaging or upstream integration do not add hidden blockers. - [ ] Review against AC-1–AC-7, AC-9–AC-10; keep AC-8 separate. Use @superpowers:requesting-code-review and @superpowers:verification-before-completion. Maintainer approval precedes any production tag. -- [ ] Commit validated documentation/evidence with `Record initial release qualification`; create implementation PRs through the repository PR creator only when their required checks pass. +- [ ] Commit validated documentation/evidence with `Record initial release qualification`; update existing PR #1262 with implementation and validation evidence only when the required checks pass; never create a replacement implementation PR. ## Focused Rust test selection guard @@ -413,7 +413,7 @@ A completed task means its described tests pass and reviewed code exists, not me ## Planning-only verification and handoff -For these Markdown plans, validate formatting, links, shell syntax, requirement coverage, task dependencies and independent review. Do not claim that runtime tests verify implementation that has not been written. Preserve the current PR branch; the planning worktree is `/private/tmp/trusted-server-1235-plan-20261013`, branch `plan/1235-attested-release-implementation`. +For these Markdown plans, validate formatting, links, shell syntax, requirement coverage, task dependencies and independent review. Do not claim that runtime tests verify implementation that has not been written. Continue in the main checkout `/Users/prk-jr/Desktop/opensource/rust/trusted-server`, branch `spec/1235-release-attestation`, and existing PR #1262. The temporary planning worktree/branch were used only to draft the plans; their commits have been incorporated into this branch. ## Review record diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index 618cc9c362..c54f780dfc 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -4,7 +4,7 @@ **Date:** 2026-10-09. **Review revision:** 2026-10-12. -**Status:** Draft for maintainer review. Approval and merge of this specification precede an implementation PR. Requirements below describe proposed behavior, not an existing release pipeline. The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) records measured prototypes, missing implementations and unresolved hosted/provider qualifications; document review alone is not feasibility proof. +**Status:** Draft for maintainer review. As directed on 2026-10-13, this specification, its implementation plans and implementation share PR #1262 on `spec/1235-release-attestation`; no separate implementation PR is opened. Contract/code approval is required before merge and all selected qualification gates remain required before official release enablement. Requirements below describe proposed behavior, not an existing release pipeline. The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) records measured prototypes, missing implementations and unresolved hosted/provider qualifications; document review alone is not feasibility proof. **Source baseline:** PR #1262 at `7f6056bf83eb96aa4167efca64d43280299fba73`. The implementation MUST reconcile [#1214](https://github.com/IABTechLab/trusted-server/pull/1214) (isolated JS builds) and [#1213](https://github.com/IABTechLab/trusted-server/pull/1213) (source-version headers) before release enablement; neither is assumed merged at this baseline. @@ -394,7 +394,7 @@ Implementation changes MUST run appropriate target-matched tests/lints after eac ## Rollout -1. Approve and merge this contract independently; implementation links the approved version. Reconcile #1214/#1213 and permission-config separation. +1. Review this contract and its plans, then implement on the same `spec/1235-release-attestation` branch and PR #1262. Approve the contract/code together before merge; this explicitly replaces the separate-spec-PR delivery procedure for this work, without waiving qualification or release authorization. Reconcile #1214/#1213 and permission-config separation. 2. Prototype Fastly A/B equality, shared JS output, SBOM native/host/JS fixtures and CLI compatibility baselines with throwaway recipes. Record feasibility and review any cohort change before production work; this step does not promise replay of nonexistent retained inputs. 3. Implement reviewed release recipes/tools/cohort, JS inventory, composition generator, facts, validation, measurements, schema/policy, signing and immutable publication. Remove obsolete claims that configuration is baked into wasm from CI workflows, the integration-test setup action, adapter comments/tests and integration helpers, not just `test.yml`. 4. Prepare governance/settings/tag rules, trusted gh/CLI bootstrap and documentation. Exercise publication and verification in a disposable public repository using policy fixtures for that repository; no production-policy override is added. Document/validate the manual verified-byte Fastly production route. Publish the first eligible four-artifact release after all its selected requirements pass. @@ -404,7 +404,7 @@ Implementation changes MUST run appropriate target-matched tests/lints after eac ## Acceptance criteria -- [ ] **AC-1:** Spec approved/merged independently; implementation links it and records the selected cohort/profiles before execution. +- [ ] **AC-1:** Spec, plans and implementation reviewed/approved together in PR #1262; implementation records the selected cohort/profiles before release execution. - [ ] **AC-2:** Configuration independence, complete input inventory, selected variant and qualified CLI compatibility hold for every selected Build A. - [ ] **AC-3:** Fresh locked shared JS passes byte equality; Fastly A/B matches complete bytes; native measurements have honest terminal outcomes and cannot waive required failures. - [ ] **AC-4:** Per-artifact provenance/SPDX/bundles/index/checksums and composition coverage pass their exact evidence and schema checks. From 8a45cc10815c70a915702f18ef1ee949d60746f0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 18:50:29 +0530 Subject: [PATCH 009/153] Clarify release implementation prerequisites --- .../2026-10-13-attested-release-implementation.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 5b6b0cf7be..6eb4607e73 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -422,3 +422,13 @@ On 2026-10-13, independent whole-package review approved these three plans after This is planning-completeness approval, not maintainer spec approval or proof of implementation/hosted qualification. Document verification covers formatting/domain rules, relative references, shell/Python example syntax and all 29 requirement mappings. A subsequent fresh spec-to-plan review on 2026-10-13 found three execution-coverage gaps despite the earlier review: applying actual production network confinement, registering private Rust tests before filtered runs, and independently qualifying integrated production activation. All were corrected; the two fresh reviewers then reported clean primary and milestone alignment. The optional native scheduler-failure fixture was also strengthened. These reviews do not replace the future implementation and qualification gates. + +## Implementation execution record + +Implementation starts on 2026-10-13 at `d157766fa4b9c13b9b4e666a8961009f1133857b`, the current governing-spec revision, in the main checkout on `spec/1235-release-attestation` and PR #1262. The schema revision inspected is `24a8a7180a32a30e0438d9f5b77697c058c6e9c3`. These are investigation revisions, not an approved release source commit. The user has authorized implementation on this branch; maintainer contract/code approval and release qualification remain outstanding. + +Task 1 investigation confirms the four-artifact cohort, its existing compatibility baselines, `TRUSTED_SERVER__RELEASE_BUILD=1` and `TSJS_PREBUILT_INVENTORY`. No release workflow exists at this revision. The selected publication owner remains `release.yml`, with same-revision `release-build.yml` owning signing. PR #1292 is a documentation draft at `bbba62aca580b94192628495826ae9551efad24b`; its package-only distribution, additional target, compatibility changes and annotated tags require reconciliation through the linked packaging plan. It must not independently publish an overlapping immutable tag. + +Permission-policy reconciliation remains an enablement dependency owned by the #1045/#1084 work (`jwrosewell`), not a blocker for local implementation. The current checkout loads app configuration at runtime and has no embedded permission-policy module. #1045 at `a028c00da096cc722dd95307ab8ee0e3514a448e` embeds `config/permissions/sample.yaml`; #1084 at `3c43876fc8b0d316d812a8c74aba47cc87d17279` explicitly defers runtime policy. Those proposals must satisfy R-CONFIG before release enablement. This record neither approves their embedded policy nor selects a new transport. + +Task completion requires implementation evidence and both independent reviews. Task 1's owner reconciliation and maintainer milestone review remain pending; subsequent local implementation can proceed. Hosted qualification, governance setup, signing/publication rehearsal and live Fastly submission remain distinct evidence gates, and no production release trigger is enabled by this record. From 1cfca1357d0eee7158a4e6049444fef46807c564 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:00:50 +0530 Subject: [PATCH 010/153] Add strict release document utilities --- scripts/release/common.py | 214 ++++++++++++++ scripts/release/requirements.txt | 153 ++++++++++ scripts/release/tests/fixtures/README.md | 63 +++++ scripts/release/tests/test_common.py | 344 +++++++++++++++++++++++ 4 files changed, 774 insertions(+) create mode 100644 scripts/release/common.py create mode 100644 scripts/release/requirements.txt create mode 100644 scripts/release/tests/fixtures/README.md create mode 100644 scripts/release/tests/test_common.py diff --git a/scripts/release/common.py b/scripts/release/common.py new file mode 100644 index 0000000000..715c4caf0f --- /dev/null +++ b/scripts/release/common.py @@ -0,0 +1,214 @@ +"""Strict byte-oriented utilities shared by release producers and verifiers.""" + +import hashlib +import json +import math +import os +import re +import tempfile +from pathlib import Path + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key!r}") + result[key] = value + return result + + +def load_json_bytes(data, *, max_bytes, max_depth=64): + """Decode a bounded UTF-8 JSON document without duplicate object keys. + + The caller selects the byte cap. Container depth includes the root and is + checked before JSON decoding; string content does not add depth. Raise ValueError + for invalid limits, malformed UTF-8 or JSON, nonfinite numbers, unpaired + surrogate escapes, or an exceeded bound. + """ + require_nonnegative_integer(max_bytes) + require_nonnegative_integer(max_depth) + if not isinstance(data, bytes): + raise ValueError("JSON input must be bytes") + if len(data) > max_bytes: + raise ValueError(f"JSON byte size exceeds {max_bytes}") + text = data.decode("utf-8") + _check_depth(text, max_depth) + document = json.loads( + text, + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + parse_float=_finite_float, + ) + _check_unicode(document) + return document + + +def require_nonnegative_integer(value): + """Return a nonnegative integer, rejecting booleans and other types.""" + if type(value) is not int or value < 0: + raise ValueError("value must be a nonnegative integer") + return value + + +def read_bounded(stream, *, max_bytes): + """Read a binary stream to EOF, consuming at most the limit plus one byte. + + Short reads are supported; file metadata is never trusted as a size bound. + Raise ValueError for an invalid limit, non-binary stream, or excess data. + """ + require_nonnegative_integer(max_bytes) + data = bytearray() + while True: + chunk = stream.read(min(65536, max_bytes + 1 - len(data))) + if not isinstance(chunk, bytes): + raise ValueError("stream must return binary bytes") + if not chunk: + return bytes(data) + if len(data) + len(chunk) > max_bytes: + raise ValueError(f"stream byte size exceeds {max_bytes}") + data.extend(chunk) + + +def read_json_file(path, *, max_bytes, max_depth=64): + """Read a file as bounded bytes and decode it as strict UTF-8 JSON. + + Raise ValueError for invalid limits or documents and OSError for read errors. + Callers that also need the original digest should retain read_bounded bytes + and pass the same bytes to load_json_bytes and sha256_bytes. + """ + with Path(path).open("rb") as stream: + data = read_bounded(stream, max_bytes=max_bytes) + return load_json_bytes(data, max_bytes=max_bytes, max_depth=max_depth) + + +def sha256_bytes(data): + """Return the lowercase SHA-256 digest of the exact supplied bytes.""" + if not isinstance(data, bytes): + raise ValueError("digest input must be bytes") + return hashlib.sha256(data).hexdigest() + + +def require_safe_asset_name(name): + """Return a flat ASCII asset name matching the release document contract. + + Names have at most 160 characters, start with a letter or digit, and use + letters, digits, underscores, or hyphens in nonempty dot-separated parts. + Raise ValueError for an unsafe name. + """ + if ( + not isinstance(name, str) + or len(name) > 160 + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*", name) + ): + raise ValueError("asset name must be a safe flat ASCII filename") + return name + + +def require_unique_identities(records, fields): + """Reject repeated identity tuples selected by the caller's field names. + + Identity values must be strings or integers; missing fields and compound + values raise ValueError rather than participating in ambiguous matches. + """ + seen = set() + for record in records: + if not isinstance(record, dict) or any(field not in record for field in fields): + raise ValueError("identity fields must be present in an object") + identity = tuple(record[field] for field in fields) + if any(type(value) not in (str, int) for value in identity): + raise ValueError("identity fields must be strings or integers") + if identity in seen: + raise ValueError(f"duplicate identity: {identity!r}") + seen.add(identity) + + +def deterministic_json_bytes(document): + """Serialize sorted, compact UTF-8 JSON bytes followed by one newline. + + This is a producer encoding, not a canonicalization step for verification. + Hash signed or downloaded documents using their original bytes instead. + Raise ValueError for nonfinite numbers, surrogate strings, or invalid keys. + """ + text = json.dumps( + document, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + _check_unicode(document) + return (text + "\n").encode("utf-8") + + +def atomic_write(path, data): + """Replace a destination with exact bytes using a private sibling file. + + Flush and sync the temporary file before atomic replacement. On failure, + remove the temporary file and preserve the destination's existing bytes. + Raise ValueError for non-byte data and OSError for filesystem failures. + """ + if not isinstance(data, bytes): + raise ValueError("atomic write input must be bytes") + destination = Path(path) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + dir=destination.parent, prefix=f".{destination.name}.", delete=False + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(data) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_path, destination) + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def _check_depth(text, max_depth): + depth = 0 + in_string = False + escaped = False + for character in text: + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + elif character == '"': + in_string = True + elif character in "[{": + depth += 1 + if depth > max_depth: + raise ValueError(f"JSON container depth exceeds {max_depth}") + elif character in "]}": + depth -= 1 + + +def _check_unicode(document): + pending = [document] + while pending: + value = pending.pop() + if isinstance(value, str): + value.encode("utf-8") + elif isinstance(value, dict): + if any(not isinstance(key, str) for key in value): + raise ValueError("JSON object keys must be strings") + pending.extend(value.keys()) + pending.extend(value.values()) + elif isinstance(value, list): + pending.extend(value) + + +def _reject_constant(value): + raise ValueError(f"JSON number must be finite: {value}") + + +def _finite_float(value): + number = float(value) + if not math.isfinite(number): + raise ValueError(f"JSON number must be finite: {value}") + return number diff --git a/scripts/release/requirements.txt b/scripts/release/requirements.txt new file mode 100644 index 0000000000..0ce5fb5634 --- /dev/null +++ b/scripts/release/requirements.txt @@ -0,0 +1,153 @@ +# Install in a private Python 3.11+ virtual environment with --require-hashes. +# Includes every transitive dependency and published distribution SHA-256. +# typing-extensions is required by referencing on Python versions below 3.13. + +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + +rpds-py==2026.9.1 \ + --hash=sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25 \ + --hash=sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68 \ + --hash=sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495 \ + --hash=sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32 \ + --hash=sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96 \ + --hash=sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834 \ + --hash=sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0 \ + --hash=sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6 \ + --hash=sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e \ + --hash=sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa \ + --hash=sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759 \ + --hash=sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff \ + --hash=sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7 \ + --hash=sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c \ + --hash=sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903 \ + --hash=sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d \ + --hash=sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9 \ + --hash=sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e \ + --hash=sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b \ + --hash=sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a \ + --hash=sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a \ + --hash=sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2 \ + --hash=sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945 \ + --hash=sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91 \ + --hash=sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044 \ + --hash=sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69 \ + --hash=sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e \ + --hash=sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3 \ + --hash=sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4 \ + --hash=sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33 \ + --hash=sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52 \ + --hash=sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9 \ + --hash=sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb \ + --hash=sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35 \ + --hash=sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299 \ + --hash=sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c \ + --hash=sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063 \ + --hash=sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a \ + --hash=sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6 \ + --hash=sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12 \ + --hash=sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14 \ + --hash=sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0 \ + --hash=sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30 \ + --hash=sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31 \ + --hash=sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02 \ + --hash=sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af \ + --hash=sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef \ + --hash=sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0 \ + --hash=sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888 \ + --hash=sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a \ + --hash=sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c \ + --hash=sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b \ + --hash=sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86 \ + --hash=sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f \ + --hash=sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531 \ + --hash=sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6 \ + --hash=sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82 \ + --hash=sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d \ + --hash=sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7 \ + --hash=sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448 \ + --hash=sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391 \ + --hash=sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb \ + --hash=sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4 \ + --hash=sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e \ + --hash=sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c \ + --hash=sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15 \ + --hash=sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f \ + --hash=sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297 \ + --hash=sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36 \ + --hash=sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d \ + --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b \ + --hash=sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237 \ + --hash=sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb \ + --hash=sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477 \ + --hash=sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c \ + --hash=sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e \ + --hash=sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608 \ + --hash=sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07 \ + --hash=sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c \ + --hash=sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35 \ + --hash=sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0 \ + --hash=sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785 \ + --hash=sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad \ + --hash=sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc \ + --hash=sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9 \ + --hash=sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025 \ + --hash=sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f \ + --hash=sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009 \ + --hash=sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d \ + --hash=sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118 \ + --hash=sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db \ + --hash=sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9 \ + --hash=sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab \ + --hash=sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37 \ + --hash=sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d \ + --hash=sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc \ + --hash=sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9 \ + --hash=sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3 \ + --hash=sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569 \ + --hash=sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69 \ + --hash=sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b \ + --hash=sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018 \ + --hash=sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f \ + --hash=sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c \ + --hash=sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f \ + --hash=sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5 \ + --hash=sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445 \ + --hash=sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1 \ + --hash=sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e \ + --hash=sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429 \ + --hash=sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa \ + --hash=sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b \ + --hash=sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250 \ + --hash=sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e \ + --hash=sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83 \ + --hash=sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f \ + --hash=sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd \ + --hash=sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346 \ + --hash=sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858 \ + --hash=sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871 \ + --hash=sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da \ + --hash=sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0 \ + --hash=sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f \ + --hash=sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050 \ + --hash=sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31 \ + --hash=sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df \ + --hash=sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12 \ + --hash=sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567 + +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 diff --git a/scripts/release/tests/fixtures/README.md b/scripts/release/tests/fixtures/README.md new file mode 100644 index 0000000000..63d1fb1e41 --- /dev/null +++ b/scripts/release/tests/fixtures/README.md @@ -0,0 +1,63 @@ +# Release document fixtures + +The utility tests construct small byte fixtures in `test_common.py` so duplicate +keys, invalid UTF-8, surrogate escapes, and trailing control bytes retain their +exact representations. These cases cover strict parsing, bounded reads, nesting, +flat asset names, lengths, identity tuples, original-byte hashes, deterministic +producer output, and atomic replacement failures. Future document fixtures belong +in this directory and must use fictional names and `example.com` URLs only. + +Run the standard-library utility tests from the repository root: + +```sh +python3 -m unittest discover -s scripts/release/tests -p 'test_common.py' -v +``` + +Schema validation requires Python 3.11 or newer and an isolated environment. Use +the environment's interpreter explicitly; do not rely on an ambient `jsonschema` +installation. + +This also qualifies the validator against the Draft 2020-12 `prefixItems` and +`items` semantics, which distinguish this dialect from older drafts. The temporary +root is private, and the cleanup trap removes the test environment when the shell +exits. + +```sh +set -eu +RELEASE_TEST_ROOT=$(mktemp -d) +trap 'rm -rf "$RELEASE_TEST_ROOT"' EXIT +python3 -m venv "$RELEASE_TEST_ROOT/venv" +"$RELEASE_TEST_ROOT/venv/bin/python" -m pip install --require-hashes --only-binary=:all: -r scripts/release/requirements.txt +"$RELEASE_TEST_ROOT/venv/bin/python" -m pip check +"$RELEASE_TEST_ROOT/venv/bin/python" -m unittest discover -s scripts/release/tests -p 'test_common.py' -v +"$RELEASE_TEST_ROOT/venv/bin/python" - <<'PY' +from jsonschema import Draft202012Validator +from jsonschema.exceptions import ValidationError + +schema = {"type": "array", "prefixItems": [{"type": "integer"}], "items": False} +Draft202012Validator.check_schema(schema) +validator = Draft202012Validator(schema) +validator.validate([1]) +for invalid in (["example"], [1, 2]): + try: + validator.validate(invalid) + except ValidationError: + continue + raise AssertionError("should reject invalid Draft 2020-12 arrays") +print("Draft 2020-12 qualification passed") +PY +``` + +`requirements.txt` pins `jsonschema` and every transitive dependency, with SHA-256 +hashes for the published distributions. It includes `typing-extensions`, which +`referencing` requires below Python 3.13. Future schemas use the explicit +`jsonschema.Draft202012Validator`; installing this dependency does not replace the +strict byte parser or authorize schema-based reserialization before hashing. + +The parsing and reading helpers require callers to choose `max_bytes` for the +document role. Their default maximum depth is 64 containers, including the root; +brackets inside strings do not increase depth. Hashing always uses the supplied +original bytes. Producer JSON uses sorted keys, compact separators, UTF-8, and one +final newline. Atomic writes replace a destination using a private sibling file +with mode `0600`, syncing its bytes first and cleaning up the temporary file on +failure. diff --git a/scripts/release/tests/test_common.py b/scripts/release/tests/test_common.py new file mode 100644 index 0000000000..fd6a9bfa15 --- /dev/null +++ b/scripts/release/tests/test_common.py @@ -0,0 +1,344 @@ +"""Tests for strict release document utilities.""" + +import hashlib +import io +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import common + + +class JsonBytesTests(unittest.TestCase): + def test_rejects_duplicate_keys(self): + with self.assertRaisesRegex(ValueError, "duplicate"): + common.load_json_bytes(b'{"name":1,"name":2}', max_bytes=1024) + + def test_rejects_nonfinite_numbers(self): + for number in (b"NaN", b"Infinity", b"-Infinity", b"1e999", b"-1e999"): + with self.subTest(number=number): + with self.assertRaisesRegex(ValueError, "finite"): + common.load_json_bytes(b'{"value":' + number + b"}", max_bytes=1024) + + def test_rejects_invalid_utf8_and_unpaired_surrogates(self): + for data in ( + b'"\xff"', + b'"\xed\xa0\x80"', + b'"\\ud800"', + b'"\\udfff"', + b'{"\\ud800":0}', + ): + with self.subTest(data=data): + with self.assertRaises(ValueError): + common.load_json_bytes(data, max_bytes=1024) + + def test_rejects_trailing_controls_and_accepts_json_whitespace(self): + for control in (b"\x00", b"\x0b", b"\x0c", b"\x1f"): + with self.subTest(control=control): + with self.assertRaises(ValueError): + common.load_json_bytes(b"{}" + control, max_bytes=1024) + self.assertEqual( + common.load_json_bytes(b" \t\r\n{} \t\r\n", max_bytes=1024), + {}, + "should accept only the JSON whitespace characters", + ) + + def test_accepts_valid_unicode_and_finite_numbers(self): + self.assertEqual( + common.load_json_bytes( + b'{"value":1.25,"text":"\\ud83d\\ude00"}', max_bytes=1024 + ), + {"value": 1.25, "text": "😀"}, + "should preserve finite numbers and paired surrogate escapes", + ) + + def test_rejects_container_depth_65_and_accepts_64(self): + with self.assertRaisesRegex(ValueError, "depth"): + common.load_json_bytes(b"[" * 65 + b"0" + b"]" * 65, max_bytes=1024) + document = common.load_json_bytes( + b"[" * 64 + b"0" + b"]" * 64, max_bytes=1024 + ) + for _ in range(64): + self.assertIsInstance(document, list, "should accept every allowed level") + document = document[0] + self.assertEqual(document, 0, "should preserve the nested scalar") + + def test_depth_check_ignores_brackets_and_escaped_quotes_in_strings(self): + data = b'{"text":"[\\\"{]"}' + self.assertEqual( + common.load_json_bytes(data, max_bytes=1024, max_depth=1), + {"text": '["{]'}, + "should count containers rather than bracket characters in strings", + ) + + def test_rejects_oversized_bytes_before_decoding(self): + with self.assertRaisesRegex(ValueError, "size"): + common.load_json_bytes(b"\xff" * 5, max_bytes=4) + self.assertEqual( + common.load_json_bytes(b"{}", max_bytes=2), + {}, + "should accept exactly the configured byte limit", + ) + + def test_requires_bytes_and_nonnegative_integer_limits(self): + with self.assertRaisesRegex(ValueError, "bytes"): + common.load_json_bytes("{}", max_bytes=1024) + for invalid in (-1, True, False, 1.5, "4"): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(ValueError, "integer"): + common.load_json_bytes(b"{}", max_bytes=invalid) + with self.assertRaisesRegex(ValueError, "integer"): + common.load_json_bytes(b"{}", max_bytes=1024, max_depth=invalid) + + +class IntegerTests(unittest.TestCase): + def test_rejects_negative_lengths_and_booleans(self): + for value in (-1, True, False, 1.0, "1", None): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "integer"): + common.require_nonnegative_integer(value) + for value in (0, 1, 512 * 1024 * 1024): + self.assertEqual( + common.require_nonnegative_integer(value), + value, + "should preserve a valid nonnegative integer", + ) + + +class BoundedReadTests(unittest.TestCase): + def test_rejects_oversized_stream_without_unbounded_read(self): + stream = io.BytesIO(b"x" * 10) + with self.assertRaisesRegex(ValueError, "size"): + common.read_bounded(stream, max_bytes=4) + self.assertEqual(stream.tell(), 5, "should read only the limit and one byte") + + def test_accepts_limit_and_short_stream_reads(self): + class ShortReads(io.BytesIO): + def read(self, size=-1): + return super().read(min(size, 2)) + + for stream in (io.BytesIO(b"1234"), ShortReads(b"1234")): + with self.subTest(stream=type(stream).__name__): + self.assertEqual( + common.read_bounded(stream, max_bytes=4), + b"1234", + "should read through short reads up to EOF", + ) + self.assertEqual( + common.read_bounded(io.BytesIO(), max_bytes=0), + b"", + "should accept empty data at a zero byte limit", + ) + + def test_rejects_invalid_stream_limits_and_text_streams(self): + for limit in (-1, True): + with self.subTest(limit=limit): + with self.assertRaisesRegex(ValueError, "integer"): + common.read_bounded(io.BytesIO(b"{}"), max_bytes=limit) + with self.assertRaisesRegex(ValueError, "binary"): + common.read_bounded(io.StringIO("{}"), max_bytes=4) + + def test_read_json_file_checks_actual_bytes(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "index.json" + path.write_bytes(b'{"name":"example"}') + with self.assertRaisesRegex(ValueError, "size"): + common.read_json_file(path, max_bytes=4) + self.assertEqual( + common.read_json_file(path, max_bytes=1024), + {"name": "example"}, + "should parse a document read from bounded binary data", + ) + + +class DigestTests(unittest.TestCase): + def test_sha256_hashes_original_bytes(self): + compact = b'{"name":"example"}' + spaced = b'{ "name" : "example" }\n' + self.assertEqual( + common.load_json_bytes(compact, max_bytes=1024), + common.load_json_bytes(spaced, max_bytes=1024), + "should parse both byte representations to the same document", + ) + for data in (compact, spaced): + self.assertEqual( + common.sha256_bytes(data), + hashlib.sha256(data).hexdigest(), + "should match the digest of the exact original bytes", + ) + self.assertNotEqual( + common.sha256_bytes(compact), + common.sha256_bytes(spaced), + "should distinguish different serialization bytes", + ) + + def test_sha256_rejects_text(self): + with self.assertRaisesRegex(ValueError, "bytes"): + common.sha256_bytes("{}") + + +class AssetNameTests(unittest.TestCase): + def test_rejects_unsafe_flat_asset_names(self): + for name in ( + "", ".", "..", "../index.json", "a/b", "a\\b", "/index.json", + "C:index.json", "a\x00b", "a\nb", "a\x7fb", ".hidden", + "-option", "a b", "é.json", "a%2fb", None, 12, + ): + with self.subTest(name=name): + with self.assertRaisesRegex(ValueError, "asset name"): + common.require_safe_asset_name(name) + for name in ("index.json", "trusted-server-linux-x86_64.tar.gz", "a_b-1.wasm"): + self.assertEqual( + common.require_safe_asset_name(name), name, + "should preserve an unambiguous flat ASCII asset name", + ) + + def test_rejects_names_outside_release_asset_contract(self): + for name in ("name+extra", "a..b", "a.", "a" * 161): + with self.subTest(name=name): + with self.assertRaisesRegex(ValueError, "asset name"): + common.require_safe_asset_name(name) + self.assertEqual( + common.require_safe_asset_name("a" * 160), "a" * 160, + "should accept the maximum contract name length", + ) + + +class IdentityTests(unittest.TestCase): + def test_rejects_ambiguous_duplicate_identities(self): + records = [ + {"kind": "asset", "name": "index.json", "sha256": "a" * 64}, + {"kind": "asset", "name": "index.json", "sha256": "b" * 64}, + ] + with self.assertRaisesRegex(ValueError, "duplicate identity"): + common.require_unique_identities(records, ("kind", "name")) + + def test_accepts_distinct_composite_identities(self): + common.require_unique_identities( + [{"kind": "asset", "name": "index.json"}, + {"kind": "statement", "name": "index.json"}], + ("kind", "name"), + ) + + def test_rejects_missing_and_nonscalar_identity_fields(self): + for record in ({}, {"name": []}, {"name": {}}, {"name": True}, {"name": None}): + with self.subTest(record=record): + with self.assertRaisesRegex(ValueError, "identity"): + common.require_unique_identities([record], ("name",)) + + +class SerializationTests(unittest.TestCase): + def test_deterministic_json_bytes_sorts_keys_and_uses_utf8(self): + expected = '{"a":"é","z":{"a":1,"b":2}}\n'.encode("utf-8") + for document in ( + {"z": {"b": 2, "a": 1}, "a": "é"}, + {"a": "é", "z": {"a": 1, "b": 2}}, + ): + self.assertEqual( + common.deterministic_json_bytes(document), expected, + "should write compact sorted UTF-8 JSON with a final newline", + ) + + def test_serialization_rejects_nonfinite_and_unpaired_surrogates(self): + for value in (float("nan"), float("inf"), float("-inf"), "\ud800"): + with self.subTest(value=repr(value)): + with self.assertRaises(ValueError): + common.deterministic_json_bytes({"value": value}) + + def test_serialization_rejects_nonstring_object_keys(self): + with self.assertRaisesRegex(ValueError, "keys"): + common.deterministic_json_bytes({1: "example"}) + + def test_serialization_rejects_cycles_promptly(self): + source = """ +import sys +sys.path.insert(0, sys.argv[1]) +import common +document = [] +document.append(document) +try: + common.deterministic_json_bytes(document) +except ValueError: + sys.exit(0) +raise AssertionError('should reject a cyclic producer document') +""" + try: + result = subprocess.run( + [sys.executable, "-c", source, str(Path(common.__file__).parent)], + capture_output=True, text=True, timeout=2, check=False, + ) + except subprocess.TimeoutExpired: + self.fail("should reject a cyclic producer document promptly") + self.assertEqual( + result.returncode, 0, f"should reject cyclic documents: {result.stderr}" + ) + + +class AtomicWriteTests(unittest.TestCase): + def test_atomic_write_replaces_existing_bytes_privately(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "index.json" + path.write_bytes(b"old") + common.atomic_write(path, b"new") + self.assertEqual(path.read_bytes(), b"new", "should replace the old bytes") + self.assertEqual( + stat.S_IMODE(path.stat().st_mode), 0o600, + "should publish the private temporary file's permissions", + ) + self.assertEqual( + list(Path(directory).iterdir()), [path], + "should leave no temporary files after success", + ) + + def test_atomic_write_failure_preserves_old_file_and_removes_temporary(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "index.json" + path.write_bytes(b"old") + with mock.patch.object( + os, "replace", side_effect=OSError("replace failed") + ): + with self.assertRaisesRegex(OSError, "replace failed"): + common.atomic_write(path, b"new") + self.assertEqual( + path.read_bytes(), b"old", "should preserve existing bytes" + ) + self.assertEqual( + list(Path(directory).iterdir()), [path], + "should remove the unpublished temporary file", + ) + + def test_atomic_write_rejects_text_before_creating_a_file(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "index.json" + with self.assertRaisesRegex(ValueError, "bytes"): + common.atomic_write(path, "text") + self.assertEqual( + list(Path(directory).iterdir()), [], + "should leave the directory empty for invalid input", + ) + + def test_atomic_write_sync_failure_cleans_up_before_replace(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "index.json" + path.write_bytes(b"old") + with mock.patch.object(os, "fsync", side_effect=OSError("sync failed")): + with self.assertRaisesRegex(OSError, "sync failed"): + common.atomic_write(path, b"new") + self.assertEqual( + path.read_bytes(), b"old", "should preserve existing bytes" + ) + self.assertEqual( + list(Path(directory).iterdir()), [path], + "should remove a temporary file after write or sync failure", + ) + + +if __name__ == "__main__": + unittest.main() From c29940908d396b94b0b164fb1a253c6a2297899d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:05:50 +0530 Subject: [PATCH 011/153] Reject invalid keys in nested producer arrays --- scripts/release/common.py | 2 +- scripts/release/tests/test_common.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/release/common.py b/scripts/release/common.py index 715c4caf0f..bdebc26c5d 100644 --- a/scripts/release/common.py +++ b/scripts/release/common.py @@ -199,7 +199,7 @@ def _check_unicode(document): raise ValueError("JSON object keys must be strings") pending.extend(value.keys()) pending.extend(value.values()) - elif isinstance(value, list): + elif isinstance(value, (list, tuple)): pending.extend(value) diff --git a/scripts/release/tests/test_common.py b/scripts/release/tests/test_common.py index fd6a9bfa15..fd730a5153 100644 --- a/scripts/release/tests/test_common.py +++ b/scripts/release/tests/test_common.py @@ -256,6 +256,20 @@ def test_serialization_rejects_nonstring_object_keys(self): with self.assertRaisesRegex(ValueError, "keys"): common.deterministic_json_bytes({1: "example"}) + def test_serialization_rejects_nonstring_keys_inside_tuple_containers(self): + for key in (1, True): + for document in (({key: "example"},), {"items": (({key: "example"},),)}): + with self.subTest(key=key, document=document): + with self.assertRaisesRegex(ValueError, "keys"): + common.deterministic_json_bytes(document) + + def test_serialization_accepts_valid_tuple_containers(self): + self.assertEqual( + common.deterministic_json_bytes(({"name": "example"},)), + b'[{"name":"example"}]\n', + "should serialize valid tuples using JSON array syntax", + ) + def test_serialization_rejects_cycles_promptly(self): source = """ import sys From fbb88d2e631c0d1752eec804283e0b99d1b74b99 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:12:33 +0530 Subject: [PATCH 012/153] Normalize serialization errors and identity fields --- scripts/release/common.py | 21 ++++++++++++-------- scripts/release/tests/test_common.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/scripts/release/common.py b/scripts/release/common.py index bdebc26c5d..123b47580e 100644 --- a/scripts/release/common.py +++ b/scripts/release/common.py @@ -111,6 +111,7 @@ def require_unique_identities(records, fields): Identity values must be strings or integers; missing fields and compound values raise ValueError rather than participating in ambiguous matches. """ + fields = tuple(fields) seen = set() for record in records: if not isinstance(record, dict) or any(field not in record for field in fields): @@ -128,15 +129,19 @@ def deterministic_json_bytes(document): This is a producer encoding, not a canonicalization step for verification. Hash signed or downloaded documents using their original bytes instead. - Raise ValueError for nonfinite numbers, surrogate strings, or invalid keys. + Raise ValueError for unsupported values, nonfinite numbers, surrogate strings, + or invalid keys. """ - text = json.dumps( - document, - ensure_ascii=False, - allow_nan=False, - sort_keys=True, - separators=(",", ":"), - ) + try: + text = json.dumps( + document, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + except TypeError as error: + raise ValueError(f"invalid JSON serialization: {error}") from error _check_unicode(document) return (text + "\n").encode("utf-8") diff --git a/scripts/release/tests/test_common.py b/scripts/release/tests/test_common.py index fd730a5153..e9ffbbc3f3 100644 --- a/scripts/release/tests/test_common.py +++ b/scripts/release/tests/test_common.py @@ -233,6 +233,22 @@ def test_rejects_missing_and_nonscalar_identity_fields(self): with self.assertRaisesRegex(ValueError, "identity"): common.require_unique_identities([record], ("name",)) + def test_generator_fields_accept_distinct_identities(self): + result = common.require_unique_identities( + [{"name": "index.json"}, {"name": "statement.json"}], + iter(("name",)), + ) + self.assertIsNone( + result, "should accept distinct identities with iterator fields" + ) + + def test_generator_fields_reject_duplicate_identities(self): + with self.assertRaisesRegex(ValueError, "duplicate identity"): + common.require_unique_identities( + [{"name": "index.json"}, {"name": "index.json"}], + iter(("name",)), + ) + class SerializationTests(unittest.TestCase): def test_deterministic_json_bytes_sorts_keys_and_uses_utf8(self): @@ -256,6 +272,19 @@ def test_serialization_rejects_nonstring_object_keys(self): with self.assertRaisesRegex(ValueError, "keys"): common.deterministic_json_bytes({1: "example"}) + def test_serialization_reports_mixed_keys_as_value_errors(self): + for document in ( + {"name": "example", 1: "example"}, + {"items": [{"name": "example", 1: "example"}]}, + ): + with self.subTest(document=document): + with self.assertRaisesRegex(ValueError, "JSON serialization"): + common.deterministic_json_bytes(document) + + def test_serialization_reports_unsupported_values_as_value_errors(self): + with self.assertRaisesRegex(ValueError, "JSON serialization"): + common.deterministic_json_bytes({"items": {"example"}}) + def test_serialization_rejects_nonstring_keys_inside_tuple_containers(self): for key in (1, True): for document in (({key: "example"},), {"items": (({key: "example"},),)}): From 7acdfb39612881102fe78e93dbe56307448734a7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:15:24 +0530 Subject: [PATCH 013/153] Record release utility verification and review --- .../2026-10-13-attested-release-implementation.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 6eb4607e73..f9848562ee 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -70,9 +70,9 @@ New files below are proposed, not existing APIs. Keep security parsing and opera **Create:** `scripts/release/common.py`, `scripts/release/tests/test_common.py`, `scripts/release/tests/fixtures/README.md`, `scripts/release/requirements.txt`. -- [ ] Write unittest cases for duplicate keys, nonfinite JSON, trailing controls, unsafe names, depth 65, oversized input, negative lengths and ambiguous duplicate identities. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_common.py' -v`; expect failure because the module does not exist. -- [ ] Implement strict byte-first loading, bounded reads, SHA-256, safe flat asset names, deterministic output and atomic writes. Start duplicate handling with: +- [x] Write unittest cases for duplicate keys, nonfinite JSON, trailing controls, unsafe names, depth 65, oversized input, negative lengths and ambiguous duplicate identities. +- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_common.py' -v`; expect failure because the module does not exist. +- [x] Implement strict byte-first loading, bounded reads, SHA-256, safe flat asset names, deterministic output and atomic writes. Start duplicate handling with: ```python def unique_object(pairs): @@ -86,8 +86,8 @@ def unique_object(pairs): Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_constant)` after size/depth checks. Hash original file bytes, not parsed/reserialized objects. -- [ ] Qualify and pin the schema-validator distribution and hashes in `requirements.txt`; install through a private venv. Add explicit dependency installation to producer validation, rather than relying on ambient Python packages. -- [ ] Re-run the tests; expect all negatives rejected and deterministic positive bytes. Commit `Add strict release document utilities`. +- [x] Qualify and pin the schema-validator distribution and hashes in `requirements.txt`; install through a private venv. Add explicit dependency installation to producer validation, rather than relying on ambient Python packages. +- [x] Re-run the tests; expect all negatives rejected and deterministic positive bytes. Commit `Add strict release document utilities`. ### Task 3: Encode cohort, tools and fail-closed release preflight @@ -432,3 +432,5 @@ Task 1 investigation confirms the four-artifact cohort, its existing compatibili Permission-policy reconciliation remains an enablement dependency owned by the #1045/#1084 work (`jwrosewell`), not a blocker for local implementation. The current checkout loads app configuration at runtime and has no embedded permission-policy module. #1045 at `a028c00da096cc722dd95307ab8ee0e3514a448e` embeds `config/permissions/sample.yaml`; #1084 at `3c43876fc8b0d316d812a8c74aba47cc87d17279` explicitly defers runtime policy. Those proposals must satisfy R-CONFIG before release enablement. This record neither approves their embedded policy nor selects a new transport. Task completion requires implementation evidence and both independent reviews. Task 1's owner reconciliation and maintainer milestone review remain pending; subsequent local implementation can proceed. Hosted qualification, governance setup, signing/publication rehearsal and live Fastly submission remain distinct evidence gates, and no production release trigger is enabled by this record. + +Task 2 is implemented in `1cfca1357`, with review corrections in `c29940908` and `fbb88d2e`. Independent spec and quality reviews pass after resolving tuple-contained key validation, serialization exception consistency and iterator identity selectors. The controller's fresh utility run passes 35 tests. A fresh private Python 3.12 environment installs all six pinned dependencies with `--require-hashes --only-binary=:all:`; `pip check`, the normative release-index schema check and Draft 2020-12 positive/negative semantics pass. The reproducible installation/validation procedure is in `scripts/release/tests/fixtures/README.md`; Task 14 must wire this explicit installation into hosted producer validation before using the validator. These checks establish utility behavior and local validator compatibility, not artifact or hosted qualification. From c9076d0f11f16c0014e10a8ad7eea82fccd30b2b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:37:03 +0530 Subject: [PATCH 014/153] Define fixed release inputs and preflight policy --- crates/trusted-server-cli/release-policy.json | 140 ++++ .../src/commands/dev/lint/domains.rs | 48 ++ scripts/release/preflight.py | 682 ++++++++++++++++++ scripts/release/release-cohort.json | 79 ++ scripts/release/release-tools.json | 345 +++++++++ scripts/release/tests/test_preflight.py | 457 ++++++++++++ 6 files changed, 1751 insertions(+) create mode 100644 crates/trusted-server-cli/release-policy.json create mode 100644 scripts/release/preflight.py create mode 100644 scripts/release/release-cohort.json create mode 100644 scripts/release/release-tools.json create mode 100644 scripts/release/tests/test_preflight.py diff --git a/crates/trusted-server-cli/release-policy.json b/crates/trusted-server-cli/release-policy.json new file mode 100644 index 0000000000..35c9017875 --- /dev/null +++ b/crates/trusted-server-cli/release-policy.json @@ -0,0 +1,140 @@ +{ + "artifacts": [ + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom-repeatable", + "bin": "trusted-server-adapter-fastly", + "capabilities": ["fastly-production"], + "compatibility": { + "architecture": "wasm32", + "minimum_version": "wasip1", + "runtime": "fastly-compute" + }, + "default_features": true, + "features": [], + "kind": "wasm-core", + "name": "trusted-server-fastly.wasm", + "package": "trusted-server-adapter-fastly", + "role": "server", + "target": "wasm32-wasip1", + "variant": "default" + }, + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "aarch64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-aarch64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "aarch64-apple-darwin", + "variant": "default" + }, + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-apple-darwin", + "variant": "default" + }, + { + "actions": ["readonly-verify"], + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-unknown-linux-gnu", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + } + ], + "bounds": { + "attempt_seconds": 900, + "bundle_bytes": 33554432, + "child_seconds": 120, + "cohort_executables": 4, + "companion_bytes": 33554432, + "executable_bytes": 536870912, + "index_bytes": 1048576, + "inventory_bytes": 1048576, + "json_depth": 64, + "matching_attestations_per_bundle": 1, + "metadata_bytes": 1048576, + "policy_bytes": 1048576, + "spdx_bytes": 16777216, + "stderr_bytes": 1048576, + "stdout_bytes": 134217728, + "trusted_roots_bytes": 1048576 + }, + "cohort": "phase1-fastly-cli", + "github_host": "github.com", + "historical_producer_policies": [], + "identity": { + "builder_id": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/", + "caller_commit": "same-approved-source-commit", + "caller_path": ".github/workflows/release.yml", + "caller_repository": "https://github.com/IABTechLab/trusted-server", + "certificate_fields": [ + "buildConfigDigest", + "buildConfigURI", + "buildTrigger", + "runInvocationURI", + "sourceRepositoryIdentifier", + "sourceRepositoryOwnerIdentifier" + ], + "certificate_san": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/", + "event": "push", + "external_parameters": "exact-caller-workflow-only-no-inputs", + "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance", + "issuer": "https://token.actions.githubusercontent.com", + "runner": "github-hosted", + "signer_commit": "same-approved-source-commit", + "source_commit": "independently-approved-40-lowercase-hex", + "source_dependency": "git+https://github.com/IABTechLab/trusted-server@refs/tags/", + "source_ref": "refs/tags/" + }, + "policy_version": 1, + "predicates": { + "build_type": "https://actions.github.io/buildtypes/workflow/v1", + "provenance": "https://slsa.dev/provenance/v1", + "sbom": "https://spdx.dev/Document" + }, + "public_actions": ["readonly-verify"], + "repository": "IABTechLab/trusted-server", + "repository_id": "950860906", + "repository_owner_id": "82903538", + "tool_versions": { + "actions_attest": "4.2.2", + "actions_attest_revision": "1e69f48acb82d1966a394da916b4c1698aa569d6", + "gh": "2.97.0" + } +} diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs index 8978af61c0..3fe578b3b0 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -112,8 +112,11 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "iabtechlab.github.io", "iabeurope.github.io", // Specs (supply chain) + "actions.github.io", "in-toto.io", "rslstandard.org", + "slsa.dev", + "spdx.dev", // Specs (other) "webassembly.org", // Fastly docs @@ -1369,6 +1372,51 @@ mod scan_line_tests { } } + #[test] + fn release_policy_standard_identifiers_pass_without_suppression() { + let policy = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/release-policy.json")); + for line in policy.lines() { + assert!( + hosts(line).is_empty(), + "should accept reviewed production policy identifiers: {line}" + ); + } + } + + #[test] + fn release_policy_standard_hosts_reject_subdomains_and_lookalikes() { + let policy: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/release-policy.json" + ))) + .expect("should parse the production release policy"); + let predicates = policy["predicates"] + .as_object() + .expect("should provide fixed predicate identities"); + for predicate in predicates.values() { + let identifier = predicate + .as_str() + .expect("should provide a string predicate identifier"); + let authority = identifier + .strip_prefix("https://") + .expect("should use an HTTPS predicate identifier") + .split('/') + .next() + .expect("should contain the standard host"); + for host in [ + format!("subdomain.{authority}"), + format!("{authority}.unreviewed"), + format!("lookalike-{authority}"), + ] { + assert_eq!( + hosts(&format!("https://{host}/example")), + vec![host], + "should reject unreviewed standard-host variants" + ); + } + } + } + #[test] fn disallowed_reports() { assert_eq!(hosts("see https://test.com"), vec!["test.com"]); diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py new file mode 100644 index 0000000000..8284304e96 --- /dev/null +++ b/scripts/release/preflight.py @@ -0,0 +1,682 @@ +"""Check the fixed release contract before any preparation or signing. + +This module selects no executable from downloaded JSON. Qualification remains +pending until authenticated acquisition and the real hosted recipes are reviewed. +""" + +import argparse +import io +import logging +import os +import re +import stat +import subprocess +import sys +import tomllib +from pathlib import Path + +# The preflight must not add Python cache files to its source-only directory. +sys.dont_write_bytecode = True + +from common import deterministic_json_bytes, read_json_file, require_nonnegative_integer, sha256_bytes + +LIMIT = 1048576 +PLATFORMS = ( + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +) +ATTEST_REVISION = "1e69f48acb82d1966a394da916b4c1698aa569d6" +VERSIONS = { + "actions/attest": "4.2.2", + "fastly": "15.1.0", + "gh": "2.97.0", + "node": "24.12.0", + "npm": "11.6.2", + "rust": "1.95.0", + "viceroy": "0.17.0", + "wasmtime": "44.0.1", +} + + +def fixed_artifacts(): + """Return the four source-owned artifact identities and build selections.""" + artifacts = [] + for target in PLATFORMS: + linux = target.endswith("linux-gnu") + artifacts.append({ + "name": f"ts-{target}", + "role": "operator-cli", + "kind": "native-executable", + "package": "trusted-server-cli", + "bin": "ts", + "target": target, + "variant": "default", + "default_features": True, + "features": [], + "assurance_profile": "provenance-sbom", + "capabilities": ["cli-execute"], + "compatibility": { + "runtime": "linux-gnu" if linux else "macos", + "minimum_version": "glibc-2.34" if linux else "13.0", + "architecture": target.split("-")[0], + }, + }) + artifacts.append({ + "name": "trusted-server-fastly.wasm", + "role": "server", + "kind": "wasm-core", + "package": "trusted-server-adapter-fastly", + "bin": "trusted-server-adapter-fastly", + "target": "wasm32-wasip1", + "variant": "default", + "default_features": True, + "features": [], + "assurance_profile": "provenance-sbom-repeatable", + "capabilities": ["fastly-production"], + "compatibility": { + "runtime": "fastly-compute", + "minimum_version": "wasip1", + "architecture": "wasm32", + }, + }) + return sorted(artifacts, key=lambda artifact: artifact["name"]) + + +def fixed_tools(): + """Return exact version selections with explicitly unmeasured digests.""" + tools = [] + for name, version in sorted(VERSIONS.items()): + platforms = ("github-action",) if name == "actions/attest" else PLATFORMS + for platform in platforms: + tools.append({ + "name": f"{name}:{platform}", + "version": version, + "revision": ATTEST_REVISION if name == "actions/attest" else None, + "sha256": None, + "acquisition": None, + "qualification": "pending", + }) + environments = [{ + "id": identity, + "image_sha256": None, + "sdk_sha256": None, + "acquisition": None, + "qualification": "pending", + } for identity in ( + "debian12-x86_64", "macos15-aarch64", "macos15-x86_64", + "ubi9-x86_64", "ubuntu22.04-x86_64", + )] + recipes = [] + for artifact in fixed_artifacts(): + recipes.append({ + "id": artifact["name"], + "script": "scripts/release/build.sh", + "sha256": None, + "qualification": "pending", + "settings": recipe_settings(), + }) + recipes.append({ + "id": "tsjs", + "script": "scripts/release/prepare-js.mjs", + "sha256": None, + "qualification": "pending", + "settings": recipe_settings(), + }) + return { + "schema_version": 1, + "tools": tools, + "environments": environments, + "recipes": sorted(recipes, key=lambda recipe: recipe["id"]), + } + + +def recipe_settings(): + """Return reviewed effective settings for the later executable recipes.""" + return { + "cargo_locked": True, + "cargo_config": ".cargo/config.toml", + "cargo_home": "private-prepared", + "rust_toolchain": "1.95.0", + "release_debug": 1, + "fresh_target": True, + "network_boundary": "qualification-pending", + "tool_paths": "authenticated-absolute-paths-required", + "sysroot": "authenticated-sdk-required", + "aws_lc_use_system": False, + "aws_lc_target_selector": True, + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": False, + "vite_mode": "production", + "vite_env_loading": False, + "vite_config_loading": False, + "npm_lifecycle": "reviewed-preparation-only", + } + + +def fixed_policy(): + """Return the embedded consumer contract; its hash is computed externally.""" + repository = "https://github.com/IABTechLab/trusted-server" + return { + "policy_version": 1, + "cohort": "phase1-fastly-cli", + "github_host": "github.com", + "repository": "IABTechLab/trusted-server", + "repository_id": "950860906", + "repository_owner_id": "82903538", + "identity": { + "issuer": "https://token.actions.githubusercontent.com", + "certificate_san": repository + "/.github/workflows/release-build.yml@refs/tags/", + "builder_id": repository + "/.github/workflows/release-build.yml@refs/tags/", + "caller_path": ".github/workflows/release.yml", + "caller_repository": repository, + "source_ref": "refs/tags/", + "source_dependency": "git+" + repository + "@refs/tags/", + "source_commit": "independently-approved-40-lowercase-hex", + "signer_commit": "same-approved-source-commit", + "caller_commit": "same-approved-source-commit", + "event": "push", + "runner": "github-hosted", + "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance", + "external_parameters": "exact-caller-workflow-only-no-inputs", + "certificate_fields": [ + "buildConfigDigest", "buildConfigURI", "buildTrigger", "runInvocationURI", + "sourceRepositoryIdentifier", "sourceRepositoryOwnerIdentifier", + ], + }, + "predicates": { + "provenance": "https://slsa.dev/provenance/v1", + "build_type": "https://actions.github.io/buildtypes/workflow/v1", + "sbom": "https://spdx.dev/Document", + }, + "tool_versions": { + "gh": "2.97.0", + "actions_attest": "4.2.2", + "actions_attest_revision": ATTEST_REVISION, + }, + "historical_producer_policies": [], + "public_actions": ["readonly-verify"], + "artifacts": [dict(artifact, actions=["readonly-verify"]) + for artifact in fixed_artifacts()], + "bounds": { + "child_seconds": 120, + "attempt_seconds": 900, + "stdout_bytes": 134217728, + "stderr_bytes": 1048576, + "metadata_bytes": 1048576, + "index_bytes": 1048576, + "policy_bytes": 1048576, + "inventory_bytes": 1048576, + "trusted_roots_bytes": 1048576, + "companion_bytes": 33554432, + "bundle_bytes": 33554432, + "spdx_bytes": 16777216, + "executable_bytes": 536870912, + "json_depth": 64, + "cohort_executables": 4, + "matching_attestations_per_bundle": 1, + }, + } + + +def _same_contract(actual, expected, label): + # Type comparison prevents Python's True == 1 from weakening JSON types. + if type(actual) is not type(expected): + raise ValueError(f"{label}: unexpected type") + if isinstance(expected, dict): + if actual.keys() != expected.keys(): + raise ValueError(f"{label}: missing or unknown fields") + for key, value in expected.items(): + _same_contract(actual[key], value, f"{label}.{key}") + elif isinstance(expected, list): + if len(actual) != len(expected): + raise ValueError(f"{label}: unexpected membership") + for index, (value, wanted) in enumerate(zip(actual, expected)): + _same_contract(value, wanted, f"{label}[{index}]") + elif actual != expected: + raise ValueError(f"{label}: unreviewed selection") + + +def validate_cohort(cohort): + """Reject unknown artifacts, selections and branch patterns with ValueError.""" + if not isinstance(cohort, dict) or set(cohort) != { + "schema_version", "cohort", "eligible_branches", "artifacts" + }: + raise ValueError("cohort: missing or unknown fields") + branches = cohort["eligible_branches"] + if (not isinstance(branches, list) or not branches + or any(not isinstance(branch, str) for branch in branches) + or branches != sorted(set(branches)) or "main" not in branches + or any(branch != "main" and not re.fullmatch( + r"rc/[0-9]{4}(?:0[1-9]|1[0-2])", branch + ) for branch in branches)): + raise ValueError("cohort: branches must be exact reviewed main/rc entries") + _same_contract(cohort, { + "schema_version": 1, + "cohort": "phase1-fastly-cli", + "eligible_branches": branches, + "artifacts": fixed_artifacts(), + }, "cohort") + + +def validate_tools(tools, *, require_qualified=False): + """Check selections, refusing release use until Task 8 qualification exists.""" + expected = fixed_tools() + # Digest strings alone do not confer qualification. Task 8 owns evidence. + for section, digest_fields in ( + ("tools", ("sha256",)), + ("environments", ("image_sha256", "sdk_sha256")), + ("recipes", ("sha256",)), + ): + records = tools.get(section) if isinstance(tools, dict) else None + if not isinstance(records, list) or len(records) != len(expected[section]): + raise ValueError(f"tools.{section}: unexpected membership") + for record, wanted in zip(records, expected[section]): + if not isinstance(record, dict): + raise ValueError(f"tools.{section}: expected record") + for field in digest_fields: + digest = record.get(field) + if digest is not None and ( + not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) + ): + raise ValueError("tools: invalid measured digest") + wanted[field] = digest + _same_contract(tools, expected, "tools") + if require_qualified: + raise ValueError("release qualification pending: authenticated acquisition and Task 8 evidence required") + + +def validate_policy(policy): + """Reject consumer policy weakening or self-selected trust with ValueError.""" + _same_contract(policy, fixed_policy(), "policy") + + +def load_contracts(cohort_path, tools_path, policy_path, *, require_qualified=False): + """Load bounded strict contracts and reject incomplete release qualification.""" + cohort = read_json_file(cohort_path, max_bytes=LIMIT) + tools = read_json_file(tools_path, max_bytes=LIMIT) + policy = read_json_file(policy_path, max_bytes=LIMIT) + validate_cohort(cohort) + validate_tools(tools, require_qualified=require_qualified) + validate_policy(policy) + return cohort, tools, policy + + +def audit_environment(inherited): + """Reject unreviewed build overrides before clearing inherited values. + + Diagnostics contain variable names only. Publisher and provider values are + removed by the fresh child environment rather than interpreted as build facts. + """ + for name in inherited: + upper = name.upper() + if name == "TRUSTED_SERVER__RELEASE_BUILD" and inherited[name] != "1": + raise ValueError("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1 when present") + if upper.startswith(("TRUSTED_SERVER__", "EDGEZERO__")): + continue + if upper == "TSJS_SKIP_BUILD": + raise ValueError("TSJS_SKIP_BUILD is forbidden with every value") + forbidden_prefix = upper.startswith(( + "AWS_LC_SYS_", "CARGO", "RUST", "NODE_", "NPM_", "VITE_", + "LIBSQLITE3_", "SQLITE", "SQLCIPHER", "PKG_CONFIG", "VCPKG", + "GIT_", "PYTHON", "LD_", "DYLD_", "BINDGEN_", "CLANG_", "TSJS_", + )) + compiler = re.search( + r"(?:^|_)(?:CC|CXX|CPP|AR|AS|RANLIB|CFLAGS|CXXFLAGS|CPPFLAGS|LDFLAGS|" + r"OPENSSL|SYSROOT)(?:_|$)", upper + ) + if forbidden_prefix or compiler or upper in { + "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "CDPATH", "IFS", + "SDKROOT", "DEVELOPER_DIR", "MACOSX_DEPLOYMENT_TARGET", + "EFFECTIVE_TARGET", "TARGET", "HOST", "PROFILE", "OPT_LEVEL", + "DEBUG", "OUT_DIR", "SOURCE_DATE_EPOCH", "MAKEFLAGS", "MFLAGS", + "CMAKE_TOOLCHAIN_FILE", "CMAKE_PREFIX_PATH", "CPATH", "LIBRARY_PATH", + "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "COMPILER_PATH", "GCC_EXEC_PREFIX", + }: + raise ValueError(f"unreviewed inherited override: {name}") + + +def _commit_id(commit): + if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ValueError("source commit must be independently approved full lowercase hex") + return commit + + +def build_environment(inherited, commit, epoch, target, paths): + """Construct fresh children from recipe-owned absolute paths and facts. + + Paths must come from authenticated recipe selection, never ambient PATH or + downloaded descriptors. This structural helper does not qualify those paths. + """ + audit_environment(inherited) + _commit_id(commit) + require_nonnegative_integer(epoch) + if target not in (*PLATFORMS, "wasm32-wasip1"): + raise ValueError("unknown release target") + if not isinstance(paths, dict) or set(paths) != { + "home", "cargo_home", "rustup_home", "tmpdir", "tool_path", "sysroot" + }: + raise ValueError("explicit authenticated recipe paths required") + selected = list(paths.values()) + tool_path = paths["tool_path"] + if not isinstance(tool_path, list) or not tool_path: + raise ValueError("explicit tool path directories required") + selected.remove(tool_path) + selected.extend(tool_path) + if any(not isinstance(path, str) or not Path(path).is_absolute() + or not Path(path).is_dir() for path in selected): + raise ValueError("recipe paths must be existing absolute directories") + for filename in ("config", "config.toml"): + if (Path(paths["cargo_home"]) / filename).exists(): + raise ValueError("private Cargo home must not supply unreviewed configuration") + environment = { + "HOME": paths["home"], + "CARGO_HOME": paths["cargo_home"], + "RUSTUP_HOME": paths["rustup_home"], + "TMPDIR": paths["tmpdir"], + "PATH": os.pathsep.join(tool_path), + "LANG": "C", + "LC_ALL": "C", + "TZ": "UTC", + "SOURCE_DATE_EPOCH": str(epoch), + "TRUSTED_SERVER__GIT_VERSION": commit, + "TRUSTED_SERVER__RELEASE_BUILD": "1", + "AWS_LC_SYS_USE_SYSTEM": "0", + f"AWS_LC_SYS_USE_SYSTEM_{target.replace('-', '_')}": "0", + "LIBSQLITE3_SYS_USE_PKG_CONFIG": "0", + "NODE_ENV": "production", + "PYTHONDONTWRITEBYTECODE": "1", + } + if target.endswith("apple-darwin"): + environment["SDKROOT"] = paths["sysroot"] + environment["MACOSX_DEPLOYMENT_TARGET"] = "13.0" + return environment + + +def _git(repo, arguments, data=None): + # Never inherit replacement objects, config injection, loaders or credentials. + environment = { + "PATH": "/usr/bin:/bin", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_NO_REPLACE_OBJECTS": "1", + "LC_ALL": "C", + } + result = subprocess.run( + ["/usr/bin/git", "--no-replace-objects", "-C", str(repo), *arguments], + input=data, env=environment, capture_output=True, timeout=120, check=False, + ) + if result.returncode: + raise ValueError("Git source inspection failed") + return result.stdout + + +def _audit_configuration(repo, inherited): + configuration = _git(repo, ["config", "--local", "--no-includes", "--name-only", "--list"]) + for key in configuration.decode("utf-8").splitlines(): + if key.startswith(("include.", "includeif.", "filter.", "extensions.")) or key in { + "core.worktree", "core.fsmonitor", "core.attributesfile", "core.sshcommand", + }: + raise ValueError("unreviewed Git configuration channel") + if _git(repo, ["for-each-ref", "--format=%(refname)", "refs/replace/"]).strip(): + raise ValueError("Git replacement references are forbidden") + git_directory = Path(_git(repo, ["rev-parse", "--absolute-git-dir"]).decode().strip()) + for relative in ("objects/info/alternates", "info/grafts"): + if (git_directory / relative).exists(): + raise ValueError("Git alternative object or graft channel is forbidden") + locations = list(repo.parents) + home = inherited.get("HOME") + if home: + locations.append(Path(home)) + for location in locations: + if location == repo: + continue + for filename in ("config", "config.toml"): + if os.path.lexists(location / ".cargo" / filename): + raise ValueError("unreviewed ancestor/home Cargo configuration") + if os.path.lexists(repo / ".cargo/config"): + raise ValueError("unreviewed legacy Cargo configuration layer") + selected = repo / ".cargo/config.toml" + if selected.exists(): + configuration = tomllib.loads(selected.read_text(encoding="utf-8")) + if set(configuration) - {"alias", "target"}: + raise ValueError("Cargo release configuration may select only reviewed aliases and test runners") + aliases = configuration.get("alias", {}) + targets = configuration.get("target", {}) + if not isinstance(aliases, dict) or any(not isinstance(value, str) for value in aliases.values()): + raise ValueError("Cargo aliases must be strings") + if not isinstance(targets, dict) or any( + name != 'cfg(all(target_arch = "wasm32"))' + or not isinstance(settings, dict) or set(settings) != {"runner"} + or settings["runner"] != "viceroy run -C ../../fastly.toml -- " + for name, settings in targets.items() + ): + raise ValueError("unreviewed Cargo target compiler/linker settings") + + +def _source_roots(repo, entries): + roots = {".cargo", "scripts/release"} + for name in entries: + parts = Path(name).parts + if parts[0] == "crates": + for directory in ("src", "build_support"): + if directory in parts: + roots.add(str(Path(*parts[:parts.index(directory) + 1]))) + crates = repo / "crates" + if crates.exists(): + if crates.is_symlink(): + raise ValueError("symlinked source parent") + for crate in crates.iterdir(): + if crate.is_symlink(): + raise ValueError("symlinked source crate") + if crate.is_dir(): + for directory in ("src", "build_support"): + if os.path.lexists(crate / directory): + roots.add(str((crate / directory).relative_to(repo))) + return roots + + +def _check_source_parents(repo, path): + for parent in path.parents: + if parent == repo: + break + if parent.is_symlink(): + raise ValueError("symlinked source parent") + + +def _unexpected_sources(repo, entries): + for relative in sorted(_source_roots(repo, entries)): + root = repo / relative + _check_source_parents(repo, root) + if root.is_symlink(): + raise ValueError("symlinked source directory") + if not root.exists(): + continue + for directory, children, filenames in os.walk(root, followlinks=False): + for name in children + filenames: + path = Path(directory) / name + relative_path = str(path.relative_to(repo)) + if path.is_symlink() and relative_path not in entries: + raise ValueError("unexpected source symlink") + if not path.is_dir() and relative_path not in entries: + raise ValueError(f"unexpected source-only file: {relative_path}") + # Root build scripts and JS discovery/configuration scripts are compile inputs. + for directory in [repo, *repo.glob("crates/*"), repo / "crates/trusted-server-js/lib"]: + if not directory.is_dir(): + continue + for path in directory.iterdir(): + if path.suffix in {".rs", ".mjs", ".cjs", ".js", ".ts"} or path.name in { + "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "package.json", "package-lock.json", + ".npmrc", + }: + if str(path.relative_to(repo)) not in entries: + raise ValueError("unexpected build source/configuration file") + + +def source_snapshot(repo, commit, inherited): + """Compare bytes, modes and links to the committed tree, ignoring index flags. + + Raise ValueError for mutations, unreviewed source additions, Git/Cargo input + channels or a revision that is not an actual approved full commit object. + """ + audit_environment(inherited) + _commit_id(commit) + repo = Path(repo).absolute() + if repo.is_symlink(): + raise ValueError("symlinked source checkout") + # OS aliases such as /var -> /private/var are outside the source boundary. + repo = repo.resolve() + _audit_configuration(repo, inherited) + if _git(repo, ["cat-file", "-t", commit]).strip() != b"commit": + raise ValueError("source revision must be an actual commit object") + epoch = int(_git(repo, ["show", "-s", "--format=%ct", commit]).strip()) + require_nonnegative_integer(epoch) + entries = {} + for record in _git(repo, ["ls-tree", "-rz", "--full-tree", commit]).split(b"\0"): + if not record: + continue + identity, filename = record.split(b"\t", 1) + mode, kind, object_id = identity.decode("ascii").split() + name = filename.decode("utf-8") + if kind != "blob" or mode not in {"100644", "100755", "120000"}: + raise ValueError("unsupported source tree type or mode") + if Path(name).is_absolute() or ".." in Path(name).parts: + raise ValueError("unsafe source tree path") + entries[name] = (mode, object_id) + _unexpected_sources(repo, entries) + payload = "".join(f"{object_id}\n" for _, object_id in entries.values()).encode("ascii") + blobs = io.BytesIO(_git(repo, ["cat-file", "--batch"], payload)) + files = {} + for name, (mode, object_id) in entries.items(): + header = blobs.readline().decode("ascii").strip().split() + if len(header) != 3 or header[0] != object_id or header[1] != "blob": + raise ValueError("unexpected committed source object") + expected = blobs.read(int(header[2])) + if blobs.read(1) != b"\n": + raise ValueError("incomplete committed source object") + path = repo / name + _check_source_parents(repo, path) + try: + information = path.lstat() + except FileNotFoundError as error: + raise ValueError("missing committed source file") from error + if mode == "120000": + if not stat.S_ISLNK(information.st_mode): + raise ValueError("source link mode changed") + actual = os.fsencode(os.readlink(path)) + if not path.resolve().is_relative_to(repo): + raise ValueError("source link escapes checkout") + else: + if not stat.S_ISREG(information.st_mode): + raise ValueError("source file mode changed") + expected_execute = mode == "100755" + if bool(information.st_mode & stat.S_IXUSR) != expected_execute: + raise ValueError("source executable mode changed") + actual = path.read_bytes() + if actual != expected: + raise ValueError(f"committed source bytes changed: {name}") + files[name] = { + "mode": mode, + "permissions": stat.S_IMODE(information.st_mode), + "sha256": sha256_bytes(actual), + } + if blobs.read(1): + raise ValueError("extra committed source objects") + return {"source_commit": commit, "source_date_epoch": epoch, "files": files} + + +def assert_source_unchanged(repo, commit, before, inherited): + """Repeat the committed-tree check after execution and reject any mutation.""" + if source_snapshot(repo, commit, inherited) != before: + raise ValueError("source changed during release execution") + + +def _validate_build_inputs(repo): + workspace = tomllib.loads((repo / "Cargo.toml").read_text(encoding="utf-8")) + release = workspace.get("profile", {}).get("release", {}) + if type(release.get("debug")) is not int or release["debug"] != 1: + raise ValueError("source release profile must retain debug=1") + toolchain = tomllib.loads((repo / "rust-toolchain.toml").read_text(encoding="utf-8")) + if toolchain.get("toolchain", {}).get("channel") != VERSIONS["rust"]: + raise ValueError("source Rust toolchain pin disagrees with release contract") + versions = {} + for line in (repo / ".tool-versions").read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + fields = line.split() + if len(fields) != 2 or fields[0] in versions: + raise ValueError("ambiguous source tool version pin") + versions[fields[0]] = fields[1] + for name, key in (("rust", "rust"), ("node", "nodejs"), ("fastly", "fastly"), + ("viceroy", "viceroy"), ("wasmtime", "wasmtime")): + if versions.get(key) != VERSIONS[name]: + raise ValueError("source tool pin disagrees with release contract") + for artifact in fixed_artifacts(): + manifest = tomllib.loads((repo / "crates" / artifact["package"] / "Cargo.toml").read_text(encoding="utf-8")) + if manifest.get("package", {}).get("name") != artifact["package"]: + raise ValueError("source package identity disagrees with release contract") + binaries = manifest.get("bin", []) + if binaries and not any(binary.get("name") == artifact["bin"] for binary in binaries): + raise ValueError("source binary identity disagrees with release contract") + if manifest.get("features", {}).get("default", []): + raise ValueError("source default features require release contract review") + + +def preflight(repo, commit, *, inherited=None, cohort_path=None, require_qualified=False): + """Validate source-owned inputs and report exact contract byte digests. + + Structural success is explicitly unqualified. Release callers must require + qualification; this gate fails before any preparation or signing is possible. + """ + inherited = dict(os.environ) if inherited is None else inherited + audit_environment(inherited) + _commit_id(commit) + repo = Path(repo).resolve() + selected_cohort = repo / "scripts/release/release-cohort.json" + if cohort_path is not None and Path(cohort_path).resolve() != selected_cohort: + raise ValueError("cohort must be the fixed source-owned contract path") + before = source_snapshot(repo, commit, inherited) + _validate_build_inputs(repo) + paths = ( + selected_cohort, + repo / "scripts/release/release-tools.json", + repo / "crates/trusted-server-cli/release-policy.json", + ) + for path in paths: + if str(path.relative_to(repo)) not in before["files"] or path.is_symlink(): + raise ValueError("release contracts must be tracked regular source files") + cohort, _, _ = load_contracts(*paths, require_qualified=require_qualified) + digests = {str(path.relative_to(repo)): sha256_bytes(path.read_bytes()) for path in paths} + assert_source_unchanged(repo, commit, before, inherited) + return { + "source_commit": commit, + "source_date_epoch": before["source_date_epoch"], + "cohort": cohort["cohort"], + "contract_sha256": digests, + "qualified": False, + "qualification": "pending-authenticated-acquisition-and-hosted-recipes", + } + + +def main(): + """Run the read-only preflight command, returning nonzero on any failure.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--cohort", type=Path, required=True) + parser.add_argument("--source-commit", required=True) + parser.add_argument("--require-qualified", action="store_true") + arguments = parser.parse_args() + try: + result = preflight( + arguments.repo, arguments.source_commit, cohort_path=arguments.cohort, + require_qualified=arguments.require_qualified, + ) + except (ValueError, OSError, subprocess.SubprocessError) as error: + logging.error("Release preflight fails: %s", error) + return 1 + sys.stdout.buffer.write(deterministic_json_bytes(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/release-cohort.json b/scripts/release/release-cohort.json new file mode 100644 index 0000000000..be477ccbb3 --- /dev/null +++ b/scripts/release/release-cohort.json @@ -0,0 +1,79 @@ +{ + "artifacts": [ + { + "assurance_profile": "provenance-sbom-repeatable", + "bin": "trusted-server-adapter-fastly", + "capabilities": ["fastly-production"], + "compatibility": { + "architecture": "wasm32", + "minimum_version": "wasip1", + "runtime": "fastly-compute" + }, + "default_features": true, + "features": [], + "kind": "wasm-core", + "name": "trusted-server-fastly.wasm", + "package": "trusted-server-adapter-fastly", + "role": "server", + "target": "wasm32-wasip1", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "aarch64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-aarch64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "aarch64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "13.0", + "runtime": "macos" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-apple-darwin", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-apple-darwin", + "variant": "default" + }, + { + "assurance_profile": "provenance-sbom", + "bin": "ts", + "capabilities": ["cli-execute"], + "compatibility": { + "architecture": "x86_64", + "minimum_version": "glibc-2.34", + "runtime": "linux-gnu" + }, + "default_features": true, + "features": [], + "kind": "native-executable", + "name": "ts-x86_64-unknown-linux-gnu", + "package": "trusted-server-cli", + "role": "operator-cli", + "target": "x86_64-unknown-linux-gnu", + "variant": "default" + } + ], + "cohort": "phase1-fastly-cli", + "eligible_branches": ["main"], + "schema_version": 1 +} diff --git a/scripts/release/release-tools.json b/scripts/release/release-tools.json new file mode 100644 index 0000000000..4ea243eef9 --- /dev/null +++ b/scripts/release/release-tools.json @@ -0,0 +1,345 @@ +{ + "environments": [ + { + "acquisition": null, + "id": "debian12-x86_64", + "image_sha256": null, + "qualification": "pending", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "macos15-aarch64", + "image_sha256": null, + "qualification": "pending", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "macos15-x86_64", + "image_sha256": null, + "qualification": "pending", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "ubi9-x86_64", + "image_sha256": null, + "qualification": "pending", + "sdk_sha256": null + }, + { + "acquisition": null, + "id": "ubuntu22.04-x86_64", + "image_sha256": null, + "qualification": "pending", + "sdk_sha256": null + } + ], + "recipes": [ + { + "id": "trusted-server-fastly.wasm", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_home": "private-prepared", + "cargo_locked": true, + "fresh_target": true, + "network_boundary": "qualification-pending", + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "rust_toolchain": "1.95.0", + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production" + }, + "sha256": null + }, + { + "id": "ts-aarch64-apple-darwin", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_home": "private-prepared", + "cargo_locked": true, + "fresh_target": true, + "network_boundary": "qualification-pending", + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "rust_toolchain": "1.95.0", + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production" + }, + "sha256": null + }, + { + "id": "ts-x86_64-apple-darwin", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_home": "private-prepared", + "cargo_locked": true, + "fresh_target": true, + "network_boundary": "qualification-pending", + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "rust_toolchain": "1.95.0", + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production" + }, + "sha256": null + }, + { + "id": "ts-x86_64-unknown-linux-gnu", + "qualification": "pending", + "script": "scripts/release/build.sh", + "settings": { + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_home": "private-prepared", + "cargo_locked": true, + "fresh_target": true, + "network_boundary": "qualification-pending", + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "rust_toolchain": "1.95.0", + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production" + }, + "sha256": null + }, + { + "id": "tsjs", + "qualification": "pending", + "script": "scripts/release/prepare-js.mjs", + "settings": { + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_home": "private-prepared", + "cargo_locked": true, + "fresh_target": true, + "network_boundary": "qualification-pending", + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "rust_toolchain": "1.95.0", + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production" + }, + "sha256": null + } + ], + "schema_version": 1, + "tools": [ + { + "acquisition": null, + "name": "actions/attest:github-action", + "qualification": "pending", + "revision": "1e69f48acb82d1966a394da916b4c1698aa569d6", + "sha256": null, + "version": "4.2.2" + }, + { + "acquisition": null, + "name": "fastly:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "15.1.0" + }, + { + "acquisition": null, + "name": "fastly:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "15.1.0" + }, + { + "acquisition": null, + "name": "fastly:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "15.1.0" + }, + { + "acquisition": null, + "name": "gh:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "2.97.0" + }, + { + "acquisition": null, + "name": "gh:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "2.97.0" + }, + { + "acquisition": null, + "name": "gh:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "2.97.0" + }, + { + "acquisition": null, + "name": "node:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "24.12.0" + }, + { + "acquisition": null, + "name": "node:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "24.12.0" + }, + { + "acquisition": null, + "name": "node:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "24.12.0" + }, + { + "acquisition": null, + "name": "npm:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "11.6.2" + }, + { + "acquisition": null, + "name": "npm:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "11.6.2" + }, + { + "acquisition": null, + "name": "npm:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "11.6.2" + }, + { + "acquisition": null, + "name": "rust:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "1.95.0" + }, + { + "acquisition": null, + "name": "rust:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "1.95.0" + }, + { + "acquisition": null, + "name": "rust:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "1.95.0" + }, + { + "acquisition": null, + "name": "viceroy:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "0.17.0" + }, + { + "acquisition": null, + "name": "viceroy:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "0.17.0" + }, + { + "acquisition": null, + "name": "viceroy:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "0.17.0" + }, + { + "acquisition": null, + "name": "wasmtime:aarch64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "44.0.1" + }, + { + "acquisition": null, + "name": "wasmtime:x86_64-apple-darwin", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "44.0.1" + }, + { + "acquisition": null, + "name": "wasmtime:x86_64-unknown-linux-gnu", + "qualification": "pending", + "revision": null, + "sha256": null, + "version": "44.0.1" + } + ] +} diff --git a/scripts/release/tests/test_preflight.py b/scripts/release/tests/test_preflight.py new file mode 100644 index 0000000000..5ef30f7a55 --- /dev/null +++ b/scripts/release/tests/test_preflight.py @@ -0,0 +1,457 @@ +"""Fail-closed release input checks, using isolated fictional source trees.""" + +import copy +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +ROOT = RELEASE.parents[1] +sys.path.insert(0, str(RELEASE)) +if importlib.util.find_spec("preflight") is not None: + import preflight +else: + preflight = None + + +class ContractTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(preflight, "should provide release preflight implementation") + self.cohort = json.loads((RELEASE / "release-cohort.json").read_bytes()) + self.tools = json.loads((RELEASE / "release-tools.json").read_bytes()) + self.policy = json.loads( + (ROOT / "crates/trusted-server-cli/release-policy.json").read_bytes() + ) + + def test_fixed_cohort_accepts_four_exact_artifacts(self): + preflight.validate_cohort(self.cohort) + self.assertEqual(len(self.cohort["artifacts"]), 4, "should fix four artifacts") + + def test_rejects_missing_extra_and_duplicate_artifacts(self): + for artifacts in ( + self.cohort["artifacts"][:-1], + self.cohort["artifacts"] + [self.cohort["artifacts"][0]], + [self.cohort["artifacts"][0]] * 4, + ): + with self.subTest(artifacts=artifacts): + self.cohort["artifacts"] = artifacts + with self.assertRaises(ValueError): + preflight.validate_cohort(self.cohort) + + def test_rejects_downgrade_target_feature_and_commands(self): + for field, value in ( + ("assurance_profile", "provenance-sbom"), + ("target", "wasm32-unknown-unknown"), + ("features", ["reusable-sandbox"]), + ("command", "example-command"), + ): + with self.subTest(field=field): + changed = copy.deepcopy(self.cohort) + fastly = next(a for a in changed["artifacts"] if a["role"] == "server") + fastly[field] = value + with self.assertRaises(ValueError): + preflight.validate_cohort(changed) + + def test_branches_are_exact_sorted_and_reviewed(self): + for branches in (["*"], ["main", "main"], ["rc/*"], ["rc/202613"], ["dev"]): + with self.subTest(branches=branches): + self.cohort["eligible_branches"] = branches + with self.assertRaises(ValueError): + preflight.validate_cohort(self.cohort) + self.cohort["eligible_branches"] = ["main", "rc/202610"] + preflight.validate_cohort(self.cohort) + + def test_pending_tools_are_structurally_valid_but_never_qualified(self): + preflight.validate_tools(self.tools) + with self.assertRaisesRegex(ValueError, "qualif"): + preflight.validate_tools(self.tools, require_qualified=True) + for record in self.tools["tools"]: + record["sha256"] = "a" * 64 + with self.assertRaisesRegex(ValueError, "qualif"): + preflight.validate_tools(self.tools, require_qualified=True) + + def test_rejects_missing_pins_unknown_version_and_unreviewed_recipe(self): + for change in ("missing", "version", "command", "vite", "sqlite"): + with self.subTest(change=change): + changed = copy.deepcopy(self.tools) + if change == "missing": + changed["tools"].pop() + elif change == "version": + changed["tools"][0]["version"] = "999.0.0" + elif change == "command": + changed["recipes"][0]["command"] = "example-command" + elif change == "vite": + changed["recipes"][0]["settings"]["vite_env_loading"] = True + else: + changed["recipes"][0]["settings"]["sqlite_features"] = ["sqlcipher"] + with self.assertRaises(ValueError): + preflight.validate_tools(changed) + + def test_policy_fixes_identity_bounds_actions_and_has_no_self_hash(self): + preflight.validate_policy(self.policy) + self.assertNotIn("policy_sha256", self.policy, "should avoid self-hash cycles") + for field in ("bounds", "identity", "artifacts", "tool_versions"): + changed = copy.deepcopy(self.policy) + changed[field] = {} + with self.subTest(field=field): + with self.assertRaises(ValueError): + preflight.validate_policy(changed) + + def test_rejects_wrong_types_unsorted_arrays_and_unknown_contract_fields(self): + for document in (self.cohort, self.tools, self.policy): + validate = (preflight.validate_cohort if document is self.cohort else + preflight.validate_tools if document is self.tools else preflight.validate_policy) + changed = copy.deepcopy(document) + changed["unexpected"] = None + with self.assertRaises(ValueError): + validate(changed) + for field in ("schema_version",): + self.cohort[field] = True + with self.assertRaises(ValueError): + preflight.validate_cohort(self.cohort) + self.tools["tools"].reverse() + with self.assertRaises(ValueError): + preflight.validate_tools(self.tools) + + +class EnvironmentTests(unittest.TestCase): + def setUp(self): + self.assertTrue(hasattr(preflight, "audit_environment"), "should audit inherited inputs") + + def test_skip_presence_is_rejected_for_every_value(self): + for value in ("", "0", "1"): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "TSJS_SKIP_BUILD"): + preflight.audit_environment({"TSJS_SKIP_BUILD": value}) + + def test_rejects_compiler_cargo_node_native_and_loader_channels(self): + for name in ( + "RUSTC", "RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER", "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", "CARGO_PROFILE_RELEASE_DEBUG", "CARGO_HOME", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER", "RUSTUP_TOOLCHAIN", + "NODE_OPTIONS", "npm_config_userconfig", "VITE_MODE", "CC", "TARGET_CC", + "TSJS_PREBUILT_DIR", "TSJS_PREBUILT_INVENTORY", "TSJS_RELEASE_MODE", + "CC_x86_64_unknown_linux_gnu", "x86_64_unknown_linux_gnu_OPENSSL_DIR", + "CFLAGS", "LDFLAGS", "SDKROOT", "SYSROOT", "RUST_LIB_SRC", + "AWS_LC_SYS_USE_SYSTEM", "AWS_LC_SYS_USE_SYSTEM_x86_64_unknown_linux_gnu", + "AWS_LC_SYS_SYSTEM_DIR", "AWS_LC_SYS_SKIP_VERSION_CHECK", "EFFECTIVE_TARGET", + "LIBSQLITE3_SYS_USE_PKG_CONFIG", "SQLITE3_LIB_DIR", "SQLCIPHER_LIB_DIR", + "PKG_CONFIG_PATH", "VCPKG_ROOT", "GIT_CONFIG_COUNT", "GIT_REPLACE_REF_BASE", + "PYTHONPATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES", "BASH_ENV", "ENV", + ): + with self.subTest(name=name): + with self.assertRaisesRegex(ValueError, name): + preflight.audit_environment({name: "example-value"}) + + def test_fresh_environment_removes_operator_and_provider_values(self): + with tempfile.TemporaryDirectory() as temporary: + paths = {key: temporary for key in ( + "home", "cargo_home", "rustup_home", "tmpdir", "sysroot" + )} + paths["tool_path"] = [temporary] + inherited = { + "HOME": temporary, "PATH": "/example/ambient-tools", + "TRUSTED_SERVER__PUBLISHER__NAME": "example publisher", + "TRUSTED_SERVER__RELEASE_BUILD": "1", "EDGEZERO__CONFIG": "example", + "GH_TOKEN": "example-token", "FASTLY_API_TOKEN": "example-token", + "AWS_ACCESS_KEY_ID": "example-token", "UNREVIEWED_VALUE": "example", + } + result = preflight.build_environment( + inherited, "a" * 40, 1234567890, "x86_64-unknown-linux-gnu", paths + ) + self.assertEqual(result["SOURCE_DATE_EPOCH"], "1234567890", "should fix epoch") + self.assertIn("TRUSTED_SERVER__GIT_VERSION", result, + "should inject the approved Git version channel") + self.assertEqual(result["TRUSTED_SERVER__GIT_VERSION"], "a" * 40, + "should inject full approved commit") + self.assertNotIn("TRUSTED_SERVER__SOURCE_COMMIT", result, + "should use only the approved Git version channel") + self.assertEqual(result["TRUSTED_SERVER__RELEASE_BUILD"], "1", "should enable facts") + self.assertEqual(result["AWS_LC_SYS_USE_SYSTEM"], "0", "should choose bundled AWS-LC") + self.assertEqual(result["AWS_LC_SYS_USE_SYSTEM_x86_64_unknown_linux_gnu"], "0", + "should select the stronger target-specific AWS-LC variable") + self.assertEqual(result["LIBSQLITE3_SYS_USE_PKG_CONFIG"], "0", "should choose bundled SQLite") + self.assertEqual(result["NODE_ENV"], "production", "should select production mode") + for name in inherited.keys() - {"HOME", "PATH", "TRUSTED_SERVER__RELEASE_BUILD"}: + self.assertNotIn(name, result, "should remove unreviewed inherited values") + + def test_release_control_is_strict_before_publisher_removal(self): + for value in ("", "0", "true", "yes", " 1", "1\n"): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "RELEASE_BUILD"): + preflight.audit_environment({"TRUSTED_SERVER__RELEASE_BUILD": value}) + preflight.audit_environment({"TRUSTED_SERVER__RELEASE_BUILD": "1"}) + + def test_environment_rejects_skip_before_sanitation_and_invalid_facts(self): + with self.assertRaisesRegex(ValueError, "TSJS_SKIP_BUILD"): + preflight.build_environment({"TSJS_SKIP_BUILD": "0"}, "a" * 40, 1, + "wasm32-wasip1", {}) + for commit, epoch, target in (("HEAD", 1, "wasm32-wasip1"), + ("a" * 40, True, "wasm32-wasip1"), + ("a" * 40, 1, "unknown")): + with self.subTest(commit=commit, epoch=epoch, target=target): + with self.assertRaises(ValueError): + preflight.build_environment({}, commit, epoch, target, {}) + + +class SourceTests(unittest.TestCase): + def setUp(self): + self.assertTrue(hasattr(preflight, "source_snapshot"), "should verify committed source bytes") + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.repo = Path(self.temporary.name) / "checkout" + self.repo.mkdir() + self.environment = { + "HOME": self.temporary.name, "PATH": "/usr/bin:/bin", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Example Author", "GIT_AUTHOR_EMAIL": "author@example.com", + "GIT_COMMITTER_NAME": "Example Author", "GIT_COMMITTER_EMAIL": "author@example.com", + "GIT_AUTHOR_DATE": "2009-02-13T23:31:30Z", + "GIT_COMMITTER_DATE": "2009-02-13T23:31:30Z", + } + self.git("init", "-q") + for name, content in { + "AGENTS.md": "Example instructions\n", + "Cargo.toml": "[profile.release]\ndebug = 1\n", + ".cargo/config.toml": "[alias]\nexample = 'check'\n", + ".gitignore": "trusted-server.toml\n*.ignored\n", + "crates/example/src/lib.rs": "// Example source\n", + "crates/trusted-server-core/build_support/example.rs": "// Example build support\n", + "crates/trusted-server-js/lib/src/core/index.ts": "export const example = 1;\n", + "scripts/release/example.py": "# Example release source\n", + }.items(): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + (self.repo / "CLAUDE.md").symlink_to("AGENTS.md") + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "Add example source") + self.commit = self.git("rev-parse", "HEAD").decode().strip() + + def git(self, *arguments): + return subprocess.run( + ["/usr/bin/git", "-C", str(self.repo), *arguments], + env=self.environment, check=True, capture_output=True, + ).stdout + + def test_clean_source_epoch_and_legitimate_tracked_link(self): + snapshot = preflight.source_snapshot(self.repo, self.commit, {}) + self.assertEqual(snapshot["source_date_epoch"], 1234567890, + "should derive epoch from approved committer timestamp") + self.assertEqual(snapshot["files"]["CLAUDE.md"]["mode"], "120000", + "should retain the exact tracked link mode") + preflight.assert_source_unchanged(self.repo, self.commit, snapshot, {}) + + def test_hidden_dirty_bytes_and_deletions_are_rejected(self): + source = "crates/example/src/lib.rs" + for flag in ("--assume-unchanged", "--skip-worktree"): + with self.subTest(flag=flag): + self.git("update-index", flag, source) + (self.repo / source).write_text("// Unexpected mutation\n") + with self.assertRaisesRegex(ValueError, "source"): + preflight.source_snapshot(self.repo, self.commit, {}) + (self.repo / source).write_text("// Example source\n") + (self.repo / source).unlink() + with self.assertRaises(ValueError): + preflight.source_snapshot(self.repo, self.commit, {}) + + def test_mode_and_link_mutation_are_rejected(self): + source = self.repo / "crates/example/src/lib.rs" + source.chmod(0o755) + with self.assertRaisesRegex(ValueError, "mode"): + preflight.source_snapshot(self.repo, self.commit, {}) + source.chmod(0o644) + link = self.repo / "CLAUDE.md" + link.unlink() + link.symlink_to("Cargo.toml") + with self.assertRaisesRegex(ValueError, "source"): + preflight.source_snapshot(self.repo, self.commit, {}) + + def test_executable_umask_variation_and_post_execution_permissions(self): + executable = self.repo / "scripts/release/example.py" + executable.chmod(0o755) + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "Make example executable") + self.commit = self.git("rev-parse", "HEAD").decode().strip() + for permission in (0o755, 0o750): + with self.subTest(permission=permission): + executable.chmod(permission) + snapshot = preflight.source_snapshot(self.repo, self.commit, {}) + self.assertEqual(snapshot["files"]["scripts/release/example.py"]["permissions"], permission, + "should capture actual permissions for the selected umask") + preflight.assert_source_unchanged(self.repo, self.commit, snapshot, {}) + executable.chmod(permission ^ 0o010) + with self.assertRaisesRegex(ValueError, "source changed"): + preflight.assert_source_unchanged(self.repo, self.commit, snapshot, {}) + executable.chmod(0o644) + with self.assertRaisesRegex(ValueError, "mode"): + preflight.source_snapshot(self.repo, self.commit, {}) + + def test_ignored_unexpected_source_files_are_rejected(self): + for directory in ( + "crates/example/src", "crates/trusted-server-core/build_support", + "crates/trusted-server-js/lib/src", "scripts/release", ".cargo", + ): + with self.subTest(directory=directory): + unexpected = self.repo / directory / "example.ignored" + unexpected.write_text("Unexpected source\n") + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.source_snapshot(self.repo, self.commit, {}) + unexpected.unlink() + + def test_symlinked_source_parent_and_external_link_are_rejected(self): + source_directory = self.repo / "crates/example/src" + saved = self.repo / "crates/example/saved" + source_directory.rename(saved) + source_directory.symlink_to(saved, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + preflight.source_snapshot(self.repo, self.commit, {}) + source_directory.unlink() + saved.rename(source_directory) + (source_directory / "example.ignored").symlink_to("/example/outside") + with self.assertRaises(ValueError): + preflight.source_snapshot(self.repo, self.commit, {}) + + def test_post_execution_mutation_and_new_source_are_rejected(self): + snapshot = preflight.source_snapshot(self.repo, self.commit, {}) + unexpected = self.repo / "scripts/release/new.ignored" + unexpected.write_text("Unexpected source\n") + with self.assertRaises(ValueError): + preflight.assert_source_unchanged(self.repo, self.commit, snapshot, {}) + unexpected.unlink() + (self.repo / "crates/example/src/lib.rs").write_text("// Unexpected source\n") + with self.assertRaises(ValueError): + preflight.assert_source_unchanged(self.repo, self.commit, snapshot, {}) + + def test_fictional_operator_config_is_independent_and_preserved(self): + snapshot = preflight.source_snapshot(self.repo, self.commit, {}) + operator = self.repo / "trusted-server.toml" + operator.write_text('[publisher]\nname = "Example Publisher"\n') + self.assertEqual(preflight.source_snapshot(self.repo, self.commit, {}), snapshot, + "should exclude operator files from compile inputs") + self.assertTrue(operator.exists(), "should preserve operator configuration") + + def test_cargo_config_layers_and_git_replacements_are_rejected(self): + ancestor = Path(self.temporary.name) / ".cargo" + ancestor.mkdir() + config = ancestor / "config.toml" + config.write_text("[build]\nrustflags = ['--cfg=example']\n") + with self.assertRaisesRegex(ValueError, "Cargo"): + preflight.source_snapshot(self.repo, self.commit, {"HOME": self.temporary.name}) + config.unlink() + self.git("config", "include.path", str(self.repo / "example-config")) + with self.assertRaisesRegex(ValueError, "Git"): + preflight.source_snapshot(self.repo, self.commit, {}) + self.git("config", "--unset", "include.path") + self.git("update-ref", "refs/replace/" + "a" * 40, self.commit) + with self.assertRaisesRegex(ValueError, "replacement"): + preflight.source_snapshot(self.repo, self.commit, {}) + + def test_approved_revision_is_a_full_actual_commit(self): + blob = self.git("rev-parse", f"{self.commit}:Cargo.toml").decode().strip() + for revision in ("HEAD", self.commit.upper(), blob): + with self.subTest(revision=revision): + with self.assertRaises(ValueError): + preflight.source_snapshot(self.repo, revision, {}) + + +class PreflightTests(unittest.TestCase): + git = SourceTests.git + + def setUp(self): + SourceTests.setUp(self) + self.assertTrue(hasattr(preflight, "preflight"), "should expose complete preflight") + for relative in ( + "scripts/release/release-cohort.json", "scripts/release/release-tools.json", + "crates/trusted-server-cli/release-policy.json", "scripts/release/preflight.py", + "scripts/release/common.py", + ): + destination = self.repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes((ROOT / relative).read_bytes()) + for relative in ("rust-toolchain.toml", ".tool-versions", + "crates/trusted-server-adapter-fastly/Cargo.toml", + "crates/trusted-server-cli/Cargo.toml"): + destination = self.repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes((ROOT / relative).read_bytes()) + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "Add example contracts") + self.commit = self.git("rev-parse", "HEAD").decode().strip() + + def test_preflight_hashes_raw_bytes_and_reports_pending_qualification(self): + result = preflight.preflight(self.repo, self.commit, inherited={}) + self.assertEqual(result["source_commit"], self.commit, "should retain source approval") + self.assertEqual(result["source_date_epoch"], 1234567890, "should use approved epoch") + self.assertFalse(result["qualified"], "should report pending qualification") + for relative, digest in result["contract_sha256"].items(): + self.assertEqual(digest, hashlib.sha256((self.repo / relative).read_bytes()).hexdigest(), + "should bind exact raw contract bytes") + + def test_preflight_fails_qualification_and_audits_skip_before_contract_loading(self): + with self.assertRaisesRegex(ValueError, "qualification"): + preflight.preflight(self.repo, self.commit, inherited={}, require_qualified=True) + (self.repo / "scripts/release/release-cohort.json").unlink() + with self.assertRaisesRegex(ValueError, "TSJS_SKIP_BUILD"): + preflight.preflight(self.repo, self.commit, inherited={"TSJS_SKIP_BUILD": ""}) + + def test_preflight_rejects_external_contract_and_compiler_cargo_config(self): + with self.assertRaisesRegex(ValueError, "source-owned"): + preflight.preflight(self.repo, self.commit, inherited={}, + cohort_path=Path(self.temporary.name) / "cohort.json") + (self.repo / ".cargo/config.toml").write_text("[build]\nrustflags = ['--cfg=example']\n") + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "Change example config") + self.commit = self.git("rev-parse", "HEAD").decode().strip() + with self.assertRaisesRegex(ValueError, "Cargo"): + preflight.preflight(self.repo, self.commit, inherited={}) + + def test_cli_fails_before_preparation_for_unqualified_contract(self): + result = subprocess.run( + [sys.executable, "-B", str(RELEASE / "preflight.py"), + "--repo", str(self.repo), "--cohort", str(self.repo / "scripts/release/release-cohort.json"), + "--source-commit", self.commit, "--require-qualified"], + env={"PATH": "/usr/bin:/bin"}, capture_output=True, check=False, + ) + self.assertNotEqual(result.returncode, 0, "should fail incomplete release contracts") + self.assertIn(b"qualification", result.stderr, "should explain the missing real qualification") + self.assertEqual(result.stdout, b"", "should produce no successful release output") + self.assertFalse((self.repo / "target").exists(), "should not prepare or build before qualification") + + def test_committed_build_profile_and_tool_pins_cannot_drift(self): + for relative, content in ( + ("Cargo.toml", "[profile.release]\ndebug = 0\n"), + ("rust-toolchain.toml", "[toolchain]\nchannel = '999.0.0'\n"), + (".tool-versions", "nodejs 999.0.0\n"), + ): + with self.subTest(relative=relative): + path = self.repo / relative + original = path.read_bytes() + path.write_text(content) + self.git("add", ".") + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "Change example build pin") + commit = self.git("rev-parse", "HEAD").decode().strip() + with self.assertRaises(ValueError): + preflight.preflight(self.repo, commit, inherited={}) + path.write_bytes(original) + + def test_plain_cli_does_not_generate_source_bytecode(self): + result = subprocess.run( + [sys.executable, str(self.repo / "scripts/release/preflight.py"), + "--repo", str(self.repo), "--cohort", str(self.repo / "scripts/release/release-cohort.json"), + "--source-commit", self.commit], + env={"PATH": "/usr/bin:/bin"}, capture_output=True, check=False, + ) + self.assertEqual(result.returncode, 0, "should permit explicit structural checks") + self.assertFalse((self.repo / "scripts/release/__pycache__").exists(), + "should not mutate the source with caches") + + +if __name__ == "__main__": + unittest.main() From 31ee0739867533eabb5696f9f3ada166a13be84b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:44:35 +0530 Subject: [PATCH 015/153] Require the SPDX 2.3 predicate in release policy --- crates/trusted-server-cli/release-policy.json | 2 +- scripts/release/preflight.py | 2 +- scripts/release/tests/test_common.py | 2 +- scripts/release/tests/test_preflight.py | 25 +++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-cli/release-policy.json b/crates/trusted-server-cli/release-policy.json index 35c9017875..4aa1115dfa 100644 --- a/crates/trusted-server-cli/release-policy.json +++ b/crates/trusted-server-cli/release-policy.json @@ -126,7 +126,7 @@ "predicates": { "build_type": "https://actions.github.io/buildtypes/workflow/v1", "provenance": "https://slsa.dev/provenance/v1", - "sbom": "https://spdx.dev/Document" + "sbom": "https://spdx.dev/Document/v2.3" }, "public_actions": ["readonly-verify"], "repository": "IABTechLab/trusted-server", diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py index 8284304e96..58bcd6a7d9 100644 --- a/scripts/release/preflight.py +++ b/scripts/release/preflight.py @@ -187,7 +187,7 @@ def fixed_policy(): "predicates": { "provenance": "https://slsa.dev/provenance/v1", "build_type": "https://actions.github.io/buildtypes/workflow/v1", - "sbom": "https://spdx.dev/Document", + "sbom": "https://spdx.dev/Document/v2.3", }, "tool_versions": { "gh": "2.97.0", diff --git a/scripts/release/tests/test_common.py b/scripts/release/tests/test_common.py index e9ffbbc3f3..f7bc8cfc45 100644 --- a/scripts/release/tests/test_common.py +++ b/scripts/release/tests/test_common.py @@ -314,7 +314,7 @@ def test_serialization_rejects_cycles_promptly(self): """ try: result = subprocess.run( - [sys.executable, "-c", source, str(Path(common.__file__).parent)], + [sys.executable, "-B", "-c", source, str(Path(common.__file__).parent)], capture_output=True, text=True, timeout=2, check=False, ) except subprocess.TimeoutExpired: diff --git a/scripts/release/tests/test_preflight.py b/scripts/release/tests/test_preflight.py index 5ef30f7a55..7ff90564c6 100644 --- a/scripts/release/tests/test_preflight.py +++ b/scripts/release/tests/test_preflight.py @@ -103,6 +103,31 @@ def test_policy_fixes_identity_bounds_actions_and_has_no_self_hash(self): with self.assertRaises(ValueError): preflight.validate_policy(changed) + def test_policy_accepts_independent_specification_spdx23_predicate(self): + specification = (ROOT / "docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md").read_text() + expected = next( + line.split("--predicate-type ", 1)[1].split()[0] + for line in specification.splitlines() + if "--predicate-type " in line and "/Document/v2.3" in line + ) + self.assertEqual(self.policy["predicates"]["sbom"], expected, + "should match the independently specified SPDX 2.3 identity") + changed = copy.deepcopy(self.policy) + changed["predicates"]["sbom"] = expected + preflight.validate_policy(changed) + + def test_policy_rejects_unversioned_spdx_predicate(self): + specification = (ROOT / "docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md").read_text() + expected = next( + line.split("--predicate-type ", 1)[1].split()[0] + for line in specification.splitlines() + if "--predicate-type " in line and "/Document/v2.3" in line + ) + changed = copy.deepcopy(self.policy) + changed["predicates"]["sbom"] = expected.removesuffix("/v2.3") + with self.assertRaises(ValueError): + preflight.validate_policy(changed) + def test_rejects_wrong_types_unsorted_arrays_and_unknown_contract_fields(self): for document in (self.cohort, self.tools, self.policy): validate = (preflight.validate_cohort if document is self.cohort else From b89552d1e818194413c2b6dac4cdaad80182b9d2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 19:56:59 +0530 Subject: [PATCH 016/153] Check discovered test sources and nested Cargo overrides --- scripts/release/preflight.py | 16 ++++-- scripts/release/tests/test_preflight.py | 69 ++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py index 58bcd6a7d9..36f063cf4b 100644 --- a/scripts/release/preflight.py +++ b/scripts/release/preflight.py @@ -21,6 +21,7 @@ from common import deterministic_json_bytes, read_json_file, require_nonnegative_integer, sha256_bytes LIMIT = 1048576 +SOURCE_DIRECTORIES = ("src", "build_support", "tests", "benches", "examples") PLATFORMS = ( "aarch64-apple-darwin", "x86_64-apple-darwin", @@ -434,6 +435,15 @@ def _audit_configuration(repo, inherited): for filename in ("config", "config.toml"): if os.path.lexists(location / ".cargo" / filename): raise ValueError("unreviewed ancestor/home Cargo configuration") + crate_locations = [repo / "crates", repo / "crates/trusted-server-js/lib"] + crate_locations.extend(path for path in repo.glob("crates/*") if path.is_dir()) + for location in crate_locations: + configuration_directory = location / ".cargo" + if configuration_directory.is_symlink(): + raise ValueError("unsupported nested Cargo configuration symlink") + for filename in ("config", "config.toml"): + if os.path.lexists(configuration_directory / filename): + raise ValueError("unsupported nested workspace Cargo configuration layer") if os.path.lexists(repo / ".cargo/config"): raise ValueError("unreviewed legacy Cargo configuration layer") selected = repo / ".cargo/config.toml" @@ -455,11 +465,11 @@ def _audit_configuration(repo, inherited): def _source_roots(repo, entries): - roots = {".cargo", "scripts/release"} + roots = {".cargo", "scripts/release", "crates/trusted-server-js/lib/test"} for name in entries: parts = Path(name).parts if parts[0] == "crates": - for directory in ("src", "build_support"): + for directory in SOURCE_DIRECTORIES: if directory in parts: roots.add(str(Path(*parts[:parts.index(directory) + 1]))) crates = repo / "crates" @@ -470,7 +480,7 @@ def _source_roots(repo, entries): if crate.is_symlink(): raise ValueError("symlinked source crate") if crate.is_dir(): - for directory in ("src", "build_support"): + for directory in SOURCE_DIRECTORIES: if os.path.lexists(crate / directory): roots.add(str((crate / directory).relative_to(repo))) return roots diff --git a/scripts/release/tests/test_preflight.py b/scripts/release/tests/test_preflight.py index 7ff90564c6..2cfc2eb2b8 100644 --- a/scripts/release/tests/test_preflight.py +++ b/scripts/release/tests/test_preflight.py @@ -13,6 +13,12 @@ RELEASE = Path(__file__).resolve().parents[1] ROOT = RELEASE.parents[1] +DISCOVERED_TEST_SOURCES = ( + "crates/trusted-server-js/lib/test/extra.test.ts", + "crates/example/tests/extra.rs", + "crates/example/benches/extra.rs", + "crates/example/examples/extra.rs", +) sys.path.insert(0, str(RELEASE)) if importlib.util.find_spec("preflight") is not None: import preflight @@ -245,7 +251,7 @@ def setUp(self): "AGENTS.md": "Example instructions\n", "Cargo.toml": "[profile.release]\ndebug = 1\n", ".cargo/config.toml": "[alias]\nexample = 'check'\n", - ".gitignore": "trusted-server.toml\n*.ignored\n", + ".gitignore": "trusted-server.toml\n*.ignored\nextra.test.ts\nextra.rs\n", "crates/example/src/lib.rs": "// Example source\n", "crates/trusted-server-core/build_support/example.rs": "// Example build support\n", "crates/trusted-server-js/lib/src/core/index.ts": "export const example = 1;\n", @@ -330,6 +336,67 @@ def test_ignored_unexpected_source_files_are_rejected(self): preflight.source_snapshot(self.repo, self.commit, {}) unexpected.unlink() + def test_ignored_discovered_test_sources_are_rejected_before_execution(self): + for relative in DISCOVERED_TEST_SOURCES: + with self.subTest(relative=relative): + unexpected = self.repo / relative + unexpected.parent.mkdir(parents=True, exist_ok=True) + unexpected.write_text("// Example unexpected executable test source\n") + self.assertTrue(self.git("check-ignore", relative).strip(), + "should exercise ignored executable test inputs") + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.source_snapshot(self.repo, self.commit, {}) + unexpected.unlink() + + def test_discovered_test_source_additions_are_rejected_after_execution(self): + before = preflight.source_snapshot(self.repo, self.commit, {}) + for relative in DISCOVERED_TEST_SOURCES: + with self.subTest(relative=relative): + unexpected = self.repo / relative + unexpected.parent.mkdir(parents=True, exist_ok=True) + unexpected.write_text("// Example unexpected executable test source\n") + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.assert_source_unchanged(self.repo, self.commit, before, {}) + unexpected.unlink() + + def test_discovered_test_source_directory_symlinks_are_rejected(self): + for relative in DISCOVERED_TEST_SOURCES: + with self.subTest(relative=relative): + directory = (self.repo / relative).parent + directory.parent.mkdir(parents=True, exist_ok=True) + directory.symlink_to(self.repo / "crates/example/src", target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + preflight.source_snapshot(self.repo, self.commit, {}) + directory.unlink() + + def test_nested_crate_cargo_configuration_layers_are_rejected(self): + for filename in ("config", "config.toml"): + with self.subTest(filename=filename): + directory = self.repo / "crates/example/.cargo" + directory.mkdir(parents=True, exist_ok=True) + config = directory / filename + config.write_text("[build]\nrustflags = ['--cfg=example']\n") + with self.assertRaisesRegex(ValueError, "Cargo"): + preflight.source_snapshot(self.repo, self.commit, {}) + config.unlink() + + def test_nested_crate_cargo_configuration_symlinks_are_rejected(self): + directory = self.repo / "crates/example/.cargo" + directory.symlink_to(self.repo / ".cargo", target_is_directory=True) + with self.assertRaisesRegex(ValueError, "Cargo|symlink"): + preflight.source_snapshot(self.repo, self.commit, {}) + + def test_download_and_output_directories_are_outside_source_roots(self): + before = preflight.source_snapshot(self.repo, self.commit, {}) + for relative in ( + "target/example.ignored", "crates/example/target/example.ignored", + "crates/trusted-server-js/lib/node_modules/example/example.ignored", + ): + destination = self.repo / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("Example generated or downloaded output\n") + preflight.assert_source_unchanged(self.repo, self.commit, before, {}) + def test_symlinked_source_parent_and_external_link_are_rejected(self): source_directory = self.repo / "crates/example/src" saved = self.repo / "crates/example/saved" From 7dd91cee78c607cf19f03320659a17d6caf8f99a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 20:04:29 +0530 Subject: [PATCH 017/153] Record preflight verification and review --- ...2026-10-13-attested-release-implementation.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index f9848562ee..585d279ea9 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -93,13 +93,13 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** `scripts/release/release-cohort.json`, `release-tools.json`, `preflight.py`, `tests/test_preflight.py`, `crates/trusted-server-cli/release-policy.json`. -- [ ] Write cases for missing/extra artifacts, profiles changed after failure, unknown target/feature, missing pins and every skip value (`""`, `"0"`, `"1"`). Add wrapper, Cargo config, NODE_OPTIONS, native selector and source mutation cases. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_preflight.py' -v`; expect missing module/behavior failures. -- [ ] Create the tracked consumer/producer policy bytes now, with the spec's concrete fixed identity/profile/action/bounds fields, so recipe/index assembly can bind their source identity before signing. Runtime enforcement is implemented in Tasks 17–20; this source-controlled contract is not a downloadable policy override. -- [ ] Map the four asset IDs to fixed package/bin/target/features. Never accept shell commands from JSON. Pin Rust/Node/npm, gh 2.97.0, attest 4.2.2/full SHA and Fastly 15.1.0; populate actual binary/image/SDK digests only from authenticated acquisition and Task 8 qualification. Missing qualified pins fail `--require-qualified`; no dummy digest is accepted. -- [ ] Inspect the inherited environment before clearing it. Reject skip presence and unreviewed override channels; create a fresh allowlisted child environment. Remove publisher prefixes and inject only approved commit/control facts. Derive epoch from the approved Git commit's committer timestamp. Check tracked bytes plus unexpected source-only files, modes and links before/after execution. -- [ ] Select both generic and target-suffixed AWS-LC selectors, bundled SQLite, Cargo config/home layers, sysroot/tool paths and Vite mode. Set `AWS_LC_SYS_USE_SYSTEM=0`, `LIBSQLITE3_SYS_USE_PKG_CONFIG=0`; reject alternate selectors rather than merely setting weaker generic defaults. -- [ ] Re-run fixtures, including a fictional operator-config independence case. Commit `Define fixed release inputs and preflight policy`. +- [x] Write cases for missing/extra artifacts, profiles changed after failure, unknown target/feature, missing pins and every skip value (`""`, `"0"`, `"1"`). Add wrapper, Cargo config, NODE_OPTIONS, native selector and source mutation cases. +- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_preflight.py' -v`; expect missing module/behavior failures. +- [x] Create the tracked consumer/producer policy bytes now, with the spec's concrete fixed identity/profile/action/bounds fields, so recipe/index assembly can bind their source identity before signing. Runtime enforcement is implemented in Tasks 17–20; this source-controlled contract is not a downloadable policy override. +- [x] Map the four asset IDs to fixed package/bin/target/features. Never accept shell commands from JSON. Pin Rust/Node/npm, gh 2.97.0, attest 4.2.2/full SHA and Fastly 15.1.0; populate actual binary/image/SDK digests only from authenticated acquisition and Task 8 qualification. Missing qualified pins fail `--require-qualified`; no dummy digest is accepted. +- [x] Inspect the inherited environment before clearing it. Reject skip presence and unreviewed override channels; create a fresh allowlisted child environment. Remove publisher prefixes and inject only approved commit/control facts. Derive epoch from the approved Git commit's committer timestamp. Check tracked bytes plus unexpected source-only files, modes and links before/after execution. +- [x] Select both generic and target-suffixed AWS-LC selectors, bundled SQLite, Cargo config/home layers, sysroot/tool paths and Vite mode. Set `AWS_LC_SYS_USE_SYSTEM=0`, `LIBSQLITE3_SYS_USE_PKG_CONFIG=0`; reject alternate selectors rather than merely setting weaker generic defaults. +- [x] Re-run fixtures, including a fictional operator-config independence case. Commit `Define fixed release inputs and preflight policy`. **Interface:** `python3 scripts/release/preflight.py --cohort scripts/release/release-cohort.json --source-commit --require-qualified`; exit 0 only for a complete selected contract, exit nonzero before preparation/signing otherwise. @@ -434,3 +434,5 @@ Permission-policy reconciliation remains an enablement dependency owned by the # Task completion requires implementation evidence and both independent reviews. Task 1's owner reconciliation and maintainer milestone review remain pending; subsequent local implementation can proceed. Hosted qualification, governance setup, signing/publication rehearsal and live Fastly submission remain distinct evidence gates, and no production release trigger is enabled by this record. Task 2 is implemented in `1cfca1357`, with review corrections in `c29940908` and `fbb88d2e`. Independent spec and quality reviews pass after resolving tuple-contained key validation, serialization exception consistency and iterator identity selectors. The controller's fresh utility run passes 35 tests. A fresh private Python 3.12 environment installs all six pinned dependencies with `--require-hashes --only-binary=:all:`; `pip check`, the normative release-index schema check and Draft 2020-12 positive/negative semantics pass. The reproducible installation/validation procedure is in `scripts/release/tests/fixtures/README.md`; Task 14 must wire this explicit installation into hosted producer validation before using the validator. These checks establish utility behavior and local validator compatibility, not artifact or hosted qualification. + +Task 3 is implemented in `c9076d0f1`, with review corrections in `31ee0739` and `b89552d1`. Independent spec and quality reviews pass after correcting the SPDX 2.3 predicate identity and including executable JS/Rust validation sources in the source checks. The controller's fresh Python run passes 72 tests, the complete native CLI test command passes, and `cargo clippy-cli` passes. Source snapshots accept Git-equivalent executable modes under the selected umask variations while retaining actual permissions for before/after checks. Narrow domain-lint support for the three mandatory standards hosts is tested against the production policy and hostile lookalikes. Real-checkout inspection rejects a pre-existing ignored CLI source `.DS_Store`, which is preserved; actual build preparation requires a clean source snapshot. Tool, image, SDK and recipe digests remain explicitly pending, and `--require-qualified` cannot succeed until Task 8 supplies authenticated acquisition and measured evidence. Vite environment suppression, OS confinement, native graph coverage and consumer enforcement remain their later tasks. From bfb697404551a28217af8906024506095bcd9559 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 20:35:37 +0530 Subject: [PATCH 018/153] Derive immutable release build facts --- crates/trusted-server-adapter-fastly/build.rs | 35 ++ .../trusted-server-adapter-fastly/src/main.rs | 12 + crates/trusted-server-cli/README.md | 13 + crates/trusted-server-cli/build.rs | 32 ++ crates/trusted-server-cli/src/lib.rs | 2 +- .../trusted-server-cli/src/prebid_bundle.rs | 25 +- crates/trusted-server-cli/src/run.rs | 28 +- .../tests/release_build_facts.rs | 497 ++++++++++++++++++ crates/trusted-server-core/build.rs | 17 +- .../build_support/git_version.rs | 30 ++ crates/trusted-server-core/src/build_info.rs | 80 +++ crates/trusted-server-core/src/lib.rs | 1 + .../tests/git_version_resolve.rs | 62 +++ 13 files changed, 820 insertions(+), 14 deletions(-) create mode 100644 crates/trusted-server-adapter-fastly/build.rs create mode 100644 crates/trusted-server-cli/build.rs create mode 100644 crates/trusted-server-cli/tests/release_build_facts.rs create mode 100644 crates/trusted-server-core/src/build_info.rs diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs new file mode 100644 index 0000000000..ea6efe16fa --- /dev/null +++ b/crates/trusted-server-adapter-fastly/build.rs @@ -0,0 +1,35 @@ +//! Compile immutable facts owned by the Fastly product. + +#![allow( + clippy::print_stdout, + reason = "Cargo build script directives use stdout" +)] + +#[allow( + dead_code, + reason = "This owner uses only release validation from the shared resolver" +)] +#[path = "../trusted-server-core/build_support/git_version.rs"] +mod git_version; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../trusted-server-core/build_support/git_version.rs"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION"); + let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION"); + if let Some(source) = git_version::release_source(control.as_deref(), source.as_deref()) + .expect("should provide valid release build inputs") + { + println!("cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION={source}"); + } + let target = std::env::var("TARGET").expect("should set the owning Cargo target"); + println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_TARGET={target}"); + let variant = if std::env::var_os("CARGO_FEATURE_REUSABLE_SANDBOX").is_some() { + "reusable-sandbox" + } else { + "default" + }; + println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_VARIANT={variant}"); +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 1f2668b0a6..c728c9e546 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -13,6 +13,7 @@ use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; +use trusted_server_core::build_info::{BuildInfo, BuildVariant}; use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive}; use trusted_server_core::constants::HEADER_X_TS_VERSION; use trusted_server_core::ec::device::DeviceSignals; @@ -58,6 +59,17 @@ use crate::sandbox::{RetainedApp, Sandbox, SandboxCounters, ServeMode, StartupDi #[cfg(feature = "reusable-sandbox")] use crate::sandbox::RetirementCounters; +/// Immutable facts compiled into the owning Fastly product. +pub const BUILD_INFO: BuildInfo = BuildInfo::new( + env!("CARGO_PKG_VERSION"), + env!("TRUSTED_SERVER_BUILD_TARGET"), + if cfg!(feature = "reusable-sandbox") { + BuildVariant::ReusableSandbox + } else { + BuildVariant::Default + }, +); + /// Opens the Fastly Config Store used by the `EdgeZero` dispatcher. /// /// # Errors diff --git a/crates/trusted-server-cli/README.md b/crates/trusted-server-cli/README.md index e73c646283..f3aa9f925c 100644 --- a/crates/trusted-server-cli/README.md +++ b/crates/trusted-server-cli/README.md @@ -22,6 +22,19 @@ two-platform command inventory and the [EdgeZero guide](../../docs/guide/edgezero.md) for configuration lifecycle and store semantics. +`ts --version` reports the CLI package version and compiled source identity. +Release builds set `TRUSTED_SERVER__RELEASE_BUILD=1` and require +`TRUSTED_SERVER__GIT_VERSION` to contain exactly 40 lowercase hexadecimal commit +characters. Any other present release control fails the build. Without that +control, development builds retain tag, branch and abbreviated commit discovery. +These compiled facts identify inputs; they do not establish release provenance. + +Help, version output and `ts config init` work outside a source checkout. Config +initialization writes the embedded fictional example scaffold for the operator +to configure. `ts prebid client` requires the repository's JavaScript sources and +local build prerequisites. Release builds discover those resources from the +current directory's ancestors and omit the developer checkout fallback. + ## Experimental PBS commands `ts prebid server` manages local configuration inputs and a small set of AWS operations for self-hosted Prebid Server Go. It sits beside `ts prebid client`, which builds browser JavaScript, and remains separate from the existing Trusted Server `ts config` and `ts deploy` commands. diff --git a/crates/trusted-server-cli/build.rs b/crates/trusted-server-cli/build.rs new file mode 100644 index 0000000000..4a69768233 --- /dev/null +++ b/crates/trusted-server-cli/build.rs @@ -0,0 +1,32 @@ +//! Compile immutable facts owned by the CLI product. + +#![allow( + clippy::print_stdout, + reason = "Cargo build script directives use stdout" +)] + +#[allow( + dead_code, + reason = "This owner uses only release validation from the shared resolver" +)] +#[path = "../trusted-server-core/build_support/git_version.rs"] +mod git_version; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../trusted-server-core/build_support/git_version.rs"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION"); + println!("cargo:rustc-check-cfg=cfg(trusted_server_release_build)"); + let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION"); + if let Some(source) = git_version::release_source(control.as_deref(), source.as_deref()) + .expect("should provide valid release build inputs") + { + println!("cargo:rustc-cfg=trusted_server_release_build"); + println!("cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION={source}"); + } + let target = std::env::var("TARGET").expect("should set the owning Cargo target"); + println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_TARGET={target}"); + println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_VARIANT=default"); +} diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 24e001d853..080bdb0db5 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -14,7 +14,7 @@ mod tls; mod url_guard; #[cfg(not(target_arch = "wasm32"))] -pub use run::{RunOutcome, run_from_env}; +pub use run::{BUILD_INFO, RunOutcome, run_from_env}; // Every `ts` subcommand's implementation lives under `commands/`. The // `ts dev` group is available on every host target; `ts dev lint` and diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 609c950531..7f92992722 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -627,17 +627,20 @@ fn find_js_lib_dir(start: &Path) -> CliResult { } } - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let candidate = manifest_dir - .join("../..") - .join("crates/trusted-server-js/lib"); - if is_js_lib_dir(&candidate) { - return candidate.canonicalize().map_err(|error| { - report_error(format!( - "failed to resolve JS library directory {}: {error}", - candidate.display() - )) - }); + #[cfg(not(trusted_server_release_build))] + { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let candidate = manifest_dir + .join("../..") + .join("crates/trusted-server-js/lib"); + if is_js_lib_dir(&candidate) { + return candidate.canonicalize().map_err(|error| { + report_error(format!( + "failed to resolve JS library directory {}: {error}", + candidate.display() + )) + }); + } } cli_error( diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index f77ebb17d2..dd2f6a19e2 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -1,10 +1,12 @@ use std::process; +use std::sync::LazyLock; use clap::{Parser, Subcommand}; use edgezero_cli::args::{ ActiveVersionArgs, AuthArgs, BuildArgs, ConfigDiffArgs, ConfigGcArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, HealthcheckArgs, ProvisionArgs, RollbackArgs, ServeArgs, }; +use trusted_server_core::build_info::{BuildInfo, BuildVariant}; use trusted_server_core::config::TrustedServerAppConfig; use crate::commands::audit::{AuditArgs, run_audit}; @@ -13,8 +15,23 @@ use crate::commands::config::init::{ConfigInitArgs, run_config_init}; use crate::commands::pbs::{self, PbsArgs}; use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; +/// Immutable facts compiled into the owning CLI product. +pub const BUILD_INFO: BuildInfo = BuildInfo::new( + env!("CARGO_PKG_VERSION"), + env!("TRUSTED_SERVER_BUILD_TARGET"), + BuildVariant::Default, +); + +static VERSION_TEXT: LazyLock = LazyLock::new(|| { + format!( + "{} ({})", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown") + ) +}); + #[derive(Debug, Parser)] -#[command(name = "ts", version, about = "Trusted Server CLI")] +#[command(name = "ts", version = VERSION_TEXT.as_str(), about = "Trusted Server CLI")] struct Args { #[command(subcommand)] command: Command, @@ -205,6 +222,15 @@ mod tests { clap::error::ErrorKind::DisplayVersion, "should print the version rather than fail to parse" ); + assert_eq!( + err.to_string(), + format!( + "ts {} ({})\n", + env!("CARGO_PKG_VERSION"), + trusted_server_core::constants::TS_GIT_VERSION.unwrap_or("unknown") + ), + "should print the owning package version and compiled source identity" + ); } #[test] diff --git a/crates/trusted-server-cli/tests/release_build_facts.rs b/crates/trusted-server-cli/tests/release_build_facts.rs new file mode 100644 index 0000000000..19f8722c89 --- /dev/null +++ b/crates/trusted-server-cli/tests/release_build_facts.rs @@ -0,0 +1,497 @@ +//! Host-only checks of the actual owning build scripts and compiled facts. + +#![cfg(not(target_arch = "wasm32"))] + +use std::ffi::OsStr; +use std::fs; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt as _; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::TempDir; + +const SOURCE: &str = "abcdef0123456789abcdef0123456789abcdef01"; + +fn workspace() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("should resolve crates") + .parent() + .expect("should resolve workspace") +} + +fn compile_build_script(owner: &str, directory: &TempDir) -> PathBuf { + let output = directory.path().join(format!("{owner}-build")); + let mut compiler = Command::new("rustc"); + compiler + .args(["--edition=2024"]) + .arg(workspace().join("crates").join(owner).join("build.rs")) + .arg("-o") + .arg(&output); + if owner == "trusted-server-core" { + // Exercise the actual core emission control flow without linking its + // unrelated manifest-loader and template-digest dependencies. + let source = fs::read_to_string(workspace().join("crates/trusted-server-core/build.rs")) + .expect("should read core build script"); + let git_start = source.find("fn git(").expect("should locate Git lookup"); + let git_end = source.find("fn main()").expect("should locate core main"); + let emit_start = source + .find("fn emit_git_version()") + .expect("should locate core source emission"); + let emit_end = source[emit_start..] + .find("/// Hash the core implementation") + .expect("should locate following digest function") + + emit_start; + let fixture = directory.path().join("core-build.rs"); + fs::write( + &fixture, + format!( + r#" +use std::env; +use std::path::Path; +use std::process::Command; +#[path = "{}"] mod git_version; +use git_version::{{Candidates, is_usable, release_source, resolve_git_version}}; +const OVERRIDE_ENV: &str = "TRUSTED_SERVER__GIT_VERSION"; +{} +{} +fn main() {{ emit_git_version(); }} +"#, + workspace() + .join("crates/trusted-server-core/build_support/git_version.rs") + .display(), + &source[git_start..git_end], + &source[emit_start..emit_end] + ), + ) + .expect("should write actual core emission fixture"); + compiler = Command::new("rustc"); + compiler + .args(["--edition=2024"]) + .arg(fixture) + .arg("-o") + .arg(&output); + } + let compiled = compiler.output().expect("should run rustc"); + assert!( + compiled.status.success(), + "should compile the actual owner script: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + output +} + +fn owner_command(script: &Path, owner: &str, directory: &TempDir) -> Command { + let manifest = workspace().join("crates").join(owner); + let mut command = Command::new(script); + command + .current_dir(&manifest) + .env("CARGO_MANIFEST_DIR", manifest) + .env("OUT_DIR", directory.path()) + .env("TARGET", "example-target") + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .env_remove("CARGO_FEATURE_REUSABLE_SANDBOX"); + command +} + +#[cfg(unix)] +fn record_git(directory: &TempDir) -> PathBuf { + let script = directory.path().join("git"); + fs::write( + &script, + "#!/bin/sh\nprintf invoked >> \"$GIT_RECORD\"\nexit 1\n", + ) + .expect("should write fake git"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o700)) + .expect("should make fake git executable"); + directory.path().join("git-record") +} + +#[cfg(unix)] +#[test] +fn release_scripts_reject_invalid_inputs_without_git_fallback() { + let directory = tempfile::tempdir().expect("should create fixture"); + let record = record_git(&directory); + for owner in [ + "trusted-server-cli", + "trusted-server-adapter-fastly", + "trusted-server-core", + ] { + let script = compile_build_script(owner, &directory); + for control in [ + OsStr::new(""), + OsStr::new("0"), + OsStr::new(" 1"), + OsStr::new("1\n"), + OsStr::from_bytes(b"\xff"), + ] { + let output = owner_command(&script, owner, &directory) + .env("PATH", directory.path()) + .env("GIT_RECORD", &record) + .env("TRUSTED_SERVER__RELEASE_BUILD", control) + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .output() + .expect("should run actual script"); + assert!( + !output.status.success(), + "should reject invalid release control for {owner}: {control:?}" + ); + assert!(!record.exists(), "should reject control before Git lookup"); + } + for source in [ + None, + Some(OsStr::new("main")), + Some(OsStr::new("v1.2.3")), + Some(OsStr::new("abcdef")), + Some(OsStr::new("ABCDEF0123456789abcdef0123456789abcdef01")), + Some(OsStr::new(" abcdef0123456789abcdef0123456789abcdef01")), + Some(OsStr::new("abcdef0123456789abcdef0123456789abcdef01\n")), + Some(OsStr::from_bytes(b"\xff")), + ] { + let mut command = owner_command(&script, owner, &directory); + command + .env("PATH", directory.path()) + .env("GIT_RECORD", &record) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1"); + if let Some(source) = source { + command.env("TRUSTED_SERVER__GIT_VERSION", source); + } + let output = command.output().expect("should run actual script"); + assert!( + !output.status.success(), + "should reject malformed release source for {owner}: {source:?}" + ); + assert!(!record.exists(), "should reject source before Git lookup"); + } + let output = owner_command(&script, owner, &directory) + .env("PATH", directory.path()) + .env("GIT_RECORD", &record) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .output() + .expect("should run valid release script"); + assert!( + output.status.success(), + "should accept valid release source for {owner}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!( + "cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION={SOURCE}" + )), + "should emit the exact complete source" + ); + assert!( + !record.exists(), + "should emit release source without Git lookup" + ); + if owner == "trusted-server-core" { + let output = owner_command(&script, owner, &directory) + .env("PATH", directory.path()) + .env("GIT_RECORD", &record) + .output() + .expect("should run development source discovery"); + assert!( + output.status.success(), + "should retain development Git fallback" + ); + assert!( + record.exists(), + "should prove the Git recorder detects development lookup" + ); + } + } +} + +#[test] +fn fastly_owns_the_reusable_sandbox_variant() { + let directory = tempfile::tempdir().expect("should create fixture"); + let owner = "trusted-server-adapter-fastly"; + let script = compile_build_script(owner, &directory); + for (feature, variant) in [(false, "default"), (true, "reusable-sandbox")] { + let mut command = owner_command(&script, owner, &directory); + command + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE); + if feature { + command.env("CARGO_FEATURE_REUSABLE_SANDBOX", "1"); + } + let output = command.output().expect("should run actual Fastly script"); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!( + "cargo:rustc-env=TRUSTED_SERVER_BUILD_VARIANT={variant}" + )), + "should derive the variant in the owning Fastly script" + ); + let expected = if feature { + "ReusableSandbox" + } else { + "Default" + }; + assert_eq!( + compile_fact_payload(owner, &directory, &String::from_utf8_lossy(&output.stdout)), + format!("9.8.7|{SOURCE}|example-target|{expected}\n"), + "should compile the owning feature variant into the facts" + ); + } +} + +#[test] +fn owners_emit_the_cargo_target() { + let directory = tempfile::tempdir().expect("should create fixture"); + for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { + let script = compile_build_script(owner, &directory); + let output = Command::new(script) + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .env("TARGET", "example-target") + .output() + .expect("should run actual owner script"); + assert!(output.status.success(), "should build development facts"); + assert!( + String::from_utf8_lossy(&output.stdout) + .contains("cargo:rustc-env=TRUSTED_SERVER_BUILD_TARGET=example-target"), + "should derive the owning target from Cargo TARGET" + ); + } +} + +fn compile_fact_payload(owner: &str, directory: &TempDir, directives: &str) -> String { + let owner_file = if owner == "trusted-server-cli" { + "run.rs" + } else { + "main.rs" + }; + let source = fs::read_to_string( + workspace() + .join("crates") + .join(owner) + .join("src") + .join(owner_file), + ) + .expect("should read owning source"); + let start = source + .find("pub const BUILD_INFO:") + .expect("should expose owning immutable facts"); + let end = source[start..] + .find(';') + .expect("should end owning constant") + + start + + 1; + let record = &source[start..end]; + let fixture = directory.path().join("facts.rs"); + fs::write(&fixture, format!(r#" +extern crate self as trusted_server_core; +#[path = "{}"] pub mod build_info; +pub mod constants {{ pub const TS_GIT_VERSION: Option<&str> = option_env!("TRUSTED_SERVER__GIT_VERSION"); }} +use build_info::{{BuildInfo, BuildVariant}}; +{record} +fn main() {{ println!("{{}}|{{}}|{{}}|{{:?}}", BUILD_INFO.package_version(), BUILD_INFO.source().expect("should have source"), BUILD_INFO.target(), BUILD_INFO.variant()); }} +"#, workspace().join("crates/trusted-server-core/src/build_info.rs").display())).expect("should write fact fixture"); + let output = directory.path().join("facts"); + let mut compiler = Command::new("rustc"); + compiler + .args(["--edition=2024"]) + .arg(&fixture) + .arg("-o") + .arg(&output) + .env("CARGO_PKG_VERSION", "9.8.7"); + for line in directives.lines() { + if let Some(fact) = line.strip_prefix("cargo:rustc-env=") { + let (key, value) = fact.split_once('=').expect("should emit environment fact"); + compiler.env(key, value); + if key == "TRUSTED_SERVER_BUILD_VARIANT" && value == "reusable-sandbox" { + compiler.args(["--cfg", "feature=\"reusable-sandbox\""]); + } + } + } + let compiled = compiler.output().expect("should compile actual facts"); + assert!( + compiled.status.success(), + "should compile actual owning facts: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + let output = Command::new(output) + .output() + .expect("should run compiled facts"); + assert!(output.status.success(), "should read immutable facts"); + String::from_utf8(output.stdout).expect("should emit UTF-8 facts") +} + +#[test] +fn external_tags_do_not_change_compiled_facts() { + let directory = tempfile::tempdir().expect("should create fixture"); + for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { + let script = compile_build_script(owner, &directory); + let mut facts = Vec::new(); + for tag in ["v1.2.3", "v1.2.4"] { + let output = owner_command(&script, owner, &directory) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .env("GITHUB_REF_NAME", tag) + .env("GITHUB_RUN_ID", tag) + .output() + .expect("should run release owner script"); + assert!(output.status.success(), "should emit valid release facts"); + facts.push(compile_fact_payload( + owner, + &directory, + &String::from_utf8_lossy(&output.stdout), + )); + } + assert_eq!( + facts[0], facts[1], + "should keep external tags and runs out of compiled facts" + ); + assert_eq!( + facts[0], + format!("9.8.7|{SOURCE}|example-target|Default\n"), + "should use owning package version, exact source, Cargo target and variant" + ); + } +} + +#[test] +fn version_help_and_config_init_work_outside_the_checkout() { + let directory = tempfile::tempdir().expect("should create outside checkout fixture"); + let binary = env!("CARGO_BIN_EXE_ts"); + for argument in ["--help", "--version"] { + let output = Command::new(binary) + .current_dir(directory.path()) + .arg(argument) + .output() + .expect("should run portable CLI"); + assert!( + output.status.success(), + "should print {argument} outside checkout" + ); + if argument == "--version" { + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!( + "ts {} ({})\n", + env!("CARGO_PKG_VERSION"), + trusted_server_core::constants::TS_GIT_VERSION.unwrap_or("unknown") + ), + "should print package version and source" + ); + } + } + let output = Command::new(binary) + .current_dir(directory.path()) + .args(["config", "init"]) + .output() + .expect("should initialize scaffold outside checkout"); + assert!( + output.status.success(), + "should initialize embedded example config" + ); + assert_eq!( + fs::read_to_string(directory.path().join("trusted-server.toml")) + .expect("should read config scaffold"), + include_str!("../../../trusted-server.example.toml"), + "should use the tracked fictional scaffold" + ); +} + +#[test] +fn release_resource_discovery_excludes_the_compiled_developer_checkout() { + let directory = tempfile::tempdir().expect("should create fixture"); + let source = + fs::read_to_string(workspace().join("crates/trusted-server-cli/src/prebid_bundle.rs")) + .expect("should read resource discovery"); + let start = source + .find("fn find_js_lib_dir(") + .expect("should find discovery function"); + let end = source[start..] + .find("fn resolve_output_dir(") + .expect("should find following function") + + start; + let fixture = directory.path().join("discovery.rs"); + fs::write(&fixture, format!(r#" +use std::path::{{Path, PathBuf}}; +type CliResult = Result; +fn cli_error(message: impl Into) -> CliResult {{ Err(message.into()) }} +fn report_error(message: impl Into) -> String {{ message.into() }} +{} +fn main() {{ match find_js_lib_dir(Path::new(&std::env::args().nth(1).expect("should pass directory"))) {{ Ok(path) => println!("{{}}", path.display()), Err(error) => {{ eprintln!("{{error}}"); std::process::exit(1); }} }} }} +"#, &source[start..end])).expect("should write actual discovery fixture"); + let binary = directory.path().join("discovery"); + let owner = "trusted-server-cli"; + let script = compile_build_script(owner, &directory); + let directives = owner_command(&script, owner, &directory) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .output() + .expect("should run release CLI build script"); + assert!( + directives.status.success(), + "should emit release CLI compile directives" + ); + let directives = String::from_utf8(directives.stdout).expect("should emit UTF-8 directives"); + assert!( + directives.contains("cargo:rustc-check-cfg=cfg(trusted_server_release_build)"), + "should register the checked release cfg" + ); + let mut compiler = Command::new("rustc"); + compiler + .args(["--edition=2024"]) + .arg(&fixture) + .arg("-o") + .arg(&binary) + .env( + "CARGO_MANIFEST_DIR", + workspace().join("crates/trusted-server-cli"), + ); + for directive in directives + .lines() + .filter_map(|line| line.strip_prefix("cargo:rustc-cfg=")) + { + compiler.arg("--cfg").arg(directive); + } + let compiled = compiler.output().expect("should compile release discovery"); + assert!( + compiled.status.success(), + "should compile discovery fixture: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + let output = Command::new(&binary) + .arg(directory.path()) + .output() + .expect("should discover release resources"); + assert!( + !output.status.success(), + "should reject the existing compiled developer checkout fallback" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("run `ts prebid client` from the Trusted Server repository"), + "should explain source prerequisites" + ); + let bytes = fs::read(&binary).expect("should read compiled release fixture"); + let checkout = workspace().join("crates/trusted-server-cli"); + assert!( + !bytes + .windows(checkout.as_os_str().as_encoded_bytes().len()) + .any(|part| part == checkout.as_os_str().as_encoded_bytes()), + "should compile the developer checkout literal out of release discovery" + ); + let resources = directory.path().join("crates/trusted-server-js/lib"); + fs::create_dir_all(&resources).expect("should create current-checkout resources"); + fs::write(resources.join("package.json"), "{}").expect("should write package fixture"); + fs::write(resources.join("build-prebid-external.mjs"), "").expect("should write build fixture"); + let nested = directory.path().join("nested"); + fs::create_dir(&nested).expect("should create nested working directory"); + let output = Command::new(binary) + .arg(&nested) + .output() + .expect("should discover ancestor resources"); + assert!( + output.status.success(), + "should preserve explicit current-checkout ancestor discovery" + ); +} diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 4cc29466f7..161227301e 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use edgezero_core::manifest::ManifestLoader; -use git_version::{Candidates, is_usable, resolve_git_version}; +use git_version::{Candidates, is_usable, release_source, resolve_git_version}; use sha2::{Digest as _, Sha256}; /// Crate-relative build inputs that are both watched and hashed besides `src/`. @@ -158,6 +158,21 @@ fn main() { fn emit_git_version() { println!("cargo:rerun-if-env-changed={OVERRIDE_ENV}"); + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + let control = env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = env::var_os(OVERRIDE_ENV); + match release_source(control.as_deref(), source.as_deref()) { + Ok(Some(commit)) => { + println!("cargo:rustc-env={OVERRIDE_ENV}={commit}"); + return; + } + Ok(None) => {} + Err(message) => { + println!("cargo::error={message}"); + std::process::exit(1); + } + } + let override_value = std::env::var(OVERRIDE_ENV).ok(); let resolved = match override_value.as_deref() { Some(value) if is_usable(value) => resolve_git_version(&Candidates { diff --git a/crates/trusted-server-core/build_support/git_version.rs b/crates/trusted-server-core/build_support/git_version.rs index f4cf2d3688..d01a82341a 100644 --- a/crates/trusted-server-core/build_support/git_version.rs +++ b/crates/trusted-server-core/build_support/git_version.rs @@ -3,6 +3,36 @@ //! Shared by `build.rs` and `tests/git_version_resolve.rs` via `#[path]`, so it //! must stay dependency-free. +use std::ffi::OsStr; + +/// Returns the exact source commit in release mode, or `None` in development. +/// +/// # Errors +/// +/// Rejects any present release control except `1`, and release sources other +/// than exactly 40 lowercase ASCII hexadecimal bytes. +pub fn release_source( + control: Option<&OsStr>, + source: Option<&OsStr>, +) -> Result, &'static str> { + match control { + None => return Ok(None), + Some(value) if value == OsStr::new("1") => {} + Some(_) => return Err("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1 when present"), + } + let source = source + .and_then(OsStr::to_str) + .ok_or("release builds require TRUSTED_SERVER__GIT_VERSION as a full lowercase commit")?; + if source.len() != 40 + || !source + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("release source must be exactly 40 lowercase ASCII hexadecimal bytes"); + } + Ok(Some(source.to_owned())) +} + /// Raw candidates for the deployed git version, in priority order. pub struct Candidates<'a> { /// `TRUSTED_SERVER__GIT_VERSION`, supplied by the deploy pipeline. diff --git a/crates/trusted-server-core/src/build_info.rs b/crates/trusted-server-core/src/build_info.rs new file mode 100644 index 0000000000..c04455bcaf --- /dev/null +++ b/crates/trusted-server-core/src/build_info.rs @@ -0,0 +1,80 @@ +//! Immutable facts compiled into an owning product. +//! +//! These facts identify build inputs. They do not establish release provenance +//! or publishing authority; release tags, run IDs and artifact checksums remain +//! external to the compiled product. + +use crate::constants::TS_GIT_VERSION; + +/// The owning product's compile-time feature variant. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum BuildVariant { + /// The product's default feature set. + Default, + /// Fastly's opt-in reusable sandbox feature. + ReusableSandbox, +} + +/// Immutable package, source, target and variant facts for an owning product. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct BuildInfo { + package_version: &'static str, + source: Option<&'static str>, + target: &'static str, + variant: BuildVariant, +} + +impl BuildInfo { + /// Defines owning product facts using the source compiled into core. + /// + /// Call this in an owning constant with Cargo's package version, its build + /// script's target and its own feature variant. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::build_info::{BuildInfo, BuildVariant}; + /// const FACTS: BuildInfo = BuildInfo::new("1.2.3", "example-target", BuildVariant::Default); + /// assert_eq!(FACTS.package_version(), "1.2.3"); + /// ``` + #[must_use] + pub const fn new( + package_version: &'static str, + target: &'static str, + variant: BuildVariant, + ) -> Self { + Self { + package_version, + source: TS_GIT_VERSION, + target, + variant, + } + } + + /// Returns the owning product's Cargo package version. + #[must_use] + pub const fn package_version(&self) -> &'static str { + self.package_version + } + + /// Returns the compiled source identity, when known. + /// + /// Release builds require a complete commit. Development builds retain + /// the existing tag, branch and abbreviated commit resolution. + #[must_use] + pub const fn source(&self) -> Option<&'static str> { + self.source + } + + /// Returns the owning product's Cargo target triple. + #[must_use] + pub const fn target(&self) -> &'static str { + self.target + } + + /// Returns the owning product's feature variant. + #[must_use] + pub const fn variant(&self) -> BuildVariant { + self.variant + } +} diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 1c20179298..5913952a0a 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod build_info; pub mod cache_policy; pub mod cache_purge; pub mod config; diff --git a/crates/trusted-server-core/tests/git_version_resolve.rs b/crates/trusted-server-core/tests/git_version_resolve.rs index 9fb77a2b54..770ef0b3a5 100644 --- a/crates/trusted-server-core/tests/git_version_resolve.rs +++ b/crates/trusted-server-core/tests/git_version_resolve.rs @@ -4,6 +4,9 @@ mod git_version; use git_version::{Candidates, resolve_git_version}; +use std::ffi::OsStr; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt as _; const COMMIT: &str = "abcdef0123456789abcdef0123456789abcdef01"; @@ -139,3 +142,62 @@ fn is_usable_accepts_only_visible_ascii() { ); assert!(!git_version::is_usable("v1-été"), "should reject non-ASCII"); } + +#[test] +fn release_source_requires_an_exact_full_lowercase_commit() { + let commit = "abcdef0123456789abcdef0123456789abcdef01"; + assert_eq!( + git_version::release_source(Some(OsStr::new("1")), Some(OsStr::new(commit))), + Ok(Some(commit.to_owned())), + "should preserve the complete release source" + ); + for source in [ + None, + Some(""), + Some("main"), + Some("v1.2.3"), + Some("abcdef"), + Some("ABCDEF0123456789abcdef0123456789abcdef01"), + Some(" abcdef0123456789abcdef0123456789abcdef01"), + Some("abcdef0123456789abcdef0123456789abcdef01\n"), + Some("abcdef0123456789abcdef0123456789abcdef0g"), + ] { + assert!( + git_version::release_source(Some(OsStr::new("1")), source.map(OsStr::new)).is_err(), + "should reject malformed release source {source:?}" + ); + } +} + +#[test] +fn release_control_accepts_only_absence_or_exact_one() { + assert_eq!( + git_version::release_source(None, Some(OsStr::new("main"))), + Ok(None), + "should retain development resolution when release control is absent" + ); + for control in ["", "0", "true", " 1", "1\n"] { + assert!( + git_version::release_source( + Some(OsStr::new(control)), + Some(OsStr::new("abcdef0123456789abcdef0123456789abcdef01")) + ) + .is_err(), + "should reject invalid control even with valid source" + ); + } +} + +#[cfg(unix)] +#[test] +fn non_unicode_release_inputs_fail_closed() { + let non_unicode = OsStr::from_bytes(b"\xff"); + assert!( + git_version::release_source(Some(non_unicode), None).is_err(), + "should recognize non-Unicode control as present and invalid" + ); + assert!( + git_version::release_source(Some(OsStr::new("1")), Some(non_unicode)).is_err(), + "should reject non-Unicode source" + ); +} From 29564850e814bf871d960b267785228101fc3e9a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 20:42:14 +0530 Subject: [PATCH 019/153] Record immutable build facts verification --- .../2026-10-13-attested-release-implementation.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 585d279ea9..39de15e8d4 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -109,11 +109,11 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** core `src/build_info.rs`; CLI `build.rs`, `tests/release_build_facts.rs`. **Modify when adding the owning facts surface:** `crates/trusted-server-adapter-fastly/src/main.rs`; retain existing version-header/service-version behavior. -- [ ] Add strict full-commit tests: missing/malformed/branch/tag values fail in release mode without local-git fallback; development behavior stays covered. Add two external tags/same source yielding identical facts, owning-target/variant checks and compiled-path fallback rejection. -- [ ] Run `cargo test -p trusted-server-core --test git_version_resolve` and `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_build_facts`; expect the new behavior to fail. -- [ ] Extend the merged #1213 helper using the approved release control. Derive target from Cargo `TARGET`, package version from the owning product, and default/reuse variant at the Fastly owner. Expose typed immutable facts without importing verifier/publisher keys into core. Do not embed tag, run ID, time or final checksum. -- [ ] Set clap's version text to package version plus source identity. Official releases compile out `find_js_lib_dir`'s literal developer checkout fallback; preserve explicit/current-checkout resource discovery. Keep the tracked example config as a fictional scaffold, never active settings. -- [ ] Run core fact/template tests, CLI tests/clippy and Fastly version-header tests. Outside-checkout help/version/config-init must pass; Prebid source-dependent commands return documented prerequisites. Commit `Derive immutable release build facts`. +- [x] Add strict full-commit tests: missing/malformed/branch/tag values fail in release mode without local-git fallback; development behavior stays covered. Add two external tags/same source yielding identical facts, owning-target/variant checks and compiled-path fallback rejection. +- [x] Run `cargo test -p trusted-server-core --test git_version_resolve` and `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_build_facts`; expect the new behavior to fail. +- [x] Extend the merged #1213 helper using the approved release control. Derive target from Cargo `TARGET`, package version from the owning product, and default/reuse variant at the Fastly owner. Expose typed immutable facts without importing verifier/publisher keys into core. Do not embed tag, run ID, time or final checksum. +- [x] Set clap's version text to package version plus source identity. Official releases compile out `find_js_lib_dir`'s literal developer checkout fallback; preserve explicit/current-checkout resource discovery. Keep the tracked example config as a fictional scaffold, never active settings. +- [x] Run core fact/template tests, CLI tests/clippy and Fastly version-header tests. Outside-checkout help/version/config-init must pass; Prebid source-dependent commands return documented prerequisites. Commit `Derive immutable release build facts`. ### Task 5: Authenticate the existing prebuilt JS interface @@ -436,3 +436,5 @@ Task completion requires implementation evidence and both independent reviews. T Task 2 is implemented in `1cfca1357`, with review corrections in `c29940908` and `fbb88d2e`. Independent spec and quality reviews pass after resolving tuple-contained key validation, serialization exception consistency and iterator identity selectors. The controller's fresh utility run passes 35 tests. A fresh private Python 3.12 environment installs all six pinned dependencies with `--require-hashes --only-binary=:all:`; `pip check`, the normative release-index schema check and Draft 2020-12 positive/negative semantics pass. The reproducible installation/validation procedure is in `scripts/release/tests/fixtures/README.md`; Task 14 must wire this explicit installation into hosted producer validation before using the validator. These checks establish utility behavior and local validator compatibility, not artifact or hosted qualification. Task 3 is implemented in `c9076d0f1`, with review corrections in `31ee0739` and `b89552d1`. Independent spec and quality reviews pass after correcting the SPDX 2.3 predicate identity and including executable JS/Rust validation sources in the source checks. The controller's fresh Python run passes 72 tests, the complete native CLI test command passes, and `cargo clippy-cli` passes. Source snapshots accept Git-equivalent executable modes under the selected umask variations while retaining actual permissions for before/after checks. Narrow domain-lint support for the three mandatory standards hosts is tested against the production policy and hostile lookalikes. Real-checkout inspection rejects a pre-existing ignored CLI source `.DS_Store`, which is preserved; actual build preparation requires a clean source snapshot. Tool, image, SDK and recipe digests remain explicitly pending, and `--require-qualified` cannot succeed until Task 8 supplies authenticated acquisition and measured evidence. Vite environment suppression, OS confinement, native graph coverage and consumer enforcement remain their later tasks. + +Task 4 is implemented in `bfb697404`. Independent spec and quality reviews pass. The controller freshly ran all 13 source-resolution tests and all six owning-build-script/facts/resource tests; the quality reviewer independently repeated both suites. Implementation verification also passes the full native CLI test command, core template tests, CLI/core/Fastly lint, Fastly source-version tests with default and reusable-sandbox features, formatting and staged domain lint. Canonical CLI/Fastly facts derive from their owning Cargo package, target and feature settings; the public data constructor cannot reconfigure those constants and carries no verification authority. Tests compile the actual release cfg and resource-discovery function, reject the existing developer checkout fallback, prove its literal is absent, and preserve ancestor resources. Help/version/config-init portability uses the Cargo-built CLI; complete official release executable startup and cross-tag final-byte equality remain later recipe qualification evidence. No release workflow, publication or provider action is introduced by this task. From e1606fa3da0af4deb4d75b26a11d6677704d747c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 21:02:48 +0530 Subject: [PATCH 020/153] Bind release JS inputs to a checked inventory --- Cargo.lock | 4 + crates/trusted-server-js/Cargo.toml | 11 + crates/trusted-server-js/README.md | 26 + crates/trusted-server-js/build.rs | 124 ++- crates/trusted-server-js/build/bundle_set.rs | 35 +- crates/trusted-server-js/build/inventory.rs | 297 +++++++ crates/trusted-server-js/build/prebuilt.rs | 288 +++++++ .../tests/release_prebuilt.rs | 728 ++++++++++++++++++ 8 files changed, 1476 insertions(+), 37 deletions(-) create mode 100644 crates/trusted-server-js/build/inventory.rs create mode 100644 crates/trusted-server-js/build/prebuilt.rs create mode 100644 crates/trusted-server-js/tests/release_prebuilt.rs diff --git a/Cargo.lock b/Cargo.lock index e915a0ea8b..07c865b64e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6716,7 +6716,11 @@ version = "0.1.0" dependencies = [ "build-print", "hex", + "libc", + "serde", + "serde_json", "sha2 0.10.9", + "tempfile", "which", ] diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index 67a4ac6989..83389b8845 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -21,6 +21,17 @@ build-print = { workspace = true } hex = { workspace = true } sha2 = { workspace = true } which = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +libc = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +build-print = { workspace = true } +which = { workspace = true } +serde = { workspace = true } +libc = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } [dependencies] hex = { workspace = true } diff --git a/crates/trusted-server-js/README.md b/crates/trusted-server-js/README.md index 254ab6f55c..574daaa6e2 100644 --- a/crates/trusted-server-js/README.md +++ b/crates/trusted-server-js/README.md @@ -12,6 +12,32 @@ bundle is missing or empty; the `dist` directory is written only by a manual explicitly through `TSJS_PREBUILT_DIR`. The external Prebid.js artifact is built separately and is not the deferred Trusted Server Prebid shim. +With `TRUSTED_SERVER__RELEASE_BUILD=1`, Rust builds require +`TRUSTED_SERVER__GIT_VERSION` as the approved 40-character lowercase commit, +plus absolute `TSJS_PREBUILT_DIR` and `TSJS_PREBUILT_INVENTORY` paths. They run +no npm. Any presence of `TSJS_SKIP_BUILD` fails in all modes. Development +still supports fresh Node builds and prebuilt bundles without an inventory; +when an inventory is supplied, its bytes are checked, and an explicitly +supplied source must match. + +The version-1 inventory has exactly `schema_version`, `source_commit`, +`modules`, `tools`, and `materials`. Each module has exactly `id`, `filename`, +`size`, `sha256`, and `contributions`. Modules are sorted by safe ASCII ID and +must match the discovered set; filenames are `tsjs-.js`, sizes are positive +integers, and digests are lowercase SHA-256. The inventory must be outside +the bundle directory, is capped at 1 MiB, and uses strict UTF-8 JSON with +unique keys, finite numbers, and at most 64 container levels. Producers must +honor the 32 MiB input limit per module. The required contribution, tool, and +material arrays contain strictly parsed, opaque JSON here; composition +validation belongs to the release producer. + +Copying checks the exact source set through held file handles, rejects links +and nonregular files, and checks source identities and private copied bytes +before generating Rust. Cargo build-script messages expose +`TSJS_INPUT_INVENTORY_SHA256` through `cargo:rustc-env`, hashing the exact raw +inventory bytes for later build evidence. These are prepared input checks; +they do not establish which modules survive linking into a final executable. + Run the JavaScript checks from `crates/trusted-server-js/lib`: ```bash diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 0d4306f15c..99f311e003 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -1,11 +1,20 @@ #![allow( clippy::expect_used, clippy::panic, - reason = "build script failures should stop Cargo with a clear diagnostic" + clippy::print_stdout, + reason = "Cargo directives require stdout and build script failures must stop the build" )] #[path = "build/bundle_set.rs"] mod bundle_set; +#[allow(dead_code, reason = "only the shared release guard is needed here")] +#[path = "../trusted-server-core/build_support/git_version.rs"] +mod git_version; +#[path = "build/inventory.rs"] +mod inventory; +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[path = "build/prebuilt.rs"] +mod prebuilt; use std::env; use std::fmt::Write as _; @@ -16,13 +25,21 @@ use std::process::{Command, ExitStatus}; use build_print::info; use sha2::{Digest as _, Sha256}; -use crate::bundle_set::{bundle_file_name, check_bundle_set, expected_module_ids, scan_bundle_dir}; +use self::bundle_set::{bundle_file_name, check_bundle_set, expected_module_ids, scan_bundle_dir}; const PREBUILT_DIR_VAR: &str = "TSJS_PREBUILT_DIR"; +const INVENTORY_VAR: &str = "TSJS_PREBUILT_INVENTORY"; +const RELEASE_VAR: &str = "TRUSTED_SERVER__RELEASE_BUILD"; +const SOURCE_VAR: &str = "TRUSTED_SERVER__GIT_VERSION"; const SKIP_BUILD_VAR: &str = "TSJS_SKIP_BUILD"; const TEST_VAR: &str = "TSJS_TEST"; fn main() { + run_with_hook(|_, _| {}); +} + +/// Run the actual build script with a crate-private post-copy race fixture. +pub(crate) fn run_with_hook(hook: impl FnMut(&Path, &Path)) { // Cargo scans directories recursively, so these cover every TS source. for path in [ "lib/src", @@ -33,10 +50,49 @@ fn main() { ] { println!("cargo:rerun-if-changed={path}"); } - for var in [PREBUILT_DIR_VAR, SKIP_BUILD_VAR, TEST_VAR] { + for var in [ + PREBUILT_DIR_VAR, + INVENTORY_VAR, + SKIP_BUILD_VAR, + TEST_VAR, + RELEASE_VAR, + SOURCE_VAR, + ] { println!("cargo:rerun-if-env-changed={var}"); } + assert!( + env::var_os(SKIP_BUILD_VAR).is_none(), + "tsjs: {SKIP_BUILD_VAR} is no longer supported whenever present" + ); + let source = env::var_os(SOURCE_VAR); + let release = + git_version::release_source(env::var_os(RELEASE_VAR).as_deref(), source.as_deref()) + .unwrap_or_else(|error| panic!("tsjs: {error}")); + let prebuilt_dir = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from); + let inventory_path = env::var_os(INVENTORY_VAR).map(PathBuf::from); + if release.is_some() { + assert!( + prebuilt_dir.is_some(), + "tsjs: release builds require {PREBUILT_DIR_VAR}" + ); + assert!( + inventory_path.is_some(), + "tsjs: release builds require {INVENTORY_VAR}" + ); + } + assert!( + inventory_path.is_none() || prebuilt_dir.is_some(), + "tsjs: {INVENTORY_VAR} requires {PREBUILT_DIR_VAR}" + ); + if let Some(path) = &inventory_path { + assert!( + path.is_absolute(), + "tsjs: {INVENTORY_VAR} must be an absolute path" + ); + println!("cargo:rerun-if-changed={}", path.display()); + } + let crate_dir = PathBuf::from( env::var("CARGO_MANIFEST_DIR").expect("should set CARGO_MANIFEST_DIR for build script"), ); @@ -48,7 +104,7 @@ fn main() { let expected = expected_module_ids(&ts_dir.join("src")) .unwrap_or_else(|err| panic!("tsjs: failed to discover modules: {err}")); - if let Some(prebuilt_dir) = env::var_os(PREBUILT_DIR_VAR).map(PathBuf::from) { + if let Some(prebuilt_dir) = prebuilt_dir { assert!( prebuilt_dir.is_absolute(), "tsjs: {PREBUILT_DIR_VAR} must be an absolute path because Cargo runs build \ @@ -60,8 +116,38 @@ fn main() { "tsjs: Using prebuilt bundles from {}", prebuilt_dir.display() ); - validate_bundle_dir(&expected, &prebuilt_dir); - copy_prebuilt_bundles(&expected, &prebuilt_dir, &bundle_dir); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let inventory = inventory_path.as_ref().map(|path| { + let expected_source = source.as_ref().map(|source| { + source + .to_str() + .expect("should provide a UTF-8 source when checking inventory") + }); + prebuilt::inventory_outside(&prebuilt_dir, path) + .unwrap_or_else(|error| panic!("tsjs: invalid inventory path: {error}")); + inventory::read_inventory(path, &expected, expected_source) + .unwrap_or_else(|error| panic!("tsjs: invalid inventory: {error}")) + }); + prebuilt::copy_checked( + &expected, + &prebuilt_dir, + &bundle_dir, + inventory.as_ref().map(|checked| &checked.inventory), + hook, + ) + .unwrap_or_else(|error| panic!("tsjs: invalid prebuilt inventory inputs: {error}")); + if let Some(inventory) = inventory { + // Cargo JSON build-script messages expose the prepared input + // identity to the later release evidence collector. + println!( + "cargo:rustc-env=TSJS_INPUT_INVENTORY_SHA256={}", + inventory.raw_sha256 + ); + } + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + panic!("tsjs: checked prebuilt copying requires a supported Linux or macOS build host"); } else { build_bundles(&ts_dir, &bundle_dir); } @@ -84,11 +170,6 @@ fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { ts_dir.with_file_name("dist").display() ); - assert!( - !env::var(SKIP_BUILD_VAR).is_ok_and(|value| value == "1"), - "tsjs: {SKIP_BUILD_VAR} is no longer supported because it embedded whatever \ - dist/ held. {how_to_prebuild}" - ); assert!( ts_dir.join("package.json").is_file(), "tsjs: {} not found. {how_to_prebuild}", @@ -208,27 +289,6 @@ fn validate_bundle_dir(expected: &[String], dir: &Path) { } } -fn copy_prebuilt_bundles(expected: &[String], prebuilt_dir: &Path, bundle_dir: &Path) { - if bundle_dir.exists() { - fs::remove_dir_all(bundle_dir) - .unwrap_or_else(|err| panic!("tsjs: failed to clean {}: {err}", bundle_dir.display())); - } - fs::create_dir_all(bundle_dir) - .unwrap_or_else(|err| panic!("tsjs: failed to create {}: {err}", bundle_dir.display())); - for id in expected { - let file_name = bundle_file_name(id); - let source = prebuilt_dir.join(&file_name); - let target = bundle_dir.join(&file_name); - fs::copy(&source, &target).unwrap_or_else(|err| { - panic!( - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ) - }); - } -} - fn write_module_table(expected: &[String], bundle_dir: &Path, out_dir: &Path) { let mut codegen = String::new(); codegen.push_str("// Auto-generated by build.rs - DO NOT EDIT\n\n"); diff --git a/crates/trusted-server-js/build/bundle_set.rs b/crates/trusted-server-js/build/bundle_set.rs index ec9b52ab34..ec80d606e0 100644 --- a/crates/trusted-server-js/build/bundle_set.rs +++ b/crates/trusted-server-js/build/bundle_set.rs @@ -14,6 +14,14 @@ pub(crate) fn bundle_file_name(id: &str) -> String { format!("tsjs-{id}.js") } +/// Whether the ID is an ASCII identifier safe in a file and generated Rust. +pub(crate) fn safe_module_id(id: &str) -> bool { + !id.is_empty() + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + /// Return the module IDs `build-all.mjs` builds from `lib_src`. /// /// Mirrors the discovery in `build-all.mjs`: `core` plus every @@ -51,6 +59,17 @@ pub(crate) fn expected_module_ids(lib_src: &Path) -> Result, String> let mut ids = Vec::with_capacity(integrations.len() + 1); ids.push(CORE_MODULE_ID.to_owned()); ids.extend(integrations); + if ids.iter().any(|id| !safe_module_id(id)) { + return Err("module IDs must be safe ASCII identifiers".to_owned()); + } + if ids + .iter() + .filter(|id| id.as_str() == CORE_MODULE_ID) + .count() + != 1 + { + return Err("duplicate core module ID".to_owned()); + } Ok(ids) } @@ -71,12 +90,14 @@ pub(crate) fn scan_bundle_dir(dir: &Path) -> Result, String> .strip_prefix("tsjs-") .and_then(|stem| stem.strip_suffix(".js")) else { - continue; + return Err(format!("unexpected file in bundle directory: {file_name}")); }; - let len = entry - .metadata() - .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))? - .len(); + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))?; + if !safe_module_id(id) || !metadata.is_file() { + return Err(format!("unsafe or nonregular bundle: {file_name}")); + } + let len = metadata.len(); found.push((id.to_owned(), len)); } Ok(found) @@ -106,6 +127,10 @@ pub(crate) fn check_bundle_set(expected: &[String], found: &[(String, u64)]) -> unexpected.sort_unstable(); let mut problems = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + if found.iter().any(|(id, _)| !ids.insert(id)) { + problems.push("duplicate bundle IDs".to_owned()); + } if !missing.is_empty() { problems.push(format!("missing bundles: {missing:?}")); } diff --git a/crates/trusted-server-js/build/inventory.rs b/crates/trusted-server-js/build/inventory.rs new file mode 100644 index 0000000000..d0c1d13273 --- /dev/null +++ b/crates/trusted-server-js/build/inventory.rs @@ -0,0 +1,297 @@ +//! Strict, bounded parsing of the prebuilt input inventory. +//! +//! Contribution, tool and material values are opaque here: composition joins +//! belong to the release producer, rather than the Rust input consumer. + +use std::collections::BTreeSet; +use std::fmt; +#[cfg(not(unix))] +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Read as _}; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use serde::Deserialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest as _, Sha256}; + +use super::bundle_set::{bundle_file_name, check_bundle_set, safe_module_id}; + +const MAX_INVENTORY: u64 = 1024 * 1024; +// Release producers must enforce the same per-module input budget. +pub(crate) const MAX_BUNDLE: u64 = 32 * 1024 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Inventory { + schema_version: u64, + source_commit: String, + pub(crate) modules: Vec, + tools: Vec, + materials: Vec, +} + +/// Parsed input and the digest of its exact bounded raw bytes. +pub(crate) struct CheckedInventory { + pub(crate) inventory: Inventory, + pub(crate) raw_sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Module { + pub(crate) id: String, + pub(crate) filename: String, + pub(crate) size: u64, + pub(crate) sha256: String, + contributions: Vec, +} + +pub(crate) fn invalid(message: impl fmt::Display) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.to_string()) +} + +pub(crate) fn read_inventory( + path: &Path, + expected: &[String], + source: Option<&str>, +) -> io::Result { + #[cfg(unix)] + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path)?; + #[cfg(not(unix))] + let file = File::open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(invalid("inventory must be a regular file")); + } + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(invalid("inventory must have exactly one hard link")); + } + let mut bytes = Vec::new(); + (&file).take(MAX_INVENTORY + 1).read_to_end(&mut bytes)?; + #[cfg(unix)] + { + let after = file.metadata()?; + if ( + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + metadata.nlink(), + ) != ( + after.len(), + after.mtime(), + after.mtime_nsec(), + after.ctime(), + after.ctime_nsec(), + after.nlink(), + ) { + return Err(invalid("inventory changed while reading")); + } + } + if bytes.len() as u64 > MAX_INVENTORY { + return Err(invalid("inventory exceeds 1 MiB")); + } + let text = std::str::from_utf8(&bytes).map_err(|_| invalid("inventory must be UTF-8"))?; + validate_numbers(text)?; + let mut parser = serde_json::Deserializer::from_str(text); + let value = StrictValue(0).deserialize(&mut parser).map_err(invalid)?; + parser.end().map_err(invalid)?; + let inventory: Inventory = serde_json::from_value(value).map_err(invalid)?; + if inventory.schema_version != 1 + || !lower_hex(&inventory.source_commit, 40) + || source.is_some_and(|source| source != inventory.source_commit) + { + return Err(invalid("inventory schema version or source does not match")); + } + let mut previous: Option<&str> = None; + let mut filenames = BTreeSet::new(); + for module in &inventory.modules { + if !safe_module_id(&module.id) + || module.filename != bundle_file_name(&module.id) + || !filenames.insert(&module.filename) + || previous.is_some_and(|id| id >= module.id.as_str()) + || module.size == 0 + || module.size > MAX_BUNDLE + || !lower_hex(&module.sha256, 64) + { + return Err(invalid( + "inventory module identity, order, size or digest is invalid", + )); + } + previous = Some(&module.id); + } + let found = inventory + .modules + .iter() + .map(|module| (module.id.clone(), module.size)) + .collect::>(); + check_bundle_set(expected, &found).map_err(invalid)?; + // Required arrays were parsed strictly, but are not composition evidence. + let _ = (&inventory.tools, &inventory.materials); + for module in &inventory.modules { + let _ = &module.contributions; + } + Ok(CheckedInventory { + inventory, + raw_sha256: hex::encode(Sha256::digest(&bytes)), + }) +} + +fn lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +// serde_json normally converts overflowing integer literals into floats. +// Reject those before parsing, including numbers in otherwise opaque values. +fn validate_numbers(text: &str) -> io::Result<()> { + let bytes = text.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'"' => { + index += 1; + while index < bytes.len() && bytes[index] != b'"' { + if bytes[index] == b'\\' { + index += 1; + } + index += 1; + } + } + b'-' | b'0'..=b'9' => { + let start = index; + while index < bytes.len() + && matches!(bytes[index], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + { + index += 1; + } + let number = &text[start..index]; + if number.contains(['.', 'e', 'E']) { + if !number.parse::().is_ok_and(f64::is_finite) { + return Err(invalid("inventory number overflows")); + } + } else if (number.starts_with('-') && number.parse::().is_err()) + || (!number.starts_with('-') && number.parse::().is_err()) + { + return Err(invalid("inventory integer overflows")); + } + continue; + } + _ => {} + } + index += 1; + } + Ok(()) +} + +struct StrictValue(usize); + +impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("strict JSON with unique keys and depth at most 64") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite number")) + } + + fn visit_seq>(self, mut sequence: A) -> Result { + if self.0 >= 64 { + return Err(serde::de::Error::custom("JSON depth exceeds 64")); + } + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(StrictValue(self.0 + 1))? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map>(self, mut object: A) -> Result { + if self.0 >= 64 { + return Err(serde::de::Error::custom("JSON depth exceeds 64")); + } + let mut values = Map::new(); + while let Some(key) = object.next_key::()? { + if values.contains_key(&key) { + return Err(serde::de::Error::custom("duplicate JSON key")); + } + let value = object.next_value_seed(StrictValue(self.0 + 1))?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::fs; + use std::os::unix::fs::symlink; + + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + #[test] + fn rejects_inventory_leaf_links_even_when_pointing_at_valid_bytes() { + let root = tempdir().expect("should create inventory fixture"); + let real = root.path().join("real.json"); + let link = root.path().join("inventory.json"); + fs::write( + &real, + serde_json::to_vec(&json!({ + "schema_version": 1, "source_commit": "a".repeat(40), + "modules": [], "tools": [], "materials": [] + })) + .expect("should serialize inventory"), + ) + .expect("should write inventory"); + symlink(&real, &link).expect("should link inventory"); + + let result = read_inventory(&link, &[], None); + + assert!( + result.is_err(), + "should reject links at the actual inventory read" + ); + } +} diff --git a/crates/trusted-server-js/build/prebuilt.rs b/crates/trusted-server-js/build/prebuilt.rs new file mode 100644 index 0000000000..8224d9b8ba --- /dev/null +++ b/crates/trusted-server-js/build/prebuilt.rs @@ -0,0 +1,288 @@ +//! Copy checked prebuilt inputs through held directory handles. +//! +//! Cargo owns `OUT_DIR` and its parents. Other processes able to write that +//! private build directory are outside this input-validation trust boundary. +//! Source parents may contain links; a held directory descriptor anchors all +//! reads, and reopening the path after copying must resolve to the same inode. + +use std::collections::BTreeMap; +use std::ffi::{CStr, CString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read as _, Write as _}; +use std::os::fd::{AsRawFd as _, FromRawFd as _}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use sha2::{Digest as _, Sha256}; + +use super::bundle_set::{bundle_file_name, check_bundle_set, safe_module_id}; +use super::inventory::{Inventory, MAX_BUNDLE, invalid}; + +#[derive(Debug, Eq, PartialEq)] +struct Identity { + device: u64, + inode: u64, + size: u64, + modified: (i64, i64), + changed: (i64, i64), + links: u64, +} + +fn identity(file: &File) -> io::Result { + let metadata = file.metadata()?; + Ok(Identity { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + modified: (metadata.mtime(), metadata.mtime_nsec()), + changed: (metadata.ctime(), metadata.ctime_nsec()), + links: metadata.nlink(), + }) +} + +fn open_directory(path: &Path) -> io::Result { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) +} + +fn open_file(directory: &File, name: &str) -> io::Result { + let name = CString::new(name).map_err(|_| invalid("unsafe bundle filename"))?; + // SAFETY: the descriptor and C string remain alive for the syscall; a + // successful descriptor is immediately transferred into an owning File. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openat returned a fresh descriptor owned solely by this File. + let file = unsafe { File::from_raw_fd(descriptor) }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(invalid( + "bundle must be a regular file with exactly one hard link", + )); + } + Ok(file) +} + +struct DirectoryStream(*mut libc::DIR); + +impl Drop for DirectoryStream { + fn drop(&mut self) { + // SAFETY: this stream owns the successful fdopendir result. + unsafe { + libc::closedir(self.0); + } + } +} + +fn names(directory: &File, maximum: usize) -> io::Result> { + // Opening '.' creates a separate enumeration offset for every scan. + let dot = c"."; + // SAFETY: the descriptor and static C string are valid for this syscall. + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: descriptor is fresh; ownership passes to the stream on success. + let stream = unsafe { libc::fdopendir(descriptor) }; + if stream.is_null() { + let error = io::Error::last_os_error(); + // SAFETY: fdopendir failed and did not take ownership. + unsafe { + libc::close(descriptor); + } + return Err(error); + } + let stream = DirectoryStream(stream); + let mut result = Vec::new(); + loop { + clear_errno(); + // SAFETY: the stream is owned, live, and used on this thread only. + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(0) { + return Err(error); + } + break; + } + // SAFETY: readdir supplies a NUL-terminated name valid until next call. + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let name = name + .to_str() + .map_err(|_| invalid("bundle filename must be UTF-8"))?; + if name != "." && name != ".." { + if result.len() >= maximum { + return Err(invalid("unexpected extra bundle directory entry")); + } + result.push(name.to_owned()); + } + } + result.sort(); + Ok(result) +} + +fn clear_errno() { + // SAFETY: these functions return the current thread's errno storage. + #[cfg(target_os = "macos")] + unsafe { + *libc::__error() = 0; + } + #[cfg(target_os = "linux")] + unsafe { + *libc::__errno_location() = 0; + } +} + +fn snapshot(directory: &File, expected: &[String]) -> io::Result> { + let mut snapshot = BTreeMap::new(); + let mut found = Vec::new(); + for name in names(directory, expected.len())? { + let id = name + .strip_prefix("tsjs-") + .and_then(|stem| stem.strip_suffix(".js")) + .filter(|id| safe_module_id(id)) + .ok_or_else(|| invalid("unexpected or unsafe bundle filename"))?; + let file = open_file(directory, &name)?; + let identity = identity(&file)?; + found.push((id.to_owned(), identity.size)); + snapshot.insert(name, identity); + } + check_bundle_set(expected, &found).map_err(invalid)?; + Ok(snapshot) +} + +fn prepare_private(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)?, + Ok(_) => { + return Err(invalid( + "private bundle destination must be a directory, never a link", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + fs::DirBuilder::new().mode(0o700).create(path) +} + +fn copy_one(source: &mut File, destination: &Path, size: u64) -> io::Result { + let mut target = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(destination)?; + let mut hasher = Sha256::new(); + let mut reader = source.take(size + 1); + let mut buffer = [0; 64 * 1024]; + let mut length = 0u64; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + length += count as u64; + if length > size { + return Err(invalid("inventory bundle size mismatch during copy")); + } + hasher.update(&buffer[..count]); + target.write_all(&buffer[..count])?; + } + if length != size { + return Err(invalid("inventory bundle size mismatch during copy")); + } + target.flush()?; + Ok(hex::encode(hasher.finalize())) +} + +pub(crate) fn copy_checked( + expected: &[String], + source_path: &Path, + destination: &Path, + inventory: Option<&Inventory>, + mut hook: impl FnMut(&Path, &Path), +) -> io::Result<()> { + let source = open_directory(source_path)?; + let source_identity = identity(&source)?; + let before = snapshot(&source, expected)?; + prepare_private(destination)?; + let mut digests = BTreeMap::new(); + for id in expected { + let name = bundle_file_name(id); + let mut file = open_file(&source, &name)?; + if before.get(&name) != Some(&identity(&file)?) { + return Err(invalid("source bundle identity changed before copy")); + } + let module = inventory + .and_then(|inventory| inventory.modules.iter().find(|module| module.id == *id)); + let size = module.map_or(file.metadata()?.len(), |module| module.size); + if size > MAX_BUNDLE { + return Err(invalid("bundle exceeds 32 MiB")); + } + let digest = copy_one(&mut file, &destination.join(&name), size)?; + if module.is_some_and(|module| module.sha256 != digest) { + return Err(invalid("inventory bundle digest mismatch")); + } + if before.get(&name) != Some(&identity(&file)?) { + return Err(invalid("source bundle changed during copy")); + } + digests.insert(name, (size, digest)); + } + // Crate-private deterministic race injection; production supplies a no-op. + hook(source_path, destination); + if snapshot(&source, expected)? != before + || identity(&source)? != source_identity + || identity(&open_directory(source_path)?)? != source_identity + { + return Err(invalid( + "source directory or bundle identity changed during copy", + )); + } + let private = open_directory(destination)?; + snapshot(&private, expected)?; + for (name, (size, digest)) in digests { + let mut file = open_file(&private, &name)?; + let mut bytes = Vec::new(); + (&mut file).take(size + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 != size || hex::encode(Sha256::digest(&bytes)) != digest { + return Err(invalid( + "private copied bundle does not match inventory input bytes", + )); + } + } + Ok(()) +} + +pub(crate) fn inventory_outside(source: &Path, inventory: &Path) -> io::Result<()> { + // Comparing canonical paths prevents aliases from placing metadata inside + // the exact bundle-only directory. The bytes are read separately once. + if inventory + .canonicalize()? + .starts_with(source.canonicalize()?) + { + return Err(invalid( + "inventory must be outside the exact bundle directory", + )); + } + // Reject a link at the inventory leaf as well as nonregular metadata. + let metadata = fs::symlink_metadata(inventory)?; + if !metadata.is_file() || metadata.nlink() != 1 || inventory.as_os_str().as_bytes().is_empty() { + return Err(invalid("inventory must be a regular single-link file")); + } + Ok(()) +} diff --git a/crates/trusted-server-js/tests/release_prebuilt.rs b/crates/trusted-server-js/tests/release_prebuilt.rs new file mode 100644 index 0000000000..4187db20db --- /dev/null +++ b/crates/trusted-server-js/tests/release_prebuilt.rs @@ -0,0 +1,728 @@ +#![cfg(any(target_os = "linux", target_os = "macos"))] +#![allow( + dead_code, + reason = "actual build script is included for isolated process fixtures" +)] + +#[path = "../build.rs"] +mod build_script; + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::os::unix::ffi::OsStringExt as _; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::PathBuf; +use std::process::{Command, Output}; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use tempfile::{TempDir, tempdir}; + +const SOURCE: &str = "0123456789abcdef0123456789abcdef01234567"; +const BYTES: &[u8] = b"console.log('example');"; + +struct Fixture { + root: TempDir, + inventory: Value, +} + +impl Fixture { + fn new() -> Self { + let root = tempdir().expect("should create fixture root"); + for name in ["lib/src/integrations", "bundles", "out", "bin"] { + fs::create_dir_all(root.path().join(name)).expect("should create fixture directory"); + } + fs::write(root.path().join("bundles/tsjs-core.js"), BYTES) + .expect("should write core bundle"); + let npm = root.path().join("bin/npm"); + fs::write( + &npm, + "#!/bin/sh\n: > \"$CARGO_MANIFEST_DIR/npm-called\"\nexit 97\n", + ) + .expect("should write failing npm"); + fs::set_permissions(npm, fs::Permissions::from_mode(0o755)) + .expect("should make npm executable"); + Self { + root, + inventory: json!({ + "schema_version": 1, "source_commit": SOURCE, + "modules": [{ "id": "core", "filename": "tsjs-core.js", "size": BYTES.len(), + "sha256": hex::encode(Sha256::digest(BYTES)), "contributions": [] }], + "tools": [], "materials": [] + }), + } + } + + fn path(&self, name: &str) -> PathBuf { + self.root.path().join(name) + } + + fn command(&self) -> Command { + let mut command = Command::new(env::current_exe().expect("should locate test executable")); + command + .args(["--exact", "fixture_entry", "--ignored", "--nocapture"]) + .env_clear() + .env("CARGO_MANIFEST_DIR", self.root.path()) + .env("OUT_DIR", self.path("out")) + .env("PATH", self.path("bin")) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .env("TSJS_PREBUILT_DIR", self.path("bundles")) + .env("TSJS_PREBUILT_INVENTORY", self.path("inventory.json")); + command + } + + fn write_inventory(&self) { + fs::write( + self.path("inventory.json"), + serde_json::to_vec(&self.inventory).expect("should serialize inventory"), + ) + .expect("should write inventory"); + } + + fn rejected(&self, output: &Output, diagnostic: &str) { + let output_text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !output.status.success(), + "should reject fixture: {output_text}" + ); + assert!( + output_text.contains(diagnostic), + "should report {diagnostic}: {output_text}" + ); + assert!( + !self.path("npm-called").exists(), + "should reject before invoking npm" + ); + assert!( + !self.path("out/tsjs_modules.rs").exists(), + "should not generate a rejected module table" + ); + } +} + +#[test] +#[ignore = "entry point invoked only by fixture subprocesses"] +fn fixture_entry() { + build_script::run_with_hook( + |source, copied| match env::var("FIXTURE_MUTATION").as_deref() { + Ok("source") => fs::write(source.join("tsjs-core.js"), b"changed bytes") + .expect("should mutate source"), + Ok("swap") => { + fs::remove_file(source.join("tsjs-core.js")).expect("should remove source"); + symlink(copied.join("tsjs-core.js"), source.join("tsjs-core.js")) + .expect("should swap source to link"); + } + Ok("private") => fs::write(copied.join("tsjs-core.js"), b"changed bytes") + .expect("should mutate copied bytes"), + Ok("extra") => { + fs::write(source.join("notes.txt"), b"extra").expect("should add source name") + } + Ok("identity") => { + fs::write(source.join("replacement.js"), BYTES) + .expect("should write replacement bytes"); + fs::rename(source.join("replacement.js"), source.join("tsjs-core.js")) + .expect("should replace source identity"); + } + Ok("directory") => { + fs::rename(source, source.with_extension("original")) + .expect("should move source directory"); + fs::create_dir(source).expect("should replace source directory"); + fs::write(source.join("tsjs-core.js"), BYTES) + .expect("should replace identical source bytes"); + } + Ok("parent") => { + let parent = source.parent().expect("should have source parent"); + let root = parent.parent().expect("should have fixture root"); + fs::remove_file(parent).expect("should remove parent link"); + symlink(root.join("other"), parent).expect("should redirect parent link"); + } + _ => {} + }, + ); +} + +#[test] +fn release_accepts_checked_bundles_without_npm() { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!( + output.status.success(), + "should accept checked bundles: {output:?}" + ); + assert_eq!( + fs::read(fixture.path("out/tsjs-dist/tsjs-core.js")).expect("should read copied bundle"), + BYTES, + "should copy authenticated bytes" + ); + assert!( + !fixture.path("npm-called").exists(), + "should never invoke npm in release mode" + ); +} + +#[test] +fn rejects_any_skip_presence_before_prebuilt_selection() { + for value in [ + OsString::from(""), + OsString::from("0"), + OsString::from("1"), + OsString::from_vec(vec![0xff]), + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env("TSJS_SKIP_BUILD", value) + .output() + .expect("should execute fixture"); + fixture.rejected(&output, "TSJS_SKIP_BUILD"); + } +} + +#[test] +fn release_requires_both_prebuilt_inputs() { + for missing in ["TSJS_PREBUILT_DIR", "TSJS_PREBUILT_INVENTORY"] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove(missing) + .output() + .expect("should execute fixture"); + fixture.rejected(&output, missing); + } +} + +#[test] +fn rejects_inventory_identity_and_module_tampering() { + for case in [ + "source", + "digest", + "size", + "duplicate", + "filename", + "id", + "missing", + "empty", + "extra", + "order", + "unknown", + "version", + "tools", + "bool_size", + "float_size", + "negative_size", + "module_unknown", + "missing_array", + "size_bound", + "contributions", + ] { + let mut fixture = Fixture::new(); + match case { + "source" => fixture.inventory["source_commit"] = json!("a".repeat(40)), + "digest" => fixture.inventory["modules"][0]["sha256"] = json!("a".repeat(64)), + "size" => fixture.inventory["modules"][0]["size"] = json!(BYTES.len() + 1), + "duplicate" => { + let module = fixture.inventory["modules"][0].clone(); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .push(module); + } + "filename" => fixture.inventory["modules"][0]["filename"] = json!("../tsjs-core.js"), + "id" => fixture.inventory["modules"][0]["id"] = json!("../core"), + "missing" => fixture.inventory["modules"] = json!([]), + "empty" => fixture.inventory["modules"][0]["size"] = json!(0), + "extra" | "order" => { + let mut module = fixture.inventory["modules"][0].clone(); + module["id"] = json!("aaa"); + module["filename"] = json!("tsjs-aaa.js"); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .push(module); + } + "unknown" => fixture.inventory["unknown"] = json!(1), + "version" => fixture.inventory["schema_version"] = json!(true), + "tools" => fixture.inventory["tools"] = json!({}), + "bool_size" => fixture.inventory["modules"][0]["size"] = json!(true), + "float_size" => fixture.inventory["modules"][0]["size"] = json!(1.0), + "negative_size" => fixture.inventory["modules"][0]["size"] = json!(-1), + "module_unknown" => fixture.inventory["modules"][0]["unknown"] = json!(1), + "missing_array" => { + fixture + .inventory + .as_object_mut() + .expect("should access inventory") + .remove("materials"); + } + "size_bound" => fixture.inventory["modules"][0]["size"] = json!(32 * 1024 * 1024 + 1), + "contributions" => fixture.inventory["modules"][0]["contributions"] = json!({}), + _ => unreachable!("should use listed case"), + } + fixture.write_inventory(); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "inventory", + ); + } +} + +#[test] +fn rejects_inexact_source_directories() { + for case in [ + "missing", + "empty", + "extra", + "unrelated", + "directory", + "link", + "hardlink", + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + match case { + "missing" => { + fs::remove_file(fixture.path("bundles/tsjs-core.js")).expect("should remove bundle") + } + "empty" => { + fs::write(fixture.path("bundles/tsjs-core.js"), []).expect("should empty bundle") + } + "extra" | "unrelated" => fs::write( + fixture.path(if case == "extra" { + "bundles/tsjs-extra.js" + } else { + "bundles/notes.txt" + }), + BYTES, + ) + .expect("should add file"), + "directory" => { + fs::create_dir(fixture.path("bundles/extra")).expect("should add directory") + } + "link" => { + fs::remove_file(fixture.path("bundles/tsjs-core.js")) + .expect("should remove bundle"); + symlink( + fixture.path("inventory.json"), + fixture.path("bundles/tsjs-core.js"), + ) + .expect("should link bundle"); + } + "hardlink" => fs::hard_link( + fixture.path("bundles/tsjs-core.js"), + fixture.path("alias.js"), + ) + .expect("should hardlink bundle"), + _ => unreachable!("should use listed case"), + } + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "tsjs", + ); + } +} + +#[test] +fn rejects_ambiguous_or_unbounded_json() { + for case in [ + "duplicate", + "nested", + "nonfinite", + "overflow", + "integer_overflow", + "depth", + "oversize", + "bom", + "utf8", + ] { + let fixture = Fixture::new(); + let valid = serde_json::to_string(&fixture.inventory).expect("should serialize fixture"); + let raw = match case { + "duplicate" => valid + .replacen("{", "{\"schema_version\":1,", 1) + .into_bytes(), + "nested" => valid + .replace("\"tools\":[]", "\"tools\":[{\"a\":1,\"a\":2}]") + .into_bytes(), + "nonfinite" => valid + .replace("\"tools\":[]", "\"tools\":[NaN]") + .into_bytes(), + "overflow" => valid + .replace("\"tools\":[]", "\"tools\":[1e9999]") + .into_bytes(), + "integer_overflow" => valid + .replace("\"tools\":[]", "\"tools\":[18446744073709551616]") + .into_bytes(), + "depth" => valid + .replace( + "\"tools\":[]", + &format!("\"tools\":[{}0{}]", "[".repeat(65), "]".repeat(65)), + ) + .into_bytes(), + "oversize" => vec![b' '; 1024 * 1024 + 1], + "bom" => [vec![0xef, 0xbb, 0xbf], valid.into_bytes()].concat(), + "utf8" => vec![0xff], + _ => unreachable!("should use listed case"), + }; + fs::write(fixture.path("inventory.json"), raw).expect("should write malformed inventory"); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "inventory", + ); + } +} + +#[test] +fn development_prebuilt_without_inventory_remains_supported() { + let fixture = Fixture::new(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TSJS_PREBUILT_INVENTORY") + .output() + .expect("should execute fixture"); + assert!( + output.status.success(), + "should support development prebuilt bundles: {output:?}" + ); +} + +#[test] +fn rejects_source_or_copied_bytes_changed_during_copy() { + for mutation in [ + "source", + "swap", + "private", + "extra", + "identity", + "directory", + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env("FIXTURE_MUTATION", mutation) + .output() + .expect("should execute fixture"); + fixture.rejected(&output, "tsjs"); + } +} + +#[test] +fn rejects_release_controls_and_relative_paths_before_npm() { + for (variable, value) in [ + ("TRUSTED_SERVER__RELEASE_BUILD", ""), + ("TRUSTED_SERVER__RELEASE_BUILD", "0"), + ("TRUSTED_SERVER__RELEASE_BUILD", "true"), + ("TRUSTED_SERVER__GIT_VERSION", ""), + ("TRUSTED_SERVER__GIT_VERSION", "abcdef"), + ("TSJS_PREBUILT_DIR", "bundles"), + ("TSJS_PREBUILT_INVENTORY", "inventory.json"), + ] { + let fixture = Fixture::new(); + fixture.write_inventory(); + fixture.rejected( + &fixture + .command() + .env(variable, value) + .output() + .expect("should execute fixture"), + "tsjs", + ); + } + let fixture = Fixture::new(); + fixture.write_inventory(); + fixture.rejected( + &fixture + .command() + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .output() + .expect("should execute fixture"), + "TRUSTED_SERVER__GIT_VERSION", + ); +} + +#[test] +fn development_inventory_checks_explicit_source_and_bundle_bytes() { + for case in ["source", "bytes"] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let mut command = fixture.command(); + command.env_remove("TRUSTED_SERVER__RELEASE_BUILD"); + if case == "source" { + command.env("TRUSTED_SERVER__GIT_VERSION", "a".repeat(40)); + } else { + fs::write(fixture.path("bundles/tsjs-core.js"), b"tampered bytes") + .expect("should change bundle"); + } + fixture.rejected( + &command.output().expect("should execute fixture"), + "inventory", + ); + } + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__GIT_VERSION") + .output() + .expect("should execute fixture"); + assert!( + output.status.success(), + "should validate development inventory without claiming approved source: {output:?}" + ); +} + +#[test] +fn rejects_skip_presence_in_development_source_and_prebuilt_modes() { + for prebuilt in [true, false] { + for value in ["", "0", "1"] { + let fixture = Fixture::new(); + let mut command = fixture.command(); + command + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TSJS_PREBUILT_INVENTORY") + .env("TSJS_SKIP_BUILD", value); + if !prebuilt { + command.env_remove("TSJS_PREBUILT_DIR"); + } + fixture.rejected( + &command.output().expect("should execute fixture"), + "TSJS_SKIP_BUILD", + ); + } + } +} + +#[test] +fn development_source_build_uses_fresh_npm_output() { + let fixture = Fixture::new(); + fs::write(fixture.path("lib/package.json"), json!({}).to_string()) + .expect("should write package fixture"); + fs::create_dir(fixture.path("lib/node_modules")) + .expect("should create installed dependencies fixture"); + fs::create_dir(fixture.path("dist")).expect("should create stale old dist"); + fs::write(fixture.path("dist/tsjs-core.js"), b"stale bytes") + .expect("should write stale bundle"); + fs::write(fixture.path("bin/npm"), "#!/bin/sh\n: > \"$CARGO_MANIFEST_DIR/npm-called\"\nfor argument in \"$@\"; do output=\"$argument\"; done\n/bin/mkdir -p \"$output\"\nprintf 'fresh source bytes' > \"$output/tsjs-core.js\"\n").expect("should write source builder fixture"); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TSJS_PREBUILT_DIR") + .env_remove("TSJS_PREBUILT_INVENTORY") + .output() + .expect("should execute fixture"); + assert!( + output.status.success(), + "should support ordinary fresh development build: {output:?}" + ); + assert_eq!( + fs::read(fixture.path("out/tsjs-dist/tsjs-core.js")).expect("should read fresh bundle"), + b"fresh source bytes", + "should embed current npm output" + ); + assert!( + fixture.path("npm-called").exists(), + "should invoke npm for development source build" + ); +} + +#[test] +fn inventory_sorting_preserves_core_first_runtime_order() { + let mut fixture = Fixture::new(); + fs::create_dir(fixture.path("lib/src/integrations/aaa")).expect("should create integration"); + fs::write( + fixture.path("lib/src/integrations/aaa/index.ts"), + b"export {};", + ) + .expect("should write source entry"); + fs::write(fixture.path("bundles/tsjs-aaa.js"), BYTES).expect("should write integration bundle"); + let mut module = fixture.inventory["modules"][0].clone(); + module["id"] = json!("aaa"); + module["filename"] = json!("tsjs-aaa.js"); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .insert(0, module); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!( + output.status.success(), + "should accept sorted inventory independently of runtime order: {output:?}" + ); + let generated = fs::read_to_string(fixture.path("out/tsjs_modules.rs")) + .expect("should read generated code"); + assert!( + generated.find("id: \"core\"") < generated.find("id: \"aaa\""), + "should retain core first runtime order" + ); +} + +#[test] +fn rejects_inventory_inside_source_and_private_destination_links() { + for case in ["inside", "link", "file"] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let mut command = fixture.command(); + match case { + "inside" => { + fs::copy( + fixture.path("inventory.json"), + fixture.path("bundles/inventory.json"), + ) + .expect("should place inventory inside source"); + command.env( + "TSJS_PREBUILT_INVENTORY", + fixture.path("bundles/inventory.json"), + ); + } + "link" => symlink(fixture.path("bundles"), fixture.path("out/tsjs-dist")) + .expect("should link private destination"), + "file" => fs::write(fixture.path("out/tsjs-dist"), b"existing file") + .expect("should write conflicting destination"), + _ => unreachable!("should use listed case"), + } + fixture.rejected(&command.output().expect("should execute fixture"), "tsjs"); + assert_eq!( + fs::read(fixture.path("bundles/tsjs-core.js")).expect("should preserve input bundle"), + BYTES, + "should preserve source when rejecting destination" + ); + } +} + +#[test] +fn emits_rerun_directives_for_inventory_and_release_inputs() { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!(output.status.success(), "should accept checked fixture"); + let stdout = String::from_utf8(output.stdout).expect("should capture UTF-8 directives"); + for variable in [ + "TSJS_SKIP_BUILD", + "TSJS_PREBUILT_DIR", + "TSJS_PREBUILT_INVENTORY", + "TRUSTED_SERVER__RELEASE_BUILD", + "TRUSTED_SERVER__GIT_VERSION", + ] { + assert!( + stdout.contains(&format!("cargo:rerun-if-env-changed={variable}")), + "should watch {variable}" + ); + } + for path in [fixture.path("inventory.json"), fixture.path("bundles")] { + assert!( + stdout.contains(&format!("cargo:rerun-if-changed={}", path.display())), + "should watch prebuilt input path" + ); + } +} + +#[test] +fn exposes_raw_inventory_digest_for_later_cargo_build_evidence() { + let fixture = Fixture::new(); + let mut raw = serde_json::to_vec(&fixture.inventory).expect("should serialize inventory"); + raw.extend_from_slice(b"\n \n"); + fs::write(fixture.path("inventory.json"), &raw).expect("should write raw inventory"); + + let output = fixture.command().output().expect("should execute fixture"); + + assert!( + output.status.success(), + "should accept whitespace-preserving inventory" + ); + let stdout = String::from_utf8(output.stdout).expect("should capture UTF-8 directives"); + assert!( + stdout.contains(&format!( + "cargo:rustc-env=TSJS_INPUT_INVENTORY_SHA256={}", + hex::encode(Sha256::digest(&raw)) + )), + "should report digest of the exact held inventory bytes: {stdout}" + ); +} + +#[test] +fn development_inventory_rejects_present_non_unicode_source() { + let fixture = Fixture::new(); + fixture.write_inventory(); + + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env( + "TRUSTED_SERVER__GIT_VERSION", + OsString::from_vec(vec![0xff]), + ) + .output() + .expect("should execute fixture"); + + fixture.rejected(&output, "source"); +} + +#[test] +fn permits_exact_inventory_size_and_depth_boundaries() { + let mut fixture = Fixture::new(); + let mut value = json!(0); + for _ in 0..62 { + value = json!([value]); + } + fixture.inventory["tools"] = json!([value]); + let mut raw = + serde_json::to_vec(&fixture.inventory).expect("should serialize boundary inventory"); + raw.resize(1024 * 1024, b' '); + fs::write(fixture.path("inventory.json"), raw).expect("should write boundary inventory"); + + let output = fixture.command().output().expect("should execute fixture"); + + assert!( + output.status.success(), + "should accept inventory at byte and container-depth limits: {output:?}" + ); +} + +#[test] +fn permits_parent_links_when_the_held_source_identity_stays_fixed() { + let fixture = Fixture::new(); + fixture.write_inventory(); + symlink(fixture.root.path(), fixture.path("parent-alias")).expect("should create parent alias"); + + let output = fixture + .command() + .env("TSJS_PREBUILT_DIR", fixture.path("parent-alias/bundles")) + .output() + .expect("should execute fixture"); + + assert!( + output.status.success(), + "should anchor copying to unchanged directory identity through a parent alias: {output:?}" + ); +} + +#[test] +fn rejects_parent_link_redirection_to_byte_identical_directory() { + let fixture = Fixture::new(); + fixture.write_inventory(); + symlink(fixture.root.path(), fixture.path("parent-alias")).expect("should create parent alias"); + fs::create_dir_all(fixture.path("other/bundles")) + .expect("should create other source directory"); + fs::write(fixture.path("other/bundles/tsjs-core.js"), BYTES) + .expect("should write identical other bytes"); + + let output = fixture + .command() + .env("TSJS_PREBUILT_DIR", fixture.path("parent-alias/bundles")) + .env("FIXTURE_MUTATION", "parent") + .output() + .expect("should execute fixture"); + + fixture.rejected(&output, "identity changed"); +} From d66812922e7b9479083012601445efb151e3fbf0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 21:10:07 +0530 Subject: [PATCH 021/153] Record checked JS input verification --- .../2026-10-13-attested-release-implementation.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 39de15e8d4..7263d5e98a 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -120,11 +120,11 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Modify:** `crates/trusted-server-js/build.rs`, `build/bundle_set.rs`, `src/lib.rs` test inclusion. **Create:** `crates/trusted-server-js/tests/release_prebuilt.rs`. -- [ ] Write process/fixture tests for prebuilt mode plus any skip presence, absent inventory, wrong source/digest/size, duplicate IDs, unsafe filenames, missing/empty/extra modules and source changed during copy. -- [ ] Run `cargo test -p trusted-server-js --test release_prebuilt`; expect current prebuilt behavior to fail these guards. -- [ ] Extend the existing bundle-set validator; require `TSJS_PREBUILT_INVENTORY` in release mode and validate exact input and private copied bytes. Track both paths/control values with Cargo rerun directives. Reject skip before selecting prebuilt/source path; no TSJS_RELEASE_BUILD parallel mode and no read from old `dist/`. -- [ ] Preserve ordinary development's supported fresh source build. In release Rust jobs require prebuilt inputs and prohibit npm invocation; a fake failing npm proves it is never run. -- [ ] Run JS crate tests, Fastly/CLI target checks and prebuilt negatives. Commit `Bind release JS inputs to a checked inventory`. +- [x] Write process/fixture tests for prebuilt mode plus any skip presence, absent inventory, wrong source/digest/size, duplicate IDs, unsafe filenames, missing/empty/extra modules and source changed during copy. +- [x] Run `cargo test -p trusted-server-js --test release_prebuilt`; expect current prebuilt behavior to fail these guards. +- [x] Extend the existing bundle-set validator; require `TSJS_PREBUILT_INVENTORY` in release mode and validate exact input and private copied bytes. Track both paths/control values with Cargo rerun directives. Reject skip before selecting prebuilt/source path; no TSJS_RELEASE_BUILD parallel mode and no read from old `dist/`. +- [x] Preserve ordinary development's supported fresh source build. In release Rust jobs require prebuilt inputs and prohibit npm invocation; a fake failing npm proves it is never run. +- [x] Run JS crate tests, Fastly/CLI target checks and prebuilt negatives. Commit `Bind release JS inputs to a checked inventory`. ### Task 6: Collect JS contributions without changing bundle bytes @@ -438,3 +438,5 @@ Task 2 is implemented in `1cfca1357`, with review corrections in `c29940908` and Task 3 is implemented in `c9076d0f1`, with review corrections in `31ee0739` and `b89552d1`. Independent spec and quality reviews pass after correcting the SPDX 2.3 predicate identity and including executable JS/Rust validation sources in the source checks. The controller's fresh Python run passes 72 tests, the complete native CLI test command passes, and `cargo clippy-cli` passes. Source snapshots accept Git-equivalent executable modes under the selected umask variations while retaining actual permissions for before/after checks. Narrow domain-lint support for the three mandatory standards hosts is tested against the production policy and hostile lookalikes. Real-checkout inspection rejects a pre-existing ignored CLI source `.DS_Store`, which is preserved; actual build preparation requires a clean source snapshot. Tool, image, SDK and recipe digests remain explicitly pending, and `--require-qualified` cannot succeed until Task 8 supplies authenticated acquisition and measured evidence. Vite environment suppression, OS confinement, native graph coverage and consumer enforcement remain their later tasks. Task 4 is implemented in `bfb697404`. Independent spec and quality reviews pass. The controller freshly ran all 13 source-resolution tests and all six owning-build-script/facts/resource tests; the quality reviewer independently repeated both suites. Implementation verification also passes the full native CLI test command, core template tests, CLI/core/Fastly lint, Fastly source-version tests with default and reusable-sandbox features, formatting and staged domain lint. Canonical CLI/Fastly facts derive from their owning Cargo package, target and feature settings; the public data constructor cannot reconfigure those constants and carries no verification authority. Tests compile the actual release cfg and resource-discovery function, reject the existing developer checkout fallback, prove its literal is absent, and preserve ancestor resources. Help/version/config-init portability uses the Cargo-built CLI; complete official release executable startup and cross-tag final-byte equality remain later recipe qualification evidence. No release workflow, publication or provider action is introduced by this task. + +Task 5 is implemented in `e1606fa3d`. Independent spec and quality reviews pass. The controller freshly ran all 28 native actual-script/helper tests; the spec reviewer independently ran those and all 10 library tests. Implementation verification also passes WASM library tests, the full 50-file/1,211-test JS suite with type checking, Fastly/CLI target checks and lint, formatting, staged domain lint, and release-mode Cargo interface fixtures. The release fixtures use checked mock bundle bytes and a failing npm, proving prebuilt consumption and no npm execution; they do not prove official source approval or JS provenance. Checked inventories are bounded strict JSON, exact sorted identities and byte hashes; native held descriptors, identity checks and private-byte verification reject links and copy-time mutation. The exact raw inventory hash is exposed through Cargo build-script evidence for Task 10. Producers must honor the documented 32 MiB per-module input budget. Contribution/tool/material arrays remain opaque at this boundary; Task 6 supplies semantic composition evidence and Task 7 supplies independent JS builds. Source parent aliases are accepted only when the held resolved directory identity remains unchanged; Cargo-owned private output parents are an explicit trust boundary. Hosted qualification and final retained-module evidence remain pending. From eeb68f9188b4609bd3b8f7890a6fe51cbc26a57d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 21:49:25 +0530 Subject: [PATCH 022/153] Record release JS composition evidence --- crates/trusted-server-js/build.rs | 3 + crates/trusted-server-js/lib/build-all.mjs | 121 ++- .../lib/build-module-options.mjs | 7 +- .../lib/release-composition.mjs | 647 +++++++++++++ .../lib/test/release-composition.test.mjs | 887 ++++++++++++++++++ crates/trusted-server-js/lib/vitest.config.ts | 3 + .../tests/release_prebuilt.rs | 10 + scripts/release/collect-js.mjs | 213 +++++ scripts/release/js-composition.md | 101 ++ 9 files changed, 1944 insertions(+), 48 deletions(-) create mode 100644 crates/trusted-server-js/lib/release-composition.mjs create mode 100644 crates/trusted-server-js/lib/test/release-composition.test.mjs create mode 100644 scripts/release/collect-js.mjs create mode 100644 scripts/release/js-composition.md diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 99f311e003..3a81942f38 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -44,6 +44,9 @@ pub(crate) fn run_with_hook(hook: impl FnMut(&Path, &Path)) { for path in [ "lib/src", "lib/build-all.mjs", + "lib/build-module-options.mjs", + "lib/release-composition.mjs", + "lib/vitest.config.ts", "lib/package.json", "lib/package-lock.json", "lib/tsconfig.json", diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index 24f842e764..e5500581d1 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -20,15 +20,12 @@ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { build } from 'vite'; import { moduleBuildOptions } from './build-module-options.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const srcDir = path.resolve(__dirname, 'src'); -const distDir = resolveOutDir(process.argv.slice(2)); -const integrationsDir = path.join(srcDir, 'integrations'); /** Resolve `--out-dir ` (relative to the cwd), defaulting to ../dist. */ function resolveOutDir(args) { @@ -43,52 +40,82 @@ function resolveOutDir(args) { return path.resolve(value); } -// Remove only the bundles this script writes, so a wrong --out-dir cannot -// delete unrelated files. -fs.mkdirSync(distDir, { recursive: true }); -for (const name of fs.readdirSync(distDir)) { - if (name.startsWith('tsjs-') && name.endsWith('.js')) { - fs.rmSync(path.join(distDir, name)); +/** Build the discovered modules; recording is opt-in and cannot change bundle bytes. */ +export async function buildAll({ + outDir, + composition, + sourceDir = path.resolve(__dirname, 'src'), +}) { + const srcDir = sourceDir; + const distDir = path.resolve(outDir); + const integrationsDir = path.join(srcDir, 'integrations'); + const records = []; + + // Remove only the bundles this script writes, so a wrong --out-dir cannot + // delete unrelated files. + fs.mkdirSync(distDir, { recursive: true }); + for (const name of fs.readdirSync(distDir)) { + if (name.startsWith('tsjs-') && name.endsWith('.js')) { + fs.rmSync(path.join(distDir, name)); + } } -} -// Discover integration modules: directories in src/integrations/ with index.ts -const integrationModules = fs.existsSync(integrationsDir) - ? fs - .readdirSync(integrationsDir) - .filter((name) => { - const fullPath = path.join(integrationsDir, name); - return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) - ); - }) - .sort() - : []; - -console.log('[build-all] Discovered integrations:', integrationModules); - -/** Build a single module as a self-contained IIFE. */ -async function buildModule(name, entryPath) { - const outFile = `tsjs-${name}.js`; - console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); - - await build(moduleBuildOptions({ name, entryPath, outDir: distDir })); - - console.log(`[build-all] Built ${outFile}`); -} + // Discover integration modules: directories in src/integrations/ with index.ts + const integrationModules = fs.existsSync(integrationsDir) + ? fs + .readdirSync(integrationsDir) + .filter((name) => { + const fullPath = path.join(integrationsDir, name); + return ( + fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) + ); + }) + .sort() + : []; + + console.log('[build-all] Discovered integrations:', integrationModules); + + /** Build a single module as a self-contained IIFE. */ + async function buildModule(name, entryPath) { + const outFile = `tsjs-${name}.js`; + console.log(`[build-all] Building ${outFile} from ${path.relative(__dirname, entryPath)}`); + + let contributions; + const recording = composition + ? { + ...composition, + onContributions: (rows) => { + contributions = rows; + }, + } + : undefined; + await build(moduleBuildOptions({ name, entryPath, outDir: distDir, composition: recording })); + records.push({ id: name, filename: outFile, contributions }); -// Build core first (synchronously), then all integrations in parallel -await buildModule('core', path.join(srcDir, 'core', 'index.ts')); + console.log(`[build-all] Built ${outFile}`); + } + + // Build core first (synchronously), then all integrations in parallel + await buildModule('core', path.join(srcDir, 'core', 'index.ts')); + + await Promise.all( + integrationModules.map((name) => + buildModule(name, path.join(integrationsDir, name, 'index.ts')) + ) + ); -await Promise.all( - integrationModules.map((name) => buildModule(name, path.join(integrationsDir, name, 'index.ts'))) -); + // List all built files + const builtFiles = fs + .readdirSync(distDir) + .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) + .sort(); -// List all built files -const builtFiles = fs - .readdirSync(distDir) - .filter((f) => f.startsWith('tsjs-') && f.endsWith('.js')) - .sort(); + console.log('[build-all] Built files:', builtFiles); + console.log(`[build-all] Total: ${builtFiles.length} modules`); -console.log('[build-all] Built files:', builtFiles); -console.log(`[build-all] Total: ${builtFiles.length} modules`); + return records.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + await buildAll({ outDir: resolveOutDir(process.argv.slice(2)) }); +} diff --git a/crates/trusted-server-js/lib/build-module-options.mjs b/crates/trusted-server-js/lib/build-module-options.mjs index 64b78a3fb7..be8b667871 100644 --- a/crates/trusted-server-js/lib/build-module-options.mjs +++ b/crates/trusted-server-js/lib/build-module-options.mjs @@ -1,12 +1,17 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { compositionPlugin, releaseBuild } from './release-composition.mjs'; + const libDir = path.dirname(fileURLToPath(import.meta.url)); /** Production Vite options for one independently bundled tsjs IIFE. */ -export function moduleBuildOptions({ name, entryPath, outDir }) { +export function moduleBuildOptions({ name, entryPath, outDir, composition }) { + const release = releaseBuild(); return { configFile: false, + ...(release ? { mode: 'production', envDir: false } : {}), + ...(composition ? { plugins: [compositionPlugin({ name, ...composition })] } : {}), root: libDir, build: { emptyOutDir: false, diff --git a/crates/trusted-server-js/lib/release-composition.mjs b/crates/trusted-server-js/lib/release-composition.mjs new file mode 100644 index 0000000000..19c6e8ad38 --- /dev/null +++ b/crates/trusted-server-js/lib/release-composition.mjs @@ -0,0 +1,647 @@ +/** Conservative Rollup evidence; rendered modules can lose more code in esbuild. */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const relative = (root, file) => path.relative(root, file).split(path.sep).join('/'); +const inside = (root, file) => file === root || file.startsWith(`${root}${path.sep}`); +const MAX_JSON = 1024 * 1024; + +/** Parse bounded UTF-8 JSON without duplicate keys or non-finite numbers. */ +export function strictJson(bytes) { + if (bytes.length > MAX_JSON) throw new Error('JSON exceeds 1 MiB'); + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + let offset = 0; + const space = () => { + while (/[\t\n\r ]/.test(text[offset] ?? '\0')) offset++; + }; + const string = () => { + const start = offset++; + while (offset < text.length) { + const character = text[offset++]; + if (character === '\\') offset++; + else if (character === '"') { + const parsed = JSON.parse(text.slice(start, offset)); + if (/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + if (depth > 64) throw new Error('JSON nesting exceeds 64'); + space(); + const character = text[offset]; + if (character === '"') return string(); + if (character === '{' || character === '[') { + if (depth >= 64) throw new Error('JSON nesting exceeds 64'); + offset++; + const object = character === '{'; + const result = object ? Object.create(null) : []; + const end = object ? '}' : ']'; + space(); + if (text[offset] === end) { + offset++; + return result; + } + for (;;) { + space(); + let key; + if (object) { + if (text[offset] !== '"') throw new Error('Expected JSON key'); + key = string(); + if (Object.hasOwn(result, key)) throw new Error(`Duplicate JSON key: ${key}`); + space(); + if (text[offset++] !== ':') throw new Error('Expected JSON colon'); + } + const parsed = value(depth + 1); + if (object) result[key] = parsed; + else result.push(parsed); + space(); + const delimiter = text[offset++]; + if (delimiter === end) return result; + if (delimiter !== ',') throw new Error('Expected JSON delimiter'); + } + } + const token = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec( + text.slice(offset) + )?.[0]; + if (!token) throw new Error('Invalid JSON value'); + offset += token.length; + const parsed = JSON.parse(token); + if ( + typeof parsed === 'number' && + (!Number.isFinite(parsed) || (Number.isInteger(parsed) && !Number.isSafeInteger(parsed))) + ) + throw new Error('Non-finite JSON number'); + return parsed; + }; + const result = value(0); + space(); + if (offset !== text.length) throw new Error('Trailing JSON data'); + return result; +} + +/** Derive release settings from the existing build control only. */ +export function releaseBuild(env = process.env) { + if (Object.hasOwn(env, 'TSJS_SKIP_BUILD')) throw new Error('TSJS_SKIP_BUILD is forbidden'); + if (!Object.hasOwn(env, 'TRUSTED_SERVER__RELEASE_BUILD')) return false; + if (env.TRUSTED_SERVER__RELEASE_BUILD !== '1') + throw new Error('Release build control must be exactly 1'); + if (!/^[a-f0-9]{40}$/.test(env.TRUSTED_SERVER__GIT_VERSION ?? '')) + throw new Error('Release requires full source commit'); + return true; +} + +/** Join real installed locations to exact package-lock package identities. */ +export function installedOrigins({ repoRoot, lockFile, installRoot = path.dirname(lockFile) }) { + repoRoot = fs.realpathSync(repoRoot); + installRoot = fs.realpathSync(installRoot); + const lock = strictJson(readBounded(lockFile)); + if ( + lock.lockfileVersion !== 3 || + !lock.packages || + typeof lock.packages !== 'object' || + Array.isArray(lock.packages) + ) + throw new Error('Expected package-lock v3 packages'); + const packages = new Map(); + const realRoots = new Map(); + for (const [lockPath, record] of Object.entries(lock.packages)) { + if (lockPath === '') continue; + if ( + !/^(?:node_modules\/(?:@[a-zA-Z0-9._-]+\/)?[a-zA-Z0-9._-]+)(?:\/node_modules\/(?:@[a-zA-Z0-9._-]+\/)?[a-zA-Z0-9._-]+)*$/.test( + lockPath + ) + ) + throw new Error(`Unknown lock source: ${lockPath}`); + const root = path.join(installRoot, lockPath); + if (!fs.existsSync(root)) continue; + const real = fs.realpathSync(root); + if (real !== root || !inside(repoRoot, real)) + throw new Error(`Installed package symlink escape or alias: ${lockPath}`); + if (realRoots.has(real)) throw new Error(`Duplicate installed identity: ${lockPath}`); + realRoots.set(real, lockPath); + packages.set(lockPath, { root, record }); + } + function packageIdentity(lockPath) { + const installed = packages.get(lockPath); + if (!installed) throw new Error(`Missing installed package: ${lockPath}`); + const { root, record } = installed; + if (Object.hasOwn(record, 'link') || typeof record.resolved !== 'string') + throw new Error(`Unsupported lock archive source: ${lockPath}`); + let archive; + try { + archive = new URL(record.resolved); + } catch { + throw new Error(`Unsupported lock archive source: ${lockPath}`); + } + if ( + archive.protocol !== 'https:' || + archive.hostname !== 'registry.npmjs.org' || + archive.port || + archive.username || + archive.password || + archive.search || + archive.hash || + archive.href !== record.resolved || + !archive.pathname.endsWith('.tgz') + ) + throw new Error(`Unsupported lock archive source: ${lockPath}`); + const manifest = strictJson(readBounded(path.join(root, 'package.json'))); + const name = lockPath.slice(lockPath.lastIndexOf('node_modules/') + 'node_modules/'.length); + if (manifest.name !== name || (record.name && record.name !== name)) + throw new Error(`Package name mismatch: ${lockPath}`); + if (!record.version || manifest.version !== record.version) + throw new Error(`Package version mismatch: ${lockPath}`); + if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(record.integrity ?? '')) + throw new Error(`Missing or unsupported package integrity: ${lockPath}`); + return { + kind: 'npm', + lock_path: lockPath, + package_name: name, + package_version: manifest.version, + integrity: record.integrity, + }; + } + function origin(id) { + const [file, ...queries] = id.split('?'); + const transform = + queries.length === 0 + ? 'identity' + : queries.length === 1 && ['raw', 'inline'].includes(queries[0]) + ? queries[0] + : null; + if (!transform) throw new Error(`Unknown module query: ${id}`); + if (!path.isAbsolute(file)) throw new Error(`Unknown module source: ${id}`); + const real = fs.realpathSync(file); + if (!inside(repoRoot, real)) throw new Error(`Source symlink escape: ${id}`); + const bytes = readBounded(real, 32 * 1024 * 1024); + if (bytes.length > 32 * 1024 * 1024) throw new Error('Source exceeds 32 MiB'); + let directory = path.dirname(real); + while (inside(installRoot, directory)) { + const lockPath = relative(installRoot, directory); + if (packages.has(lockPath)) { + if (!inside(directory, real)) throw new Error(`Package source symlink escape: ${id}`); + return { + ...packageIdentity(lockPath), + path: relative(directory, real), + transform, + sha256: sha256(bytes), + }; + } + if ( + fs.existsSync(path.join(directory, 'package.json')) && + relative(installRoot, directory).includes('node_modules/') + ) + throw new Error(`Unmapped installed ownership: ${id}`); + if (directory === installRoot) break; + directory = path.dirname(directory); + } + if (relative(repoRoot, real).split('/').includes('node_modules')) + throw new Error(`Unmapped installed ownership: ${id}`); + return { kind: 'source', path: relative(repoRoot, real), transform, sha256: sha256(bytes) }; + } + return { repoRoot, installRoot, packages, packageIdentity, origin }; +} + +/** Observe exactly one IIFE's rendered modules without altering emitted output. */ +export function compositionPlugin({ name, ...settings }) { + const owners = installedOrigins(settings); + return { + name: 'trusted-server-release-composition', + generateBundle(options, bundle) { + const outputs = Object.values(bundle); + if ( + options.format !== 'iife' || + outputs.length !== 1 || + outputs[0].type !== 'chunk' || + !outputs[0].isEntry || + outputs[0].fileName !== `tsjs-${name}.js` + ) + throw new Error('Expected exactly one IIFE chunk with no assets'); + const contributions = []; + for (const [id, rendered] of Object.entries(outputs[0].modules)) { + if (rendered.renderedLength === 0) continue; + let contribution; + if (!id.startsWith('\0')) contribution = owners.origin(id); + else { + let plugin; + let input; + let stableId = id; + if (id === '\0commonjsHelpers.js') plugin = 'commonjs'; + else if (id.startsWith('\0') && id.endsWith('?commonjs-es-import')) { + plugin = 'commonjs'; + input = owners.origin(id.slice(1, -'?commonjs-es-import'.length)); + stableId = `\0${input.kind === 'npm' ? `${input.lock_path}/` : ''}${input.path}?commonjs-es-import`; + } else if (id === '\0vite/preload-helper.js') plugin = 'vite:build-import-analysis'; + else throw new Error(`Unknown virtual producer: ${id}`); + owners.packageIdentity('node_modules/vite'); + const code = this.getModuleInfo(id)?.code; + if (typeof code !== 'string') throw new Error(`Missing generated code: ${id}`); + contribution = { + kind: 'generated', + id: stableId, + plugin, + owner_lock_path: 'node_modules/vite', + generated_sha256: sha256(code), + ...(input ? { input } : {}), + }; + } + contributions.push({ + ...contribution, + stage: 'rollup-rendered', + rendered_length: rendered.renderedLength, + }); + } + contributions.sort((a, b) => compare(JSON.stringify(a), JSON.stringify(b))); + settings.onContributions(contributions); + }, + }; +} + +function stableFile(file, consume) { + const descriptor = fs.openSync( + file, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK + ); + try { + const before = fs.fstatSync(descriptor); + if (!before.isFile()) throw new Error(`Expected regular file: ${file}`); + const result = consume(descriptor, before.size); + const after = fs.fstatSync(descriptor); + const current = fs.lstatSync(file); + if ( + before.dev !== current.dev || + before.ino !== current.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs + ) + throw new Error(`File changed while measured: ${file}`); + return result; + } finally { + fs.closeSync(descriptor); + } +} + +/** Read at most the specified input budget through a held regular-file descriptor. */ +export function readBounded(file, maximum = MAX_JSON) { + return stableFile(file, (descriptor, size) => { + if (size > maximum) throw new Error(`File exceeds size budget: ${file}`); + const bytes = Buffer.alloc(size + 1); + let count = 0; + while (count < bytes.length) { + const read = fs.readSync(descriptor, bytes, count, bytes.length - count, null); + if (!read) break; + count += read; + } + if (count !== size) throw new Error(`File changed or exceeds size budget: ${file}`); + return bytes.subarray(0, count); + }); +} + +/** Hash file bytes with fixed-size buffers, preserving the held-file identity. */ +export function fileDigest(file) { + return stableFile(file, (descriptor, size) => { + const digest = crypto.createHash('sha256'); + const buffer = Buffer.alloc(64 * 1024); + let count = 0; + for (;;) { + const read = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (!read) break; + count += read; + if (count > size) throw new Error(`File grew while measured: ${file}`); + digest.update(buffer.subarray(0, read)); + } + if (count !== size) throw new Error(`File changed while measured: ${file}`); + return digest.digest('hex'); + }); +} + +/** Hash sorted relative paths, types, lengths, bytes and normalized executable modes. */ +export function installedTreeHash(root, excludedRoots = new Set()) { + const digest = crypto.createHash('sha256'); + digest.update('trusted-server-installed-tree-v1\n'); + function visit(directory) { + for (const name of fs.readdirSync(directory).sort(compare)) { + const file = path.join(directory, name); + if (excludedRoots.has(file)) continue; + const stat = fs.lstatSync(file); + const filename = relative(root, file); + if (stat.isDirectory()) { + digest.update(`${JSON.stringify([filename, 'directory'])}\n`); + visit(file); + } else if (stat.isFile()) { + digest.update( + `${JSON.stringify([filename, 'file', stat.size, Boolean(stat.mode & 0o111)])}\n` + ); + stableFile(file, (descriptor, size) => { + const buffer = Buffer.alloc(64 * 1024); + let count = 0; + for (;;) { + const read = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (!read) break; + count += read; + if (count > size) throw new Error(`Tree file grew: ${file}`); + digest.update(buffer.subarray(0, read)); + } + if (count !== size || size !== stat.size) throw new Error(`Tree file changed: ${file}`); + }); + digest.update('\n'); + } else if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(file); + if (path.isAbsolute(target) || !inside(root, fs.realpathSync(file))) + throw new Error(`Installed tree symlink escape: ${file}`); + digest.update(`${JSON.stringify([filename, 'symlink', target])}\n`); + } else throw new Error(`Unsupported installed tree entry: ${file}`); + } + } + visit(root); + return digest.digest('hex'); +} + +/** Measure installed packages independently of their lockfile archive integrity. */ +export function installedMaterials(owners) { + const excluded = new Set([...owners.packages.values()].map(({ root }) => root)); + return [...owners.packages.keys()].sort(compare).map((lockPath) => { + const identity = owners.packageIdentity(lockPath); + return { + kind: 'npm-package', + id: `npm:${lockPath}`, + lock_path: lockPath, + name: identity.package_name, + version: identity.package_version, + integrity: identity.integrity, + installed_tree_sha256: installedTreeHash(owners.packages.get(lockPath).root, excluded), + }; + }); +} + +const hashPattern = /^[a-f0-9]{64}$/; +function fields(record, keys) { + if ( + !record || + typeof record !== 'object' || + Array.isArray(record) || + Object.keys(record).sort().join(',') !== [...keys].sort().join(',') + ) + throw new Error('Unexpected preparation fields'); +} +function digest(value) { + if (typeof value !== 'string' || !hashPattern.test(value)) + throw new Error('Invalid preparation digest'); +} +function safePath(value) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\\') || + value.includes('\0') || + value.startsWith('/') || + value.split('/').some((part) => part === '.' || part === '..' || part === '') + ) + throw new Error('Unsafe preparation path'); +} + +/** Declare expected registry-install hooks; these declarations are not execution observations. */ +export function expectedLifecycleEvents({ installRoot, packages }) { + const events = []; + for (const material of packages) { + const root = path.join(installRoot, material.lock_path); + const manifest = strictJson(readBounded(path.join(root, 'package.json'))); + if ( + fs.existsSync(path.join(root, 'binding.gyp')) && + !manifest.scripts?.install && + !manifest.scripts?.preinstall + ) + throw new Error(`Unsupported implicit lifecycle install: ${material.lock_path}`); + for (const event of ['preinstall', 'install', 'postinstall']) { + const script = manifest.scripts?.[event]; + if (script === undefined) continue; + if (typeof script !== 'string' || !script) + throw new Error('Invalid declared lifecycle script'); + events.push({ + kind: 'lifecycle-event', + id: `lifecycle:${material.lock_path}:${event}`, + package_material_id: material.id, + event, + script_sha256: sha256(script), + }); + } + } + return events; +} + +/** Check preparation observations; this does not authenticate acquisition or lifecycle execution. */ +export function validatePreparation(prepared, { installRoot, packages }) { + fields(prepared, [ + 'schema_version', + 'tools', + 'distributions', + 'packages', + 'lifecycle_events', + 'lifecycle_outputs', + ]); + if ( + prepared.schema_version !== 1 || + !['tools', 'distributions', 'packages', 'lifecycle_events', 'lifecycle_outputs'].every((key) => + Array.isArray(prepared[key]) + ) + ) + throw new Error('Expected preparation version 1 arrays'); + const materials = []; + const identities = new Set(); + function add(material) { + if (typeof material.id !== 'string' || !material.id || identities.has(material.id)) + throw new Error('Duplicate or invalid preparation identity'); + identities.add(material.id); + materials.push(material); + } + const measured = new Map(packages.map((record) => [record.id, record])); + for (const material of prepared.packages) { + fields(material, [ + 'kind', + 'id', + 'lock_path', + 'name', + 'version', + 'integrity', + 'installed_tree_sha256', + ]); + if (material.kind !== 'npm-package' || material.id !== `npm:${material.lock_path}`) + throw new Error('Invalid package material identity'); + const actual = measured.get(material.id); + if (!actual || Object.keys(material).some((key) => material[key] !== actual[key])) + throw new Error(`Installed package material mismatch: ${material.id}`); + add(material); + } + if (prepared.packages.length !== packages.length) + throw new Error('Incomplete prepared package set'); + for (const distribution of prepared.distributions) { + fields(distribution, ['material', 'path']); + const { material, path: file } = distribution; + fields(material, ['kind', 'id', 'name', 'version', 'sha256']); + if ( + material.kind !== 'tool-distribution' || + typeof material.name !== 'string' || + !material.name || + typeof material.version !== 'string' || + !material.version + ) + throw new Error('Invalid tool distribution'); + digest(material.sha256); + if (fileDigest(file) !== material.sha256) + throw new Error(`Tool distribution digest mismatch: ${material.id}`); + add(material); + } + const expectedEvents = new Set( + expectedLifecycleEvents({ installRoot, packages }).map((event) => + JSON.stringify([event.package_material_id, event.event, event.script_sha256]) + ) + ); + const events = new Set(); + for (const event of prepared.lifecycle_events) { + fields(event, ['kind', 'id', 'package_material_id', 'event', 'script_sha256']); + const owner = measured.get(event.package_material_id); + if ( + event.kind !== 'lifecycle-event' || + !owner || + !['preinstall', 'install', 'postinstall', 'prepare'].includes(event.event) + ) + throw new Error('Invalid lifecycle event join'); + digest(event.script_sha256); + const manifest = strictJson( + readBounded(path.join(installRoot, owner.lock_path, 'package.json')) + ); + const script = manifest.scripts?.[event.event]; + if (typeof script !== 'string' || sha256(script) !== event.script_sha256) + throw new Error('Lifecycle script digest mismatch'); + const identity = JSON.stringify([event.package_material_id, event.event, event.script_sha256]); + if (events.has(identity)) throw new Error('Duplicate lifecycle event'); + if (!expectedEvents.has(identity)) throw new Error('Unexpected lifecycle event'); + events.add(identity); + add(event); + } + if (events.size !== expectedEvents.size) + throw new Error('Missing expected lifecycle observations'); + for (const output of prepared.lifecycle_outputs) { + fields(output, [ + 'kind', + 'id', + 'package_material_id', + 'event', + 'script_sha256', + 'path', + 'size', + 'sha256', + ]); + if ( + output.kind !== 'lifecycle-output' || + !measured.has(output.package_material_id) || + !['preinstall', 'install', 'postinstall', 'prepare'].includes(output.event) + ) + throw new Error('Invalid lifecycle output join'); + digest(output.script_sha256); + digest(output.sha256); + safePath(output.path); + if (!Number.isSafeInteger(output.size) || output.size < 0 || output.size > 32 * 1024 * 1024) + throw new Error('Invalid lifecycle size'); + const owner = measured.get(output.package_material_id); + const ownerRoot = path.join(installRoot, owner.lock_path); + const manifest = strictJson(readBounded(path.join(ownerRoot, 'package.json'))); + const script = manifest.scripts?.[output.event]; + if (typeof script !== 'string' || sha256(script) !== output.script_sha256) + throw new Error('Lifecycle script digest mismatch'); + const file = path.join(installRoot, output.path); + if (!inside(ownerRoot, file)) throw new Error('Lifecycle output owner mismatch'); + if (!inside(fs.realpathSync(installRoot), fs.realpathSync(file))) + throw new Error('Lifecycle output escape'); + if ( + !events.has(JSON.stringify([output.package_material_id, output.event, output.script_sha256])) + ) + throw new Error('Lifecycle output has no observed event'); + const bytes = readBounded(file, 32 * 1024 * 1024); + if (bytes.length !== output.size || sha256(bytes) !== output.sha256) + throw new Error('Lifecycle output digest mismatch'); + add(output); + } + const tools = []; + const names = new Set(); + for (const tool of prepared.tools) { + fields(tool, ['name', 'version', 'role', 'material_id']); + const material = materials.find((record) => record.id === tool.material_id); + if ( + !material || + material.kind !== 'tool-distribution' || + material.name !== tool.name || + material.version !== tool.version || + !['runtime', 'package-manager', 'bundler', 'minifier', 'test-runner'].includes(tool.role) || + names.has(tool.name) + ) + throw new Error('Invalid or duplicate tool material join'); + names.add(tool.name); + tools.push(tool); + } + return { + materials: materials.sort((a, b) => compare(a.id, b.id)), + tools: tools.sort((a, b) => compare(a.name, b.name)), + }; +} + +/** Measure the actual tool entry files and native executables; no host authentication is claimed. */ +export function actualToolFiles({ installRoot }) { + const npmRoot = path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm'); + const tools = [ + { name: 'node', role: 'runtime', version: process.versions.node, path: process.execPath }, + { + name: 'npm', + role: 'package-manager', + version: strictJson(readBounded(path.join(npmRoot, 'package.json'))).version, + path: path.join(npmRoot, 'bin/npm-cli.js'), + }, + ]; + const modules = [ + ['vite', 'bundler', 'dist/node/index.js'], + ['rollup', 'bundler', 'dist/es/rollup.js'], + ['esbuild', 'minifier', 'lib/main.js'], + ['vitest', 'test-runner', 'vitest.mjs'], + ]; + for (const [name, role, entry] of modules) { + const root = path.join(installRoot, 'node_modules', name); + tools.push({ + name, + role, + version: strictJson(readBounded(path.join(root, 'package.json'))).version, + path: path.join(root, entry), + }); + } + if (!['darwin', 'linux'].includes(process.platform) || !['arm64', 'x64'].includes(process.arch)) + throw new Error('Unsupported native tool platform'); + const platform = `${process.platform}-${process.arch}`; + const esbuildRoot = path.join(installRoot, 'node_modules/@esbuild', platform); + const rollupSuffix = + process.platform === 'linux' + ? process.report.getReport().header.glibcVersionRuntime + ? '-gnu' + : '-musl' + : ''; + const rollupPlatform = `${platform}${rollupSuffix}`; + const rollupRoot = path.join(installRoot, 'node_modules/@rollup', `rollup-${rollupPlatform}`); + tools.push({ + name: `esbuild:${platform}`, + role: 'minifier', + version: strictJson(readBounded(path.join(esbuildRoot, 'package.json'))).version, + path: path.join(esbuildRoot, 'bin/esbuild'), + }); + tools.push({ + name: `rollup:${platform}`, + role: 'bundler', + version: strictJson(readBounded(path.join(rollupRoot, 'package.json'))).version, + path: path.join(rollupRoot, `rollup.${rollupPlatform}.node`), + }); + return tools.map((tool) => ({ ...tool, sha256: fileDigest(tool.path) })); +} diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs new file mode 100644 index 0000000000..d705cda303 --- /dev/null +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -0,0 +1,887 @@ +// @vitest-environment node +import crypto from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build } from 'vite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildAll } from '../build-all.mjs'; +import { moduleBuildOptions } from '../build-module-options.mjs'; + +const libDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = path.resolve(libDir, '../../..'); +const lockFile = path.join(libDir, 'package-lock.json'); +const archiveSource = JSON.parse(fs.readFileSync(lockFile, 'utf8')).packages['node_modules/dlv'] + .resolved; +const temporary = []; +const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); +function fixture() { + const root = fs.mkdtempSync(path.join(libDir, '.release-test-')); + temporary.push(root); + return root; +} +afterEach(() => { + for (const root of temporary.splice(0)) { + if (fs.lstatSync(root, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(root); + else fs.rmSync(root, { recursive: true, force: true }); + } +}); +async function bundle(root, entry, composition, plugins = []) { + const outDir = path.join(root, composition ? 'recorded' : 'plain'); + const options = moduleBuildOptions({ name: 'fixture', entryPath: entry, outDir, composition }); + options.plugins = [...(options.plugins ?? []), ...plugins]; + await build(options); + return fs.readFileSync(path.join(outDir, 'tsjs-fixture.js')); +} +function recorder(onContributions, overrides = {}) { + return { repoRoot, lockFile, onContributions, ...overrides }; +} + +describe('release Rollup composition', () => { + it('records retained source and transitive npm helpers without changing final bytes', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(path.join(root, 'unused.ts'), 'export const discarded = 42;'); + fs.writeFileSync(path.join(root, 'retained.ts'), 'export const retained = "example-retained";'); + fs.writeFileSync( + entry, + `import type { BidRequest } from 'prebid.js'; + import { discarded } from './unused'; + import { retained } from './retained'; + import dlv from 'dlv/dist/dlv.js'; + const request: BidRequest | undefined = undefined; + globalThis.exampleResult = [retained, dlv({ x: 7 }, 'x'), request];` + ); + let contributions; + const plain = await bundle(root, entry); + const recorded = await bundle( + root, + entry, + recorder((rows) => { + contributions = rows; + }) + ); + expect(recorded).toEqual(plain); + expect(contributions).toBeDefined(); + expect(contributions.some((row) => row.path?.endsWith('/retained.ts'))).toBe(true); + expect(contributions.some((row) => row.path?.endsWith('/unused.ts'))).toBe(false); + expect(contributions.some((row) => row.package_name === 'prebid.js')).toBe(false); + const npm = contributions.find((row) => row.package_name === 'dlv'); + expect(npm).toMatchObject({ + kind: 'npm', + lock_path: 'node_modules/dlv', + path: 'dist/dlv.js', + stage: 'rollup-rendered', + }); + expect(npm.sha256).toBe( + sha256(fs.readFileSync(path.join(libDir, 'node_modules/dlv/dist/dlv.js'))) + ); + expect(contributions.find((row) => row.id === '\0commonjsHelpers.js')).toMatchObject({ + kind: 'generated', + plugin: 'commonjs', + owner_lock_path: 'node_modules/vite', + }); + expect(contributions.every((row) => row.rendered_length > 0)).toBe(true); + }); + + it('preserves the actual core and Prebid bundle bytes', async () => { + for (const name of ['core', 'prebid']) { + const root = fixture(); + const entry = path.join( + libDir, + 'src', + name === 'core' ? 'core' : 'integrations/prebid', + 'index.ts' + ); + let rows; + expect( + await bundle( + root, + entry, + recorder((value) => { + rows = value; + }) + ) + ).toEqual(await bundle(root, entry)); + expect(rows).toBeDefined(); + expect(rows.some((row) => row.package_name === 'prebid.js')).toBe(false); + } + }); + + it('records original raw HTML and inline CSS hashes with their transformations', async () => { + const root = fixture(); + fs.writeFileSync(path.join(root, 'example.html'), '
example
'); + fs.writeFileSync(path.join(root, 'example.css'), '.example { color: red; }'); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + `import html from './example.html?raw'; import css from './example.css?inline'; globalThis.exampleResult = [html, css];` + ); + let rows; + const bytes = await bundle( + root, + entry, + recorder((value) => { + rows = value; + }) + ); + expect(bytes).toEqual(await bundle(root, entry)); + expect(rows.find((row) => row.path?.endsWith('/example.html'))).toMatchObject({ + transform: 'raw', + sha256: sha256(fs.readFileSync(path.join(root, 'example.html'))), + }); + expect(rows.find((row) => row.path?.endsWith('/example.css'))).toMatchObject({ + transform: 'inline', + }); + }); + + it('rejects an unrecognized virtual producer rather than assigning it to Vite', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + `import value from 'virtual:example'; globalThis.exampleResult = value;` + ); + const plugin = { + name: 'example-unreviewed', + resolveId(id) { + if (id === 'virtual:example') return '\0example-unreviewed'; + }, + load(id) { + if (id === '\0example-unreviewed') return 'export default 42;'; + }, + }; + await expect( + bundle( + root, + entry, + recorder(() => {}), + [plugin] + ) + ).rejects.toThrow(/unknown virtual/i); + }); + + it.each(['version', 'integrity'])( + 'rejects contributing installed package %s ambiguity', + async (field) => { + const root = fixture(); + const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8')); + if (field === 'version') lock.packages['node_modules/dlv'].version = '0.0.0'; + else delete lock.packages['node_modules/dlv'].integrity; + const changedLock = path.join(root, 'package-lock.json'); + fs.writeFileSync(changedLock, JSON.stringify(lock)); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + `import dlv from 'dlv/dist/dlv.js'; globalThis.exampleResult = dlv({ x: 7 }, 'x');` + ); + await expect( + bundle( + root, + entry, + recorder(() => {}, { lockFile: changedLock, installRoot: libDir }) + ) + ).rejects.toThrow(/version|integrity/i); + } + ); +}); + +describe('release collector boundaries', () => { + it.each([ + '{"a":1,"a":2}', + '{"a":{"b":1,"b":2}}', + '[1e999]', + '['.repeat(66) + '0' + ']'.repeat(66), + ])('rejects ambiguous JSON %s', async (text) => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(Buffer.from(text))).toThrow(); + }); + it('rejects invalid UTF-8 and oversized preparation JSON', async () => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(Buffer.from([0xff]))).toThrow(); + expect(() => strictJson(Buffer.alloc(1024 * 1024 + 1, 32))).toThrow(); + }); + it('validates the existing release controls, including any skip presence', async () => { + const { releaseBuild } = await import('../release-composition.mjs'); + for (const control of ['', '0', 'true']) + expect(() => releaseBuild({ TRUSTED_SERVER__RELEASE_BUILD: control })).toThrow(); + for (const skip of ['', '0', '1']) + expect(() => releaseBuild({ TSJS_SKIP_BUILD: skip })).toThrow(); + expect(() => releaseBuild({ TRUSTED_SERVER__RELEASE_BUILD: '1' })).toThrow(); + expect( + releaseBuild({ + TRUSTED_SERVER__RELEASE_BUILD: '1', + TRUSTED_SERVER__GIT_VERSION: 'a'.repeat(40), + }) + ).toBe(true); + }); + it('suppresses all four ambient env files in release build and Vitest options', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(entry, 'globalThis.exampleResult = import.meta.env.VITE_EXAMPLE;'); + const oldRelease = process.env.TRUSTED_SERVER__RELEASE_BUILD; + const oldSource = process.env.TRUSTED_SERVER__GIT_VERSION; + process.env.TRUSTED_SERVER__RELEASE_BUILD = '1'; + process.env.TRUSTED_SERVER__GIT_VERSION = 'a'.repeat(40); + try { + const options = moduleBuildOptions({ + name: 'fixture', + entryPath: entry, + outDir: path.join(root, 'out'), + }); + expect(options.envDir).toBe(false); + expect(options.mode).toBe('production'); + options.root = root; + await build(options); + const expected = fs.readFileSync(path.join(root, 'out/tsjs-fixture.js')); + for (const file of ['.env', '.env.local', '.env.production', '.env.production.local']) + fs.writeFileSync(path.join(root, file), 'VITE_EXAMPLE=fictional-environment-value'); + await build(options); + expect(fs.readFileSync(path.join(root, 'out/tsjs-fixture.js'))).toEqual(expected); + const config = (await import('../vitest.config.ts')).default; + expect(config.envDir).toBe(false); + } finally { + if (oldRelease === undefined) delete process.env.TRUSTED_SERVER__RELEASE_BUILD; + else process.env.TRUSTED_SERVER__RELEASE_BUILD = oldRelease; + if (oldSource === undefined) delete process.env.TRUSTED_SERVER__GIT_VERSION; + else process.env.TRUSTED_SERVER__GIT_VERSION = oldSource; + } + }); + it('rejects source symlink escape and unknown transform queries', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const external = fs.mkdtempSync('/private/tmp/tsjs-outside-'); + temporary.push(external); + fs.writeFileSync(path.join(external, 'outside.ts'), 'export const example = 7;'); + const link = path.join(root, 'alias.ts'); + fs.symlinkSync(path.join(external, 'outside.ts'), link); + const origins = installedOrigins({ repoRoot, lockFile }); + expect(() => origins.origin(link)).toThrow(/escape/i); + expect(() => origins.origin(path.join(root, 'entry.ts?unknown'))).toThrow(/query/i); + }); + it('requires checked preparation records rather than inventing lifecycle observations', async () => { + const collector = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); + expect(typeof collector.collectJs).toBe('function'); + await expect( + collector.collectJs({ + repoRoot, + outDir: path.join(fixture(), 'bundles'), + inventoryPath: path.join(fixture(), 'inventory.json'), + }) + ).rejects.toThrow(/preparation/i); + }); +}); + +describe('prepared evidence and installed trees', () => { + it('hashes normalized executable modes and excludes separately owned nested packages', async () => { + const { installedTreeHash } = await import('../release-composition.mjs'); + expect(typeof installedTreeHash).toBe('function'); + const root = fixture(); + fs.writeFileSync(path.join(root, 'example.js'), 'example bytes'); + const nested = path.join(root, 'node_modules/example-dependency'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'example.js'), 'nested bytes'); + const exclusions = new Set([nested]); + const first = installedTreeHash(root, exclusions); + fs.writeFileSync(path.join(nested, 'example.js'), 'changed nested bytes'); + expect(installedTreeHash(root, exclusions)).toBe(first); + fs.chmodSync(path.join(root, 'example.js'), 0o755); + expect(installedTreeHash(root, exclusions)).not.toBe(first); + }); + it('checks preparation distribution file and lifecycle output hashes', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + expect(typeof validatePreparation).toBe('function'); + const root = fixture(); + const file = path.join(root, 'tool'); + fs.writeFileSync(file, 'fictional distribution bytes'); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ scripts: { install: 'example install command' } }) + ); + const output = path.join(packageRoot, 'generated.js'); + fs.writeFileSync(output, 'fictional lifecycle output'); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const material = { + kind: 'tool-distribution', + id: 'tool:example', + name: 'example', + version: '1.0.0', + sha256: sha256(fs.readFileSync(file)), + }; + const prepared = { + schema_version: 1, + tools: [{ name: 'example', version: '1.0.0', role: 'bundler', material_id: material.id }], + distributions: [{ material, path: file }], + packages: [npm], + lifecycle_events: [ + { + kind: 'lifecycle-event', + id: 'event:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('example install command'), + }, + ], + lifecycle_outputs: [ + { + kind: 'lifecycle-output', + id: 'lifecycle:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('example install command'), + path: 'node_modules/example/generated.js', + size: fs.statSync(output).size, + sha256: sha256(fs.readFileSync(output)), + }, + ], + }; + const checked = validatePreparation(prepared, { installRoot: root, packages: [npm] }); + expect(checked.materials).toContainEqual(material); + expect(checked.materials.some((record) => record.kind === 'lifecycle-output')).toBe(true); + const broken = structuredClone(prepared); + broken.distributions[0].material.sha256 = 'b'.repeat(64); + expect(() => validatePreparation(broken, { installRoot: root, packages: [npm] })).toThrow( + /digest/i + ); + const duplicate = structuredClone(prepared); + duplicate.packages.push(npm); + expect(() => validatePreparation(duplicate, { installRoot: root, packages: [npm] })).toThrow( + /duplicate/i + ); + }); +}); + +describe('complete inventory producer', () => { + it('binds measured bundler native files separately from npm archive integrity', async () => { + const { actualToolFiles } = await import('../release-composition.mjs'); + expect(typeof actualToolFiles).toBe('function'); + const tools = actualToolFiles({ installRoot: libDir }); + expect(tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([ + 'node', + 'npm', + 'vite', + 'rollup', + 'esbuild', + 'vitest', + `esbuild:${process.platform}-${process.arch}`, + `rollup:${process.platform}-${process.arch}`, + ]) + ); + for (const tool of tools) { + expect(tool.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(tool.path).toBeTruthy(); + expect(tool.version).toBeTruthy(); + } + }); + + it('rejects lifecycle records that are not bound to the package install script', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: 'example', + version: '1.0.0', + scripts: { install: 'example observed command' }, + }) + ); + fs.writeFileSync(path.join(packageRoot, 'generated.js'), 'example output'); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const preparation = { + schema_version: 1, + tools: [], + distributions: [], + packages: [npm], + lifecycle_events: [ + { + kind: 'lifecycle-event', + id: 'event:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('example install command'), + }, + ], + lifecycle_outputs: [ + { + kind: 'lifecycle-output', + id: 'lifecycle:example', + package_material_id: npm.id, + event: 'install', + script_sha256: sha256('unrelated command'), + path: 'node_modules/example/generated.js', + size: 14, + sha256: sha256('example output'), + }, + ], + }; + expect(() => validatePreparation(preparation, { installRoot: root, packages: [npm] })).toThrow( + /script/i + ); + }); + + it('joins a nested transitive package by its exact installation location', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const installed = path.join(root, 'node_modules/example-parent/node_modules/example-child'); + fs.mkdirSync(installed, { recursive: true }); + fs.writeFileSync( + path.join(installed, 'package.json'), + JSON.stringify({ name: 'example-child', version: '1.2.3' }) + ); + fs.writeFileSync(path.join(installed, 'index.js'), 'export default 7;'); + const key = 'node_modules/example-parent/node_modules/example-child'; + const lock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + lock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [key]: { + version: '1.2.3', + resolved: archiveSource, + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + }, + }) + ); + const alias = `${root}-alias`; + fs.symlinkSync(root, alias); + temporary.push(alias); + expect( + installedOrigins({ repoRoot, lockFile: lock, installRoot: alias }).origin( + path.join(alias, key, 'index.js') + ) + ).toMatchObject({ lock_path: key, package_name: 'example-child' }); + expect( + installedOrigins({ repoRoot, lockFile: lock }).origin(path.join(installed, 'index.js')) + ).toMatchObject({ lock_path: key, package_name: 'example-child', path: 'index.js' }); + }); + + it('writes deterministic closed inventory records after final Vite/esbuild output', async () => { + const { actualToolFiles, expectedLifecycleEvents, installedOrigins, installedMaterials } = + await import('../release-composition.mjs'); + const { collectJs } = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); + expect(typeof actualToolFiles).toBe('function'); + const root = fixture(); + const packages = installedMaterials(installedOrigins({ repoRoot, lockFile })); + const measured = actualToolFiles({ installRoot: libDir }); + const distributions = measured.map(({ name, version, sha256: hash, path: file }) => ({ + material: { kind: 'tool-distribution', id: `tool:${name}`, name, version, sha256: hash }, + path: file, + })); + const tools = measured.map(({ name, version, role }) => ({ + name, + version, + role, + material_id: `tool:${name}`, + })); + const preparationPath = path.join(root, 'preparation.json'); + fs.writeFileSync( + preparationPath, + JSON.stringify({ + schema_version: 1, + packages, + distributions, + tools, + // Synthetic observations exercise joins; no preparation actually runs in this fixture. + lifecycle_events: expectedLifecycleEvents({ installRoot: libDir, packages }), + lifecycle_outputs: [], + }) + ); + const oldRelease = process.env.TRUSTED_SERVER__RELEASE_BUILD; + const oldSource = process.env.TRUSTED_SERVER__GIT_VERSION; + process.env.TRUSTED_SERVER__RELEASE_BUILD = '1'; + // This is the fixture's source identity, not release approval. + process.env.TRUSTED_SERVER__GIT_VERSION = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + try { + const inventoryPath = path.join(root, 'inventory.json'); + const outDir = path.join(root, 'bundles'); + const inventory = await collectJs({ repoRoot, outDir, inventoryPath, preparationPath }); + expect(Object.keys(inventory).sort()).toEqual([ + 'materials', + 'modules', + 'schema_version', + 'source_commit', + 'tools', + ]); + expect(inventory.modules.map((row) => row.id)).toEqual( + [...inventory.modules.map((row) => row.id)].sort() + ); + const plainDirectory = path.join(root, 'plain-bundles'); + await buildAll({ outDir: plainDirectory }); + for (const module of inventory.modules) { + const bytes = fs.readFileSync(path.join(outDir, module.filename)); + expect(bytes).toEqual(fs.readFileSync(path.join(plainDirectory, module.filename))); + expect(module.size).toBe(bytes.length); + expect(module.sha256).toBe(sha256(bytes)); + expect(Object.keys(module).sort()).toEqual([ + 'contributions', + 'filename', + 'id', + 'sha256', + 'size', + ]); + } + expect(inventory.materials.some((row) => row.kind === 'lifecycle-output')).toBe(false); + expect(inventory.materials.some((row) => row.kind === 'lifecycle-event')).toBe(true); + expect(fs.readFileSync(inventoryPath, 'utf8')).toBe(`${JSON.stringify(inventory)}\n`); + const alias = path.join(root, 'bundle-alias'); + fs.symlinkSync(outDir, alias); + await expect( + collectJs({ + repoRoot, + outDir: alias, + inventoryPath: path.join(outDir, 'inside.json'), + preparationPath, + }) + ).rejects.toThrow(/outside.*bundle/i); + await expect( + collectJs({ + repoRoot: fixture(), + outDir, + inventoryPath: path.join(root, 'wrong-checkout.json'), + preparationPath, + }) + ).rejects.toThrow(/collector.*checkout/i); + const originalPreparation = fs.readFileSync(preparationPath); + const colliding = JSON.parse(originalPreparation); + const collision = 'source:crates/trusted-server-js/lib/package.json'; + colliding.distributions.find((row) => row.material.name === 'node').material.id = collision; + colliding.tools.find((row) => row.name === 'node').material_id = collision; + fs.writeFileSync(preparationPath, JSON.stringify(colliding)); + await expect( + collectJs({ + repoRoot, + outDir, + inventoryPath: path.join(root, 'collision.json'), + preparationPath, + }) + ).rejects.toThrow(/duplicate.*material|material.*identity/i); + expect(fs.existsSync(path.join(root, 'collision.json'))).toBe(false); + fs.writeFileSync(preparationPath, originalPreparation); + const bad = JSON.parse(fs.readFileSync(preparationPath, 'utf8')); + bad.distributions.find((row) => row.material.name === 'vite').path = path.join( + root, + 'fictional-tool' + ); + fs.writeFileSync(path.join(root, 'fictional-tool'), 'unrelated Vite distribution'); + bad.distributions.find((row) => row.material.name === 'vite').material.sha256 = sha256( + 'unrelated Vite distribution' + ); + fs.writeFileSync(preparationPath, JSON.stringify(bad)); + await expect( + collectJs({ repoRoot, outDir, inventoryPath: path.join(root, 'bad.json'), preparationPath }) + ).rejects.toThrow(/actual.*tool|tool.*digest/i); + } finally { + if (oldRelease === undefined) delete process.env.TRUSTED_SERVER__RELEASE_BUILD; + else process.env.TRUSTED_SERVER__RELEASE_BUILD = oldRelease; + if (oldSource === undefined) delete process.env.TRUSTED_SERVER__GIT_VERSION; + else process.env.TRUSTED_SERVER__GIT_VERSION = oldSource; + } + }, 120_000); +}); + +describe('strict parser parity', () => { + it.each([ + Buffer.from('\ufeff{}'), + Buffer.from('"\\ud800"'), + Buffer.from('{"\\ud800":0}'), + Buffer.from('9007199254740993'), + Buffer.from('['.repeat(65) + ']'.repeat(65)), + ])('rejects ambiguous Unicode, integer or container input %#', async (bytes) => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(bytes)).toThrow(); + }); + it('accepts exactly 64 containers and paired Unicode', async () => { + const { strictJson } = await import('../release-composition.mjs'); + expect(() => strictJson(Buffer.from('['.repeat(64) + ']'.repeat(64)))).not.toThrow(); + expect(strictJson(Buffer.from('"\\ud83d\\ude00"'))).toBe('😀'); + }); +}); + +describe('observed lifecycle events', () => { + it('records an executed lifecycle event even when it produced no output file', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ scripts: { postinstall: 'example observed command' } }) + ); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const event = { + kind: 'lifecycle-event', + id: 'lifecycle:example:postinstall', + package_material_id: npm.id, + event: 'postinstall', + script_sha256: sha256('example observed command'), + }; + const preparation = { + schema_version: 1, + tools: [], + distributions: [], + packages: [npm], + lifecycle_events: [event], + lifecycle_outputs: [], + }; + const checked = validatePreparation(preparation, { installRoot: root, packages: [npm] }); + expect(checked.materials).toContainEqual(event); + const changed = structuredClone(preparation); + changed.lifecycle_events[0].script_sha256 = 'b'.repeat(64); + expect(() => validatePreparation(changed, { installRoot: root, packages: [npm] })).toThrow( + /script/i + ); + }); +}); + +describe('observer output boundary', () => { + it('rejects an ES chunk even when its filename matches the IIFE contract', async () => { + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(entry, 'globalThis.exampleResult = 7;'); + const options = moduleBuildOptions({ + name: 'fixture', + entryPath: entry, + outDir: path.join(root, 'out'), + composition: recorder(() => {}), + }); + options.build.rollupOptions.output.format = 'es'; + await expect(build(options)).rejects.toThrow(/IIFE/i); + }); +}); + +describe('complete preparation lifecycle coverage', () => { + it('rejects a missing observation for an installed registry postinstall hook', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ scripts: { postinstall: 'example observed command' } }) + ); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const preparation = { + schema_version: 1, + tools: [], + distributions: [], + packages: [npm], + lifecycle_events: [], + lifecycle_outputs: [], + }; + expect(() => validatePreparation(preparation, { installRoot: root, packages: [npm] })).toThrow( + /missing.*lifecycle|incomplete.*lifecycle/i + ); + }); +}); + +describe('nested package contribution fixture', () => { + it('preserves final bytes while recording the indirect nested installation identity', async () => { + const root = fixture(); + const parentKey = 'node_modules/example-parent'; + const childKey = `${parentKey}/node_modules/example-child`; + for (const [key, name, code] of [ + [parentKey, 'example-parent', "import value from 'example-child'; export default value;"], + [childKey, 'example-child', 'export default 7;'], + ]) { + const directory = path.join(root, key); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name, version: '1.0.0', type: 'module', main: 'index.js' }) + ); + fs.writeFileSync(path.join(directory, 'index.js'), code); + } + const integrity = `sha512-${Buffer.alloc(64).toString('base64')}`; + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [parentKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + [childKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + }, + }) + ); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync( + entry, + "import value from 'example-parent'; globalThis.exampleResult = value;" + ); + let rows; + const recorded = await bundle( + root, + entry, + recorder( + (value) => { + rows = value; + }, + { lockFile: fixtureLock } + ) + ); + expect(recorded).toEqual(await bundle(root, entry)); + expect(rows.find((row) => row.package_name === 'example-child')).toMatchObject({ + lock_path: childKey, + path: 'index.js', + }); + }); + it('rejects installed package aliases before contribution recording', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const real = path.join(root, 'node_modules/example'); + fs.mkdirSync(real, { recursive: true }); + fs.writeFileSync( + path.join(real, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }) + ); + fs.symlinkSync(real, path.join(root, 'node_modules/example-alias')); + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/example': { version: '1.0.0' }, + 'node_modules/example-alias': { version: '1.0.0' }, + }, + }) + ); + expect(() => installedOrigins({ repoRoot, lockFile: fixtureLock })).toThrow(/alias|escape/i); + }); +}); + +describe('approved source module discovery', () => { + it('uses the selected commit despite index removal or an invented staged integration', async () => { + const { expectedModuleIds } = await import( + path.join(repoRoot, 'scripts/release/collect-js.mjs') + ); + expect(typeof expectedModuleIds).toBe('function'); + const root = fixture(); + const git = (args) => + execFileSync( + 'git', + [ + '-c', + 'commit.gpgsign=false', + '-c', + 'user.name=Example', + '-c', + 'user.email=example@example.com', + ...args, + ], + { + cwd: root, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + TMPDIR: process.env.TMPDIR ?? '/private/tmp', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_NO_REPLACE_OBJECTS: '1', + }, + } + ); + git(['init', '-q']); + for (const file of [ + 'crates/trusted-server-js/lib/src/core/index.ts', + 'crates/trusted-server-js/lib/src/integrations/example/index.ts', + ]) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), 'export const example = 7;'); + } + git(['add', '.']); + git(['commit', '-qm', 'Record example source']); + const source = git(['rev-parse', 'HEAD']).trim(); + git(['rm', '--cached', '-q', 'crates/trusted-server-js/lib/src/integrations/example/index.ts']); + const invented = path.join( + root, + 'crates/trusted-server-js/lib/src/integrations/invented/index.ts' + ); + fs.mkdirSync(path.dirname(invented), { recursive: true }); + fs.writeFileSync(invented, 'export const example = 42;'); + git(['add', invented]); + expect(expectedModuleIds(root, source)).toEqual(['core', 'example']); + expect(() => expectedModuleIds(root, 'a'.repeat(40))).toThrow(); + }); +}); + +describe('locked archive source policy', () => { + it.each(['link', 'file', 'git', 'missing'])( + 'rejects unsupported contributing lock source %s', + async (variant) => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8')); + const record = lock.packages['node_modules/dlv']; + if (variant === 'link') record.link = true; + if (variant === 'file') record.resolved = 'file:example.tgz'; + if (variant === 'git') record.resolved = 'git+https://example.com/example.git'; + if (variant === 'missing') delete record.resolved; + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync(fixtureLock, JSON.stringify(lock)); + const origins = installedOrigins({ repoRoot, lockFile: fixtureLock, installRoot: libDir }); + expect(() => origins.packageIdentity('node_modules/dlv')).toThrow( + /lock.*source|archive.*source/i + ); + } + ); +}); + +describe('bounded regular-file readers', () => { + it('rejects a FIFO without waiting for a writer', () => { + const root = fixture(); + const fifo = path.join(root, 'example.fifo'); + execFileSync('mkfifo', [fifo]); + const helper = pathToFileURL(path.join(libDir, 'release-composition.mjs')).href; + const code = `import { readBounded } from ${JSON.stringify(helper)}; try { readBounded(process.argv[1]); process.exit(2); } catch(error) { if (/regular file/.test(error.message)) process.exit(0); throw error; }`; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', code, fifo], { + timeout: 2000, + encoding: 'utf8', + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); +}); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index acb591cdc5..1b754c331f 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -2,7 +2,10 @@ import path from 'node:path'; import { defineConfig } from 'vitest/config'; +import { releaseBuild } from './release-composition.mjs'; + export default defineConfig({ + ...(releaseBuild() ? { envDir: false, mode: 'production' } : {}), resolve: { alias: { // prebid.js doesn't expose src/adapterManager.js via its package diff --git a/crates/trusted-server-js/tests/release_prebuilt.rs b/crates/trusted-server-js/tests/release_prebuilt.rs index 4187db20db..925d54a4f2 100644 --- a/crates/trusted-server-js/tests/release_prebuilt.rs +++ b/crates/trusted-server-js/tests/release_prebuilt.rs @@ -619,6 +619,16 @@ fn emits_rerun_directives_for_inventory_and_release_inputs() { "should watch {variable}" ); } + for path in [ + "lib/build-module-options.mjs", + "lib/release-composition.mjs", + "lib/vitest.config.ts", + ] { + assert!( + stdout.contains(&format!("cargo:rerun-if-changed={path}")), + "should watch JS build and test configuration input {path}" + ); + } for path in [fixture.path("inventory.json"), fixture.path("bundles")] { assert!( stdout.contains(&format!("cargo:rerun-if-changed={}", path.display())), diff --git a/scripts/release/collect-js.mjs b/scripts/release/collect-js.mjs new file mode 100644 index 0000000000..11a136952e --- /dev/null +++ b/scripts/release/collect-js.mjs @@ -0,0 +1,213 @@ +/** + * Produce conservative post-Rollup contributions and final post-esbuild hashes. + * Later minification can eliminate recorded code; rendered_length is not final + * byte attribution. This producer does not prove authenticated acquisition, + * lifecycle execution, host qualification or A/B equality. Task 7 supplies + * observed preparation records; Task 8 qualifies the execution boundary. + */ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { buildAll } from "../../crates/trusted-server-js/lib/build-all.mjs"; +import { + actualToolFiles, + installedOrigins, + installedMaterials, + validatePreparation, + releaseBuild, + readBounded, + strictJson, + sha256, +} from "../../crates/trusted-server-js/lib/release-composition.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const defaultRoot = path.resolve(here, "../.."); +const MAX_BUNDLE = 32 * 1024 * 1024; + +/** Discover modules from the selected source identity. */ +export function expectedModuleIds(repoRoot, sourceCommit) { + if (!/^[a-f0-9]{40}$/.test(sourceCommit ?? "")) + throw new Error("Expected full source commit"); + const gitEnvironment = { + PATH: process.env.PATH, + TMPDIR: process.env.TMPDIR ?? "/private/tmp", + LANG: "C", + LC_ALL: "C", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_OPTIONAL_LOCKS: "0", + }; + const raw = execFileSync( + "git", + ["ls-tree", "-rz", "--name-only", sourceCommit], + { + cwd: repoRoot, + env: gitEnvironment, + maxBuffer: 1024 * 1024, + }, + ); + const tracked = new TextDecoder("utf-8", { fatal: true }) + .decode(raw) + .split("\0") + .filter( + (file) => + file === "crates/trusted-server-js/lib/src/core/index.ts" || + /^crates\/trusted-server-js\/lib\/src\/integrations\/[^/]+\/index\.ts$/.test( + file, + ), + ); + const expected = tracked + .map((file) => + file.endsWith("/core/index.ts") ? "core" : file.split("/").at(-2), + ) + .sort(); + if ( + !expected.includes("core") || + expected.some((id) => !/^[a-z][a-z0-9_-]*$/.test(id)) || + new Set(expected).size !== expected.length + ) + throw new Error("Invalid tracked module identities"); + return expected; +} + +/** Build release bundles using checked observations from independent preparation. */ +export async function collectJs({ + repoRoot = defaultRoot, + outDir, + inventoryPath, + preparationPath, +}) { + if (!preparationPath) + throw new Error("Checked preparation records are required"); + if (!releaseBuild()) + throw new Error("Collector requires the existing release build control"); + repoRoot = fs.realpathSync(repoRoot); + if (repoRoot !== fs.realpathSync(defaultRoot)) + throw new Error("Collector must execute from its own checkout"); + const sourceCommit = process.env.TRUSTED_SERVER__GIT_VERSION; + const libDir = path.join(repoRoot, "crates/trusted-server-js/lib"); + const lockFile = path.join(libDir, "package-lock.json"); + const owners = installedOrigins({ repoRoot, lockFile }); + const packages = installedMaterials(owners); + const strictPreparation = strictJson(readBounded(preparationPath)); + const prepared = validatePreparation(strictPreparation, { + installRoot: libDir, + packages, + }); + for (const actual of actualToolFiles({ installRoot: libDir })) { + const tool = prepared.tools.find( + (record) => record.name === actual.name && record.role === actual.role, + ); + const material = prepared.materials.find( + (record) => record.id === tool?.material_id, + ); + const distribution = strictPreparation.distributions.find( + (record) => record.material.id === tool?.material_id, + ); + if ( + !tool || + tool.version !== actual.version || + material.sha256 !== actual.sha256 || + fs.realpathSync(distribution.path) !== fs.realpathSync(actual.path) + ) + throw new Error( + `Actual build tool digest or identity mismatch: ${actual.name}`, + ); + } + fs.mkdirSync(path.resolve(outDir), { recursive: true }); + outDir = fs.realpathSync(path.resolve(outDir)); + inventoryPath = path.join( + fs.realpathSync(path.dirname(path.resolve(inventoryPath))), + path.basename(inventoryPath), + ); + if ( + inventoryPath === outDir || + inventoryPath.startsWith(`${outDir}${path.sep}`) + ) + throw new Error("Inventory must be outside the bundle directory"); + const expected = expectedModuleIds(repoRoot, sourceCommit); + const records = await buildAll({ + outDir, + sourceDir: path.join(libDir, "src"), + composition: { repoRoot, lockFile }, + }); + if ( + JSON.stringify(records.map((record) => record.id)) !== + JSON.stringify(expected) + ) + throw new Error("Discovered module set differs from tracked source"); + const actualFiles = fs.readdirSync(outDir).sort(); + if ( + JSON.stringify(actualFiles) !== + JSON.stringify(expected.map((id) => `tsjs-${id}.js`).sort()) + ) + throw new Error("Unexpected bundle files"); + const modules = records.map((record) => { + const bytes = readBounded(path.join(outDir, record.filename), MAX_BUNDLE); + if (!bytes.length) throw new Error("Empty release bundle"); + new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { + id: record.id, + filename: record.filename, + size: bytes.length, + sha256: sha256(bytes), + contributions: record.contributions, + }; + }); + const sources = [ + "package.json", + "package-lock.json", + "build-all.mjs", + "build-module-options.mjs", + "release-composition.mjs", + "vitest.config.ts", + ].map((file) => `crates/trusted-server-js/lib/${file}`); + sources.push("scripts/release/collect-js.mjs"); + const materials = [ + ...prepared.materials, + ...sources.map((file) => ({ + kind: "source", + id: `source:${file}`, + path: file, + sha256: sha256(readBounded(path.join(repoRoot, file), MAX_BUNDLE)), + })), + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + if ( + new Set(materials.map((material) => material.id)).size !== materials.length + ) + throw new Error("Duplicate combined material identity"); + const inventory = { + schema_version: 1, + source_commit: sourceCommit, + modules, + tools: prepared.tools, + materials, + }; + const bytes = Buffer.from(`${JSON.stringify(inventory)}\n`); + if (bytes.length > 1024 * 1024) throw new Error("Inventory exceeds 1 MiB"); + fs.writeFileSync(inventoryPath, bytes, { flag: "wx", mode: 0o600 }); + return inventory; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href +) { + const args = process.argv.slice(2); + if ( + args.length !== 6 || + args[0] !== "--out-dir" || + args[2] !== "--inventory" || + args[4] !== "--preparation" + ) + throw new Error( + "Usage: collect-js.mjs --out-dir --inventory --preparation ", + ); + await collectJs({ + outDir: args[1], + inventoryPath: args[3], + preparationPath: args[5], + }); +} diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md new file mode 100644 index 0000000000..da2452d192 --- /dev/null +++ b/scripts/release/js-composition.md @@ -0,0 +1,101 @@ +# JS composition producer interface + +`collect-js.mjs` reuses the ordinary build pipeline with a read-only Rollup +observer. Its version-1 output follows the closed Task 5 root/module schema. +It requires the existing `TRUSTED_SERVER__RELEASE_BUILD=1` control and a full +`TRUSTED_SERVER__GIT_VERSION`. Any skip presence or invalid release control fails. +Expected entry points come from the selected full commit’s Git tree, independently +of the current index. The collector builds only its own checkout, so imported +build/tool code cannot be measured as a different checkout’s installation. Source-byte immutability remains preparation’s check. +The release options disable Vite configuration and `.env` loading and select +production mode; Vitest uses the same release controls. These options do not +remove inherited Node/npm/Vite environment variables: release preflight owns +that rejection. + +## Contribution semantics + +Contributions with positive Rollup `renderedLength` carry +`stage: "rollup-rendered"` and `rendered_length`. Type-only and wholly eliminated +modules are excluded. Later esbuild minification can remove additional code. +These records describe conservative post-Rollup reachability, not final function +reachability or attribution of final bundle bytes. Final module sizes and SHA-256 +are measured from files after Vite/esbuild finishes. + +Source contributions contain repository-relative `path`, original-file `sha256` +and `transform` (`identity`, `raw` or `inline`). npm contributions additionally +contain exact `lock_path`, `package_name`, `package_version` and archive +`integrity`; their paths are relative to the owning installed package. Only the selected registry’s canonical HTTPS tarball sources are supported; +links, missing sources and file/git sources fail. Parent/root aliases are +normalized before nearest-owner lookup; package-root aliases and escapes fail. Unsupported +queries, unmapped owners, symlink escapes and ambiguous identities fail. + +Generated contributions contain `id`, `plugin`, `owner_lock_path` and +`generated_sha256` of the actual transformed virtual code. The reviewed mappings +are Vite's bundled CommonJS helper, CommonJS ES-import wrappers and Vite's preload +helper. Wrappers additionally record the underlying file's `input` origin. +Unknown virtual producers fail. Wrapper IDs use normalized input paths; +transformed-code hashes still measure actual code, which can include checkout +paths. Contribution inventories are not a claim that different checkout paths +produce identical virtual-code hashes. The bundle-byte comparison is separate. + +## Checked preparation input + +The producer accepts a bounded strict UTF-8 JSON document with exactly: + +- `schema_version: 1`; +- `packages`: the full measured installed `npm-package` material set; +- `distributions`: records with exactly `material` and `path`; +- `tools`: records with `name`, `version`, `role`, `material_id`; +- `lifecycle_events`: observed executions, including executions with no output; +- `lifecycle_outputs`: observed output files. + +A package material contains `kind: "npm-package"`, `id: "npm:"`, +`lock_path`, `name`, `version`, `integrity` and `installed_tree_sha256`. +Only installed locked locations are recorded; optional absent platform packages +are not claimed installed. The selected native esbuild and Rollup packages are +mandatory through the actual-tool checks. + +Each distribution material contains `kind: "tool-distribution"`, `id`, `name`, +`version`, `sha256`. `path` selects the actual measured tool entry file or native +executable, never a command to run. Required tools are Node, npm, Vite, Rollup, +esbuild, Vitest and the selected esbuild/Rollup native executables. Paths, versions, +roles and file digests must match the producer's actual tool selection. Whole +locked npm package trees are recorded separately. Tool entry/native file hashes +are measured identities; they are not authenticated distribution acquisition or +qualified host evidence. Task 8 supplies that boundary. + +Lifecycle events contain `kind: "lifecycle-event"`, unique `id`, +`package_material_id`, `event` and `script_sha256`. The script digest is SHA-256 +of the UTF-8 command string in the owning installed package's +`package.json.scripts[event]`. Outputs contain `kind: "lifecycle-output"`, unique +`id`, the same event/script/package join, installation-relative `path`, `size` +and `sha256`. Each output must remain inside its owning package and match an +explicit observed event. Registry-install coverage requires exactly the installed packages’ declared +preinstall, install and postinstall hooks, including hooks producing no files. +Registry dependency prepare scripts do not run in the selected npm ci recipe. +Implicit binding.gyp installs without explicit reviewed hooks are rejected. Neither array is inferred from lockfile flags. Task 7 must produce +these records from actual preparation execution; semantic checks here cannot +prove that an execution occurred or that the observations are exhaustive. +Synthetic fixture observations exercise joins but do not claim genuine execution +history. Missing required events cannot produce an inventory. + +## Installed tree hash + +`installed_tree_sha256` hashes the UTF-8 domain separator +`trusted-server-installed-tree-v1\n`, then entries in recursive sorted pathname +order. A directory contributes compact JSON `[relative_path,"directory"]` plus +newline. A regular file contributes compact JSON +`[relative_path,"file",byte_length,is_executable]`, newline, actual bytes and +newline; `is_executable` means any executable bit is set. A symlink contributes +compact JSON `[relative_path,"symlink",relative_target]` plus newline and must +resolve inside its package. Separately locked nested package roots and their +contents are excluded from the parent's hash. Timestamps, owners and +non-executable permission bits are excluded. Files are read through held +regular-file descriptors with mutation checks and bounded buffers. + +JSON and final inventory are limited to 1 MiB; source and final bundle reads are +limited to 32 MiB. Duplicate keys, invalid UTF-8, BOM, unpaired surrogates, +unsafe integers, non-finite numbers and more than 64 nested containers fail. +The inventory writer sorts module/material/tool records and appends one newline. +The inventory must live outside the bundle directory. This task does not perform +fresh installation, network confinement, A/B comparison, signing or publication. From 27080c5400a5aeaedab5ac52cb657797c9d69bca Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 21:55:01 +0530 Subject: [PATCH 023/153] Check all JavaScript validation sources --- scripts/release/preflight.py | 19 +++- scripts/release/tests/test_preflight.py | 120 ++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py index 36f063cf4b..3e6df1bb59 100644 --- a/scripts/release/preflight.py +++ b/scripts/release/preflight.py @@ -465,7 +465,7 @@ def _audit_configuration(repo, inherited): def _source_roots(repo, entries): - roots = {".cargo", "scripts/release", "crates/trusted-server-js/lib/test"} + roots = {".cargo", "scripts/release", "crates/trusted-server-js/lib"} for name in entries: parts = Path(name).parts if parts[0] == "crates": @@ -495,6 +495,12 @@ def _check_source_parents(repo, path): def _unexpected_sources(repo, entries): + """Audit all JS validation sources, separating direct npm build materials. + + Prepared node_modules contents require a separate material audit. Cargo's + exact empty npm-ci lock is coordination state; other lib files are sources. + """ + library = repo / "crates/trusted-server-js/lib" for relative in sorted(_source_roots(repo, entries)): root = repo / relative _check_source_parents(repo, root) @@ -506,6 +512,17 @@ def _unexpected_sources(repo, entries): for name in children + filenames: path = Path(directory) / name relative_path = str(path.relative_to(repo)) + if path == library / "node_modules": + if not stat.S_ISDIR(path.lstat().st_mode): + raise ValueError("direct node_modules must be a regular directory") + children.remove(name) + continue + if path == library / ".tsjs-npm-ci.lock": + information = path.lstat() + if (not stat.S_ISREG(information.st_mode) + or information.st_size != 0 or information.st_nlink != 1): + raise ValueError(".tsjs-npm-ci.lock must be an empty single-link regular file") + continue if path.is_symlink() and relative_path not in entries: raise ValueError("unexpected source symlink") if not path.is_dir() and relative_path not in entries: diff --git a/scripts/release/tests/test_preflight.py b/scripts/release/tests/test_preflight.py index 2cfc2eb2b8..d84d05d5af 100644 --- a/scripts/release/tests/test_preflight.py +++ b/scripts/release/tests/test_preflight.py @@ -15,6 +15,8 @@ ROOT = RELEASE.parents[1] DISCOVERED_TEST_SOURCES = ( "crates/trusted-server-js/lib/test/extra.test.ts", + "crates/trusted-server-js/lib/unreviewed/extra.test.ts", + "crates/trusted-server-js/lib/unreviewed/nested/extra.test.ts", "crates/example/tests/extra.rs", "crates/example/benches/extra.rs", "crates/example/examples/extra.rs", @@ -344,9 +346,11 @@ def test_ignored_discovered_test_sources_are_rejected_before_execution(self): unexpected.write_text("// Example unexpected executable test source\n") self.assertTrue(self.git("check-ignore", relative).strip(), "should exercise ignored executable test inputs") - with self.assertRaisesRegex(ValueError, "unexpected"): - preflight.source_snapshot(self.repo, self.commit, {}) - unexpected.unlink() + try: + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.source_snapshot(self.repo, self.commit, {}) + finally: + unexpected.unlink() def test_discovered_test_source_additions_are_rejected_after_execution(self): before = preflight.source_snapshot(self.repo, self.commit, {}) @@ -355,9 +359,11 @@ def test_discovered_test_source_additions_are_rejected_after_execution(self): unexpected = self.repo / relative unexpected.parent.mkdir(parents=True, exist_ok=True) unexpected.write_text("// Example unexpected executable test source\n") - with self.assertRaisesRegex(ValueError, "unexpected"): - preflight.assert_source_unchanged(self.repo, self.commit, before, {}) - unexpected.unlink() + try: + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.assert_source_unchanged(self.repo, self.commit, before, {}) + finally: + unexpected.unlink() def test_discovered_test_source_directory_symlinks_are_rejected(self): for relative in DISCOVERED_TEST_SOURCES: @@ -365,9 +371,11 @@ def test_discovered_test_source_directory_symlinks_are_rejected(self): directory = (self.repo / relative).parent directory.parent.mkdir(parents=True, exist_ok=True) directory.symlink_to(self.repo / "crates/example/src", target_is_directory=True) - with self.assertRaisesRegex(ValueError, "symlink"): - preflight.source_snapshot(self.repo, self.commit, {}) - directory.unlink() + try: + with self.assertRaisesRegex(ValueError, "symlink"): + preflight.source_snapshot(self.repo, self.commit, {}) + finally: + directory.unlink() def test_nested_crate_cargo_configuration_layers_are_rejected(self): for filename in ("config", "config.toml"): @@ -397,6 +405,100 @@ def test_download_and_output_directories_are_outside_source_roots(self): destination.write_text("Example generated or downloaded output\n") preflight.assert_source_unchanged(self.repo, self.commit, before, {}) + def test_entire_js_library_rejects_unreviewed_inputs_before_and_after_execution(self): + before = preflight.source_snapshot(self.repo, self.commit, {}) + for relative in ( + "example.ignored", "unreviewed/example.ignored", + "unreviewed/nested/example.ignored", "dist/example.ignored", + "coverage/example.ignored", "unreviewed/.tsjs-npm-ci.lock", + ): + with self.subTest(relative=relative): + unexpected = self.repo / "crates/trusted-server-js/lib" / relative + unexpected.parent.mkdir(parents=True, exist_ok=True) + unexpected.write_bytes(b"") + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.source_snapshot(self.repo, self.commit, {}) + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.assert_source_unchanged(self.repo, self.commit, before, {}) + unexpected.unlink() + + def test_nested_node_modules_are_inside_source_boundary(self): + for relative in ( + "src/node_modules/example/example.ignored", + "unreviewed/node_modules/example/example.ignored", + "unreviewed/nested/node_modules/example/example.ignored", + ): + with self.subTest(relative=relative): + unexpected = self.repo / "crates/trusted-server-js/lib" / relative + unexpected.parent.mkdir(parents=True, exist_ok=True) + unexpected.write_text("Example unreviewed input\n") + with self.assertRaisesRegex(ValueError, "unexpected"): + preflight.source_snapshot(self.repo, self.commit, {}) + unexpected.unlink() + + def test_direct_node_modules_must_be_a_regular_directory(self): + modules = self.repo / "crates/trusted-server-js/lib/node_modules" + for kind in ("file", "link", "broken-link"): + with self.subTest(kind=kind): + modules.unlink(missing_ok=True) + if kind == "file": + modules.write_text("Example unexpected input\n") + else: + target = self.repo / ("crates/example/src" if kind == "link" else "absent") + modules.symlink_to(target, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "node_modules"): + preflight.source_snapshot(self.repo, self.commit, {}) + modules.unlink() + + def test_generated_npm_material_and_empty_cargo_lock_preserve_source_snapshot(self): + before = preflight.source_snapshot(self.repo, self.commit, {}) + library = self.repo / "crates/trusted-server-js/lib" + generated = library / "node_modules/example/nested/extra.test.ts" + generated.parent.mkdir(parents=True) + generated.write_text("// Example downloaded package code\n") + (library / "node_modules/example/linked.js").symlink_to("nested/extra.test.ts") + (library / ".tsjs-npm-ci.lock").touch() + preflight.assert_source_unchanged(self.repo, self.commit, before, {}) + self.assertEqual(preflight.source_snapshot(self.repo, self.commit, {}), before, + "should leave prepared npm materials outside the source snapshot") + + def test_cargo_npm_lock_must_be_empty_regular_and_single_link(self): + library = self.repo / "crates/trusted-server-js/lib" + lock = library / ".tsjs-npm-ci.lock" + empty = Path(self.temporary.name) / "empty-lock" + empty.touch() + for kind in ("nonempty", "link", "broken-link", "hardlink", "directory"): + with self.subTest(kind=kind): + lock.unlink(missing_ok=True) + if kind == "nonempty": + lock.write_text("Example unreviewed input\n") + elif kind in {"link", "broken-link"}: + lock.symlink_to(empty if kind == "link" else empty.with_name("absent")) + elif kind == "hardlink": + os.link(empty, lock) + else: + lock.mkdir() + with self.assertRaisesRegex(ValueError, "npm-ci.lock"): + preflight.source_snapshot(self.repo, self.commit, {}) + if kind == "directory": + lock.rmdir() + else: + lock.unlink() + + def test_committed_js_tests_outside_existing_roots_remain_supported(self): + relative = "crates/trusted-server-js/lib/reviewed/nested/example.spec.ts" + source = self.repo / relative + source.parent.mkdir(parents=True) + source.write_text("// Example reviewed test\n") + self.git("add", relative) + self.git("-c", "commit.gpgsign=false", "commit", "-qm", "Add example reviewed test") + self.commit = self.git("rev-parse", "HEAD").decode().strip() + snapshot = preflight.source_snapshot(self.repo, self.commit, {}) + self.assertIn(relative, snapshot["files"], "should preserve tracked nested validation sources") + source.write_text("// Example mutated test\n") + with self.assertRaisesRegex(ValueError, "source bytes"): + preflight.assert_source_unchanged(self.repo, self.commit, snapshot, {}) + def test_symlinked_source_parent_and_external_link_are_rejected(self): source_directory = self.repo / "crates/example/src" saved = self.repo / "crates/example/saved" From c9683ba30aa4a52319fd7464af8f41d69c90ceb6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 22:00:08 +0530 Subject: [PATCH 024/153] Check lifecycle output package ownership --- .../lib/release-composition.mjs | 15 +++- .../lib/test/release-composition.test.mjs | 82 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-js/lib/release-composition.mjs b/crates/trusted-server-js/lib/release-composition.mjs index 19c6e8ad38..80c19eb699 100644 --- a/crates/trusted-server-js/lib/release-composition.mjs +++ b/crates/trusted-server-js/lib/release-composition.mjs @@ -528,6 +528,12 @@ export function validatePreparation(prepared, { installRoot, packages }) { } if (events.size !== expectedEvents.size) throw new Error('Missing expected lifecycle observations'); + const packageRoots = packages + .map((material) => ({ + id: material.id, + root: fs.realpathSync(path.join(installRoot, material.lock_path)), + })) + .sort((first, second) => second.root.length - first.root.length); for (const output of prepared.lifecycle_outputs) { fields(output, [ 'kind', @@ -558,8 +564,13 @@ export function validatePreparation(prepared, { installRoot, packages }) { throw new Error('Lifecycle script digest mismatch'); const file = path.join(installRoot, output.path); if (!inside(ownerRoot, file)) throw new Error('Lifecycle output owner mismatch'); - if (!inside(fs.realpathSync(installRoot), fs.realpathSync(file))) - throw new Error('Lifecycle output escape'); + const realFile = fs.realpathSync(file); + if (!inside(fs.realpathSync(installRoot), realFile)) throw new Error('Lifecycle output escape'); + if ( + !inside(fs.realpathSync(ownerRoot), realFile) || + packageRoots.find(({ root }) => inside(root, realFile))?.id !== output.package_material_id + ) + throw new Error('Lifecycle output owner mismatch'); if ( !events.has(JSON.stringify([output.package_material_id, output.event, output.script_sha256])) ) diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs index d705cda303..e5de1bfaef 100644 --- a/crates/trusted-server-js/lib/test/release-composition.test.mjs +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -885,3 +885,85 @@ describe('bounded regular-file readers', () => { expect(result.status).toBe(0); }); }); + +describe('lifecycle output package ownership', () => { + it.each(['nested-child', 'symlink-child'])( + 'rejects a parent event claiming a %s package output', + async (variant) => { + const { installedMaterials, installedOrigins, validatePreparation } = + await import('../release-composition.mjs'); + const root = fixture(); + const parentKey = 'node_modules/example-parent'; + const childKey = + variant === 'nested-child' + ? `${parentKey}/node_modules/example-child` + : 'node_modules/example-child'; + for (const [key, name] of [ + [parentKey, 'example-parent'], + [childKey, 'example-child'], + ]) { + const directory = path.join(root, key); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name, + version: '1.0.0', + ...(key === parentKey ? { scripts: { install: 'example install command' } } : {}), + }) + ); + } + const childOutput = path.join(root, childKey, 'output.js'); + fs.writeFileSync(childOutput, 'fictional child output'); + const integrity = `sha512-${Buffer.alloc(64).toString('base64')}`; + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [parentKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + [childKey]: { version: '1.0.0', resolved: archiveSource, integrity }, + }, + }) + ); + const packages = installedMaterials(installedOrigins({ repoRoot, lockFile: fixtureLock })); + const scriptHash = sha256('example install command'); + const event = { + kind: 'lifecycle-event', + id: 'event:parent', + package_material_id: `npm:${parentKey}`, + event: 'install', + script_sha256: scriptHash, + }; + let outputPath = `${childKey}/output.js`; + if (variant === 'symlink-child') { + // Exercise output validation independently of the prior tree measurement. + fs.symlinkSync(path.join(root, childKey), path.join(root, parentKey, 'linked')); + outputPath = `${parentKey}/linked/output.js`; + } + const prepared = { + schema_version: 1, + tools: [], + distributions: [], + packages, + lifecycle_events: [event], + lifecycle_outputs: [ + { + kind: 'lifecycle-output', + id: 'output:parent', + package_material_id: event.package_material_id, + event: event.event, + script_sha256: scriptHash, + path: outputPath, + size: fs.statSync(childOutput).size, + sha256: sha256(fs.readFileSync(childOutput)), + }, + ], + }; + expect(() => validatePreparation(prepared, { installRoot: root, packages })).toThrow( + /owner|escape/i + ); + } + ); +}); From 9684fd868c97a9c789fb6c2f4d40e731d1c24db9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 22:10:34 +0530 Subject: [PATCH 025/153] Bind installed tree modes to opened files --- .../lib/release-composition.mjs | 15 +++--- .../lib/test/release-composition.test.mjs | 51 ++++++++++++++++++- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-js/lib/release-composition.mjs b/crates/trusted-server-js/lib/release-composition.mjs index 80c19eb699..61993b56aa 100644 --- a/crates/trusted-server-js/lib/release-composition.mjs +++ b/crates/trusted-server-js/lib/release-composition.mjs @@ -271,15 +271,20 @@ function stableFile(file, consume) { try { const before = fs.fstatSync(descriptor); if (!before.isFile()) throw new Error(`Expected regular file: ${file}`); - const result = consume(descriptor, before.size); + const result = consume(descriptor, before.size, before.mode); const after = fs.fstatSync(descriptor); const current = fs.lstatSync(file); if ( before.dev !== current.dev || before.ino !== current.ino || before.size !== after.size || + before.size !== current.size || + before.mode !== after.mode || + before.mode !== current.mode || before.mtimeMs !== after.mtimeMs || - before.ctimeMs !== after.ctimeMs + before.mtimeMs !== current.mtimeMs || + before.ctimeMs !== after.ctimeMs || + before.ctimeMs !== current.ctimeMs ) throw new Error(`File changed while measured: ${file}`); return result; @@ -336,10 +341,8 @@ export function installedTreeHash(root, excludedRoots = new Set()) { digest.update(`${JSON.stringify([filename, 'directory'])}\n`); visit(file); } else if (stat.isFile()) { - digest.update( - `${JSON.stringify([filename, 'file', stat.size, Boolean(stat.mode & 0o111)])}\n` - ); - stableFile(file, (descriptor, size) => { + stableFile(file, (descriptor, size, mode) => { + digest.update(`${JSON.stringify([filename, 'file', size, Boolean(mode & 0o111)])}\n`); const buffer = Buffer.alloc(64 * 1024); let count = 0; for (;;) { diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs index e5de1bfaef..6a6cd75582 100644 --- a/crates/trusted-server-js/lib/test/release-composition.test.mjs +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -5,7 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { build } from 'vite'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildAll } from '../build-all.mjs'; import { moduleBuildOptions } from '../build-module-options.mjs'; @@ -967,3 +967,52 @@ describe('lifecycle output package ownership', () => { } ); }); + +describe('installed tree mode measurement boundaries', () => { + it('hashes the executable mode observed on the opened descriptor', async () => { + const { installedTreeHash } = await import('../release-composition.mjs'); + const root = fixture(); + const file = path.join(root, 'example.js'); + fs.writeFileSync(file, 'example bytes'); + fs.chmodSync(file, 0o644); + const oldHash = installedTreeHash(root); + const open = fs.openSync; + let changed = false; + const boundary = vi.spyOn(fs, 'openSync').mockImplementation((...args) => { + if (args[0] === file && !changed) { + changed = true; + fs.chmodSync(file, 0o755); + } + return open(...args); + }); + let observed; + try { + observed = installedTreeHash(root); + } finally { + boundary.mockRestore(); + } + expect(changed).toBe(true); + expect(observed).not.toBe(oldHash); + expect(observed).toBe(installedTreeHash(root)); + }); + + it('rejects a mode change between the final descriptor and pathname checks', async () => { + const { installedTreeHash } = await import('../release-composition.mjs'); + const root = fixture(); + const file = path.join(root, 'example.js'); + fs.writeFileSync(file, 'example bytes'); + fs.chmodSync(file, 0o644); + const lstat = fs.lstatSync; + let observations = 0; + const boundary = vi.spyOn(fs, 'lstatSync').mockImplementation((...args) => { + if (args[0] === file && ++observations === 2) fs.chmodSync(file, 0o755); + return lstat(...args); + }); + try { + expect(() => installedTreeHash(root)).toThrow(/changed/i); + expect(observations).toBe(2); + } finally { + boundary.mockRestore(); + } + }); +}); From 8c8e9a77b418103577a601799a6a0bee8d4e8f36 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 22:30:14 +0530 Subject: [PATCH 026/153] Record JS composition verification and source checks --- .../2026-10-13-attested-release-implementation.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 7263d5e98a..2f14de5956 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -131,11 +131,11 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Modify:** `crates/trusted-server-js/lib/build-module-options.mjs`, `build-all.mjs`. **Create:** `lib/test/release-composition.test.mjs`, `scripts/release/collect-js.mjs`. -- [ ] Write temporary IIFE fixtures for type-only/external Prebid import, eliminated module, transitive npm contributor and plugin/virtual helper. Build with/without recording and assert identical final JS bytes. -- [ ] Run `cd crates/trusted-server-js/lib && npx vitest run test/release-composition.test.mjs`; expect missing recording behavior. -- [ ] Add an opt-in Rollup contribution hook at `moduleBuildOptions()`. Join source/package origins to the lockfile, retain installed/lifecycle/build-tool materials separately, and hash final files after Vite/esbuild. Suppress ambient `.env` loading through the reviewed release invocation; explicit production mode alone is not sufficient. -- [ ] Emit sorted version-1 inventory with source, module names/lengths/digests and origins; ambiguous contributing ownership fails. State conservative post-Rollup reachability limits. -- [ ] Run byte-preservation and ownership negatives plus the full JS test/format checks. Commit `Record release JS composition evidence`. +- [x] Write temporary IIFE fixtures for type-only/external Prebid import, eliminated module, transitive npm contributor and plugin/virtual helper. Build with/without recording and assert identical final JS bytes. +- [x] Run `cd crates/trusted-server-js/lib && npx vitest run test/release-composition.test.mjs`; expect missing recording behavior. +- [x] Add an opt-in Rollup contribution hook at `moduleBuildOptions()`. Join source/package origins to the lockfile, retain installed/lifecycle/build-tool materials separately, and hash final files after Vite/esbuild. Suppress ambient `.env` loading through the reviewed release invocation; explicit production mode alone is not sufficient. +- [x] Emit sorted version-1 inventory with source, module names/lengths/digests and origins; ambiguous contributing ownership fails. State conservative post-Rollup reachability limits. +- [x] Run byte-preservation and ownership negatives plus the full JS test/format checks. Commit `Record release JS composition evidence`. ### Task 7: Prepare and compare two independent JS builds @@ -440,3 +440,7 @@ Task 3 is implemented in `c9076d0f1`, with review corrections in `31ee0739` and Task 4 is implemented in `bfb697404`. Independent spec and quality reviews pass. The controller freshly ran all 13 source-resolution tests and all six owning-build-script/facts/resource tests; the quality reviewer independently repeated both suites. Implementation verification also passes the full native CLI test command, core template tests, CLI/core/Fastly lint, Fastly source-version tests with default and reusable-sandbox features, formatting and staged domain lint. Canonical CLI/Fastly facts derive from their owning Cargo package, target and feature settings; the public data constructor cannot reconfigure those constants and carries no verification authority. Tests compile the actual release cfg and resource-discovery function, reject the existing developer checkout fallback, prove its literal is absent, and preserve ancestor resources. Help/version/config-init portability uses the Cargo-built CLI; complete official release executable startup and cross-tag final-byte equality remain later recipe qualification evidence. No release workflow, publication or provider action is introduced by this task. Task 5 is implemented in `e1606fa3d`. Independent spec and quality reviews pass. The controller freshly ran all 28 native actual-script/helper tests; the spec reviewer independently ran those and all 10 library tests. Implementation verification also passes WASM library tests, the full 50-file/1,211-test JS suite with type checking, Fastly/CLI target checks and lint, formatting, staged domain lint, and release-mode Cargo interface fixtures. The release fixtures use checked mock bundle bytes and a failing npm, proving prebuilt consumption and no npm execution; they do not prove official source approval or JS provenance. Checked inventories are bounded strict JSON, exact sorted identities and byte hashes; native held descriptors, identity checks and private-byte verification reject links and copy-time mutation. The exact raw inventory hash is exposed through Cargo build-script evidence for Task 10. Producers must honor the documented 32 MiB per-module input budget. Contribution/tool/material arrays remain opaque at this boundary; Task 6 supplies semantic composition evidence and Task 7 supplies independent JS builds. Source parent aliases are accepted only when the held resolved directory identity remains unchanged; Cargo-owned private output parents are an explicit trust boundary. Hosted qualification and final retained-module evidence remain pending. + +Task 3 source-check follow-up is implemented in `27080c540`. Independent spec and quality reviews pass after closing the ignored nested Vitest-input gap: the entire JS library is checked, with only its direct real `node_modules` directory and the exact empty regular single-link Cargo coordination file exempted. Nested dependency-directory lookalikes, links, nonempty coordination state and otherwise untracked validation inputs fail. The controller freshly ran all 78 Python tests. This does not approve the existing ignored operator/OS files or weaken tracked-byte, mode, environment or qualification checks. + +Task 6 is implemented in `eeb68f918`, with review corrections in `c9683ba30` and `9684fd868`. Independent spec and quality reviews pass. The controller freshly ran all 42 composition tests, the complete 51-file/1,253-test JS suite with type checking, and all 78 Python tests. Recording preserves all 13 final bundle files byte-for-byte. The producer joins conservative post-Rollup contributions to source, locked npm ownership and known virtual producers, measures final files, and checks closed preparation records and actual selected tool identities. Release Vite and Vitest suppress ambient environment-file loading. Lifecycle outputs cannot be claimed by parent packages when owned by separately locked children; installed-tree file headers and mutation checks use held-descriptor metadata, with two deterministic mode-race regressions. Recorded tool entry hashes and synthetic lifecycle fixtures are interface evidence, not authenticated acquisition or proof of execution history. Fresh installation, actual lifecycle observations, A/B isolation and comparison remain Task 7; actual hosted qualification remains Task 8. No signing, publication or provider action is enabled. From 22368764930e4adaa6b71f3069c21df0bc99f812 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 23:28:06 +0530 Subject: [PATCH 027/153] Build and compare shared release JS inputs --- .../lib/test/release-composition.test.mjs | 18 +- crates/trusted-server-js/lib/vitest.config.ts | 18 +- scripts/release/build-js.sh | 10 + scripts/release/collect-js.mjs | 1 + scripts/release/js-composition.md | 59 ++ scripts/release/preflight.py | 35 +- scripts/release/prepare.py | 809 ++++++++++++++++++ scripts/release/release-tools.json | 27 +- scripts/release/tests/test_js_release.py | 431 ++++++++++ scripts/release/tests/test_prepare.py | 282 ++++++ 10 files changed, 1672 insertions(+), 18 deletions(-) create mode 100755 scripts/release/build-js.sh create mode 100644 scripts/release/prepare.py create mode 100644 scripts/release/tests/test_js_release.py create mode 100644 scripts/release/tests/test_prepare.py diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs index 6a6cd75582..1b1390dc97 100644 --- a/crates/trusted-server-js/lib/test/release-composition.test.mjs +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -241,6 +241,20 @@ describe('release collector boundaries', () => { expect(fs.readFileSync(path.join(root, 'out/tsjs-fixture.js'))).toEqual(expected); const config = (await import('../vitest.config.ts')).default; expect(config.envDir).toBe(false); + expect(config.mode).toBe('production'); + expect(config.server.host).toBe('127.0.0.1'); + const nativeImports = config.plugins.find( + (plugin) => plugin.name === 'release-test-node-builtins' + ); + expect(nativeImports.resolveId('node:fs')).toEqual({ id: 'node:fs', external: true }); + expect(nativeImports.resolveId('dlv')).toBeUndefined(); + delete process.env.TRUSTED_SERVER__RELEASE_BUILD; + vi.resetModules(); + const ordinary = (await import('../vitest.config.ts')).default; + expect(ordinary.server).toBeUndefined(); + expect(ordinary.plugins).toBeUndefined(); + expect(ordinary.envDir).toBeUndefined(); + expect(ordinary.mode).toBeUndefined(); } finally { if (oldRelease === undefined) delete process.env.TRUSTED_SERVER__RELEASE_BUILD; else process.env.TRUSTED_SERVER__RELEASE_BUILD = oldRelease; @@ -251,7 +265,9 @@ describe('release collector boundaries', () => { it('rejects source symlink escape and unknown transform queries', async () => { const { installedOrigins } = await import('../release-composition.mjs'); const root = fixture(); - const external = fs.mkdtempSync('/private/tmp/tsjs-outside-'); + const external = fs.mkdtempSync( + path.join(process.env.TMPDIR ?? '/private/tmp', 'tsjs-outside-') + ); temporary.push(external); fs.writeFileSync(path.join(external, 'outside.ts'), 'export const example = 7;'); const link = path.join(root, 'alias.ts'); diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 1b754c331f..545426ba64 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -5,7 +5,23 @@ import { defineConfig } from 'vitest/config'; import { releaseBuild } from './release-composition.mjs'; export default defineConfig({ - ...(releaseBuild() ? { envDir: false, mode: 'production' } : {}), + ...(releaseBuild() + ? { + envDir: false, + mode: 'production', + server: { host: '127.0.0.1' }, + // Preserve native imports that Vite otherwise replaces with browser stubs in production. + plugins: [ + { + name: 'release-test-node-builtins', + enforce: 'pre' as const, + resolveId(id: string) { + if (id.startsWith('node:')) return { id, external: true }; + }, + }, + ], + } + : {}), resolve: { alias: { // prebid.js doesn't expose src/adapterManager.js via its package diff --git a/scripts/release/build-js.sh b/scripts/release/build-js.sh new file mode 100755 index 0000000000..9f348c5e8b --- /dev/null +++ b/scripts/release/build-js.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# An isolated interpreter prevents caller Python import/configuration injection. +set -eu +exec /usr/bin/python3 -I -B -c ' +import runpy, sys +if sys.version_info < (3, 11): + sys.exit("release qualification pending: authenticated Python 3.11+ required") +sys.argv = sys.argv[1:] +runpy.run_path(sys.argv[0], run_name="__main__") +' "${0%/*}/prepare.py" "$@" diff --git a/scripts/release/collect-js.mjs b/scripts/release/collect-js.mjs index 11a136952e..ccfff467ff 100644 --- a/scripts/release/collect-js.mjs +++ b/scripts/release/collect-js.mjs @@ -165,6 +165,7 @@ export async function collectJs({ "vitest.config.ts", ].map((file) => `crates/trusted-server-js/lib/${file}`); sources.push("scripts/release/collect-js.mjs"); + sources.push("scripts/release/build-js.sh", "scripts/release/prepare.py"); const materials = [ ...prepared.materials, ...sources.map((file) => ({ diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index da2452d192..a44f9c4eae 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -99,3 +99,62 @@ unsafe integers, non-finite numbers and more than 64 nested containers fail. The inventory writer sorts module/material/tool records and appends one newline. The inventory must live outside the bundle directory. This task does not perform fresh installation, network confinement, A/B comparison, signing or publication. + +## Independent preparation recipe + +`build-js.sh` runs the isolated Python entry in `prepare.py`. The official entry +requires qualified acquisitions and hosted denial evidence; it currently refuses +before creating work directories. There is no command-line fixture bypass. +Task 8 owns the source-owned authenticated tool and boundary selection. + +The separate `local_js_pair` Python API exercises the actual full recipe using +macOS sandbox-exec and returns `local-unqualified`. It cannot qualify a release. +Each installation has its own tracked-object snapshot, sanitized private Git +object store, cache, node_modules, HOME, TMP, observations and bundles. Shared +archive/tool paths permit reads only; sibling workspace data and metadata, shared +writes, network sockets and arbitrary Mach services are denied. Local system +runtime and developer-tool directories are explicitly readable. These local path +measurements do not authenticate an image, SDK or distribution acquisition. + +Archive filenames are locked SHA-512 hexadecimal digests followed by `.tgz`. +Preparation authenticates raw bytes before bounded decompression/tar inspection. +Three pinned npm archives contain the identical raw pair +`package/./dist/index.js` and `package/dist/index.js`: agent-base 7.1.4, +http-proxy-agent 7.0.2 and https-proxy-agent 7.0.6. Their exact locked SHA-512 +identities are source-owned exceptions in `NPM_ALIAS_ARCHIVES`. Both regular +entries must have identical size, bytes, type and mode, and their canonical file +is materialized once. Other duplicates, dot aliases, traversal, links, devices +and privileged modes fail. SHA-256/exact-root registry validation and exact Git +revision/whole-tree validation are also reusable by later Cargo preparation. + +Each private cache is seeded via pinned npm's public `cache add` API using only +verified archives, offline and with scripts disabled. Fresh pinned npm ci runs +offline with dev dependencies included, audit/fund disabled and hooks in the +foreground. Root install/prepare hooks and implicit binding.gyp installs fail. +Only the two exact reviewed dependency hooks are enabled. A recipe-owned script +shell records actual starts, completions, failures, command hashes and changed +owned files, including hooks with no outputs; optional failures cannot disappear +behind npm's success status. Its effective PATH binds bare `node` to the selected +runtime. Task 6 measures the full installed locked trees and actual executables. + +Collector and Vitest execute under the same denial policy, with source checks +covering all tracked inputs and unexpected files before and after each stage. +Vitest is unconditional and uses a literal host in release mode to avoid +localhost DNS resolution; it retains its middleware mode without a network +server. Its release-only test plugin preserves native `node:` imports that Vite +otherwise replaces with browser stubs in production mode; npm imports are not +externalized by this plugin. Collector builds use `configFile: false`, so the +test plugin never affects published IIFEs. Ordinary development retains its +existing config. Only final bundle bytes must match: all tracked integrations and core +are compared. A's bundles and inventory are published together only after +equality, in one atomic parent with `bundles/` and sibling `tsjs-inventory.json`. +B's inventory can legitimately contain different virtual-code hashes. + +Run ordinary fixtures with `python3 -B -m unittest discover -s +scripts/release/tests -p 'test_*.py' -v`. On macOS, explicitly enable actual local +boundary probes with `TS_RELEASE_BOUNDARY_TESTS=1`, and the full two-installation +probe with `TS_RELEASE_FULL_JS_TESTS=1`. Select Node 24.12.0 on the fixture PATH; +the exact npm 11.6.2 is checked. The full local test authenticates original archive +bytes read from the local npm cache and never copies cache indexes or installed +trees. These probes require sandbox-exec outside an enclosing application +sandbox. Hosted qualification remains pending regardless of their result. diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py index 3e6df1bb59..0b3800a6e0 100644 --- a/scripts/release/preflight.py +++ b/scripts/release/preflight.py @@ -119,10 +119,10 @@ def fixed_tools(): }) recipes.append({ "id": "tsjs", - "script": "scripts/release/prepare-js.mjs", + "script": "scripts/release/build-js.sh", "sha256": None, "qualification": "pending", - "settings": recipe_settings(), + "settings": js_recipe_settings(), }) return { "schema_version": 1, @@ -155,6 +155,30 @@ def recipe_settings(): } +def js_recipe_settings(): + """Return only effective selectors executed by the independent JS recipe.""" + return { + "fresh_source": "tracked-objects-only", + "fresh_installations": 2, + "fresh_private_npm_caches": True, + "npm_version": "11.6.2", + "node_version": "24.12.0", + "npm_offline": True, + "npm_include_dev": True, + "npm_audit": False, + "npm_fund": False, + "npm_lifecycle": "reviewed-observed-foreground", + "mandatory_tests": True, + "compare": "all-tracked-final-module-bytes", + "publish": "A-only-after-equality", + "network_boundary": "qualification-pending", + "tool_paths": "authenticated-absolute-paths-required", + "vite_mode": "production", + "vite_env_loading": False, + "vite_config_loading": False, + } + + def fixed_policy(): """Return the embedded consumer contract; its hash is computed externally.""" repository = "https://github.com/IABTechLab/trusted-server" @@ -556,7 +580,12 @@ def source_snapshot(repo, commit, inherited): _audit_configuration(repo, inherited) if _git(repo, ["cat-file", "-t", commit]).strip() != b"commit": raise ValueError("source revision must be an actual commit object") - epoch = int(_git(repo, ["show", "-s", "--format=%ct", commit]).strip()) + # Read the selected object directly; tracked snapshots need no parent history. + header = _git(repo, ["cat-file", "commit", commit]).split(b"\n\n", 1)[0] + committers = [line for line in header.splitlines() if line.startswith(b"committer ")] + if len(committers) != 1 or not re.search(rb" [0-9]+ [+-][0-9]{4}$", committers[0]): + raise ValueError("invalid committed source timestamp") + epoch = int(committers[0].rsplit(b" ", 2)[1]) require_nonnegative_integer(epoch) entries = {} for record in _git(repo, ["ls-tree", "-rz", "--full-tree", commit]).split(b"\0"): diff --git a/scripts/release/prepare.py b/scripts/release/prepare.py new file mode 100644 index 0000000000..1a1102d5b1 --- /dev/null +++ b/scripts/release/prepare.py @@ -0,0 +1,809 @@ +"""Authenticate materials and compare independently prepared JS installations. + +The official CLI remains closed while qualification is pending. LocalMacBoundary +and local_js_pair are explicitly local fixture APIs: they provide actual denial +and execution observations, never authenticated host/tool acquisition evidence. +""" + +import argparse +import base64 +import hashlib +import gzip +import io +import logging +import os +import re +import shutil +import stat +import subprocess +import sys +import tarfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from urllib.parse import urlsplit + +sys.dont_write_bytecode = True +# -I excludes both caller Python paths and this script directory. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from common import (deterministic_json_bytes, load_json_bytes, read_bounded, + read_json_file, sha256_bytes) +import preflight + +MAX_JSON = 1048576 +MAX_FILE = 33554432 +MAX_ARCHIVE = 134217728 +MAX_EXPANDED = 536870912 +MAX_MEMBERS = 100000 +LIB = Path("crates/trusted-server-js/lib") +HOOKS = ("preinstall", "install", "postinstall") +ROOT_HOOKS = (*HOOKS, "prepublish", "preprepare", "prepare", "postprepare") +REVIEWED_HOOKS = { + ("esbuild", "0.27.3", "postinstall"): "node install.js", + ("core-js", "3.48.0", "postinstall"): 'node -e "try{require(\'./postinstall\')}catch(e){}"', +} +# These exact authenticated npm archives contain one identical redundant file. +# The two raw paths are package/./dist/index.js and package/dist/index.js. +# Unknown archives, unequal bytes/modes/types and additional duplicates fail. +NPM_ALIAS_ARCHIVES = { + "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==": ("agent-base", "7.1.4"), + "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==": ("http-proxy-agent", "7.0.2"), + "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==": ("https-proxy-agent", "7.0.6"), +} + + +@dataclass(frozen=True) +class VerifiedArchive: + """Authenticated original archive identity plus its checked member catalog.""" + + path: Path + integrity: str + algorithm: str + root: str + paths: tuple + manifest: dict | None + + +def _regular_bytes(path, maximum): + path = Path(path) + try: + descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + except OSError as error: + raise ValueError("material must be a regular file without a symlink") from error + with os.fdopen(descriptor, "rb") as stream: + before = os.fstat(stream.fileno()) + identity = lambda value: (value.st_dev, value.st_ino, value.st_mode, value.st_nlink, + value.st_size, value.st_mtime_ns, value.st_ctime_ns) + if not stat.S_ISREG(before.st_mode) or identity(path.lstat()) != identity(before): + raise ValueError("material must be a regular file without a symlink") + data = read_bounded(stream, max_bytes=maximum) + after = os.fstat(stream.fileno()) + if identity(before) != identity(after) or identity(path.lstat()) != identity(after): + raise ValueError("material changed during measurement") + return data + + +def _authenticated_bytes(path, integrity, algorithm, max_bytes): + data = _regular_bytes(path, max_bytes) + if algorithm == "sha512": + if not isinstance(integrity, str) or not re.fullmatch(r"sha512-[A-Za-z0-9+/]{86}==", integrity): + raise ValueError("archive integrity must be locked SHA-512") + expected = base64.b64decode(integrity[7:], validate=True) + actual = hashlib.sha512(data).digest() + elif algorithm == "sha256": + if not isinstance(integrity, str) or not re.fullmatch(r"[0-9a-f]{64}", integrity): + raise ValueError("archive integrity must be locked SHA-256") + expected = bytes.fromhex(integrity) + actual = hashlib.sha256(data).digest() + else: + raise ValueError("unsupported archive integrity algorithm") + if actual != expected: + raise ValueError("archive integrity mismatch") + return data + + +def _approved_alias(integrity, first, second, first_bytes, second_bytes): + return (integrity in NPM_ALIAS_ARCHIVES and first.isfile() and second.isfile() + and {first.name, second.name} == {"package/./dist/index.js", "package/dist/index.js"} + and (first.size, first.mode, first.type) == (second.size, second.mode, second.type) + and len(first_bytes) == len(second_bytes) == first.size and first_bytes == second_bytes) + + +def _archive_catalog(data, max_expanded, max_members, integrity=None): + seen = {} + roots = set() + total = 0 + member_count = 0 + merged_alias = False + # Bound the entire decompressed tar, including PAX/GNU metadata, before parsing. + with gzip.GzipFile(fileobj=io.BytesIO(data)) as compressed: + expanded = read_bounded(compressed, max_bytes=max_expanded) + with tarfile.open(fileobj=io.BytesIO(expanded), mode="r:") as stream: + for member in stream: + name = member.name.rstrip("/") if member.isdir() else member.name + parts = name.split("/") + if (not name or "\\" in name or "\0" in name or name.startswith("/") + or any(part in {"", ".."} for part in parts)): + raise ValueError("unsafe archive member path") + parts = [part for part in parts if part != "."] + if not parts: + raise ValueError("unsafe archive member path") + name = "/".join(parts) + raw_name = member.name.rstrip("/") if member.isdir() else member.name + if raw_name != name and not ( + integrity in NPM_ALIAS_ARCHIVES and member.name == "package/./dist/index.js"): + raise ValueError("unknown archive path alias") + member_count += 1 + total += member.size + if total > max_expanded or member_count > max_members: + raise ValueError("archive expanded size or member bound exceeded") + if not (member.isdir() or member.isfile()): + raise ValueError("unsupported archive member") + if member.mode & 0o7000: + raise ValueError("privileged archive mode is forbidden") + if name in seen: + previous = seen[name] + if (merged_alias or not previous.isfile() or not member.isfile() or not _approved_alias( + integrity, previous, member, + read_bounded(stream.extractfile(previous), max_bytes=MAX_FILE), + read_bounded(stream.extractfile(member), max_bytes=MAX_FILE))): + raise ValueError("duplicate archive member") + merged_alias = True + continue + seen[name] = member + roots.add(parts[0]) + for name, member in seen.items(): + for parent in PurePosixPath(name).parents: + if str(parent) in seen and not seen[str(parent)].isdir(): + raise ValueError("archive file/directory collision") + if len(roots) != 1: + raise ValueError("archive must have one root") + root = next(iter(roots)) + if root in seen and not seen[root].isdir(): + raise ValueError("archive root must be a directory") + manifest_member = seen.get(root + "/package.json") + manifest = None + if manifest_member is not None: + if not manifest_member.isfile(): + raise ValueError("archive manifest must be a regular file") + manifest = load_json_bytes(read_bounded(stream.extractfile(manifest_member), max_bytes=MAX_JSON), + max_bytes=MAX_JSON) + if not isinstance(manifest, dict): + raise ValueError("archive manifest must be an object") + if integrity in NPM_ALIAS_ARCHIVES and (not merged_alias or manifest is None or + (manifest.get("name"), manifest.get("version")) != NPM_ALIAS_ARCHIVES[integrity]): + raise ValueError("pinned npm archive alias identity mismatch") + return root, seen, manifest + + +def verify_archive(path, integrity, *, algorithm="sha512", expected_root=None, + max_bytes=MAX_ARCHIVE, max_expanded=MAX_EXPANDED, max_members=MAX_MEMBERS): + """Authenticate raw registry bytes before bounded safe tar inspection. + + SHA-256 and an exact name-version root also support Cargo registry materials. + No extraction, trust in restored trees, or checksum rewriting occurs here. + Raise ValueError for invalid identities, unsafe members or exceeded bounds. + """ + data = _authenticated_bytes(path, integrity, algorithm, max_bytes) + root, members, manifest = _archive_catalog(data, max_expanded, max_members, integrity) + if expected_root is not None and root != expected_root: + raise ValueError("archive root mismatch") + return VerifiedArchive(Path(path).absolute(), integrity, algorithm, root, + tuple(sorted(name[len(root) + 1:] for name in members if name != root)), manifest) + + +def materialize_archive(checked, destination): + """Extract authenticated bytes into a new tree, rejecting stale destinations.""" + destination = Path(destination) + if os.path.lexists(destination): + raise ValueError("archive destination must be fresh") + data = _authenticated_bytes(checked.path, checked.integrity, checked.algorithm, MAX_ARCHIVE) + root, catalog, _ = _archive_catalog(data, MAX_EXPANDED, MAX_MEMBERS, checked.integrity) + if root != checked.root: + raise ValueError("archive root changed") + destination.mkdir(parents=True) + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as stream: + for name, member in sorted(catalog.items()): + if name == root: + continue + path = destination / name[len(root) + 1:] + if member.isdir(): + path.mkdir(parents=True, exist_ok=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as output: + shutil.copyfileobj(stream.extractfile(member), output, 65536) + path.chmod(0o755 if member.mode & 0o111 else 0o644) + + +def verify_git_revision(repo, revision, expected_tree=None): + """Validate exact Git revision/tree with no replacement/configuration channels.""" + preflight._commit_id(revision) + repo = Path(repo).resolve() + configuration = preflight._git(repo, ["config", "--local", "--no-includes", "--name-only", "--list"]) + for key in configuration.decode("utf-8").splitlines(): + if key.startswith(("include.", "includeif.", "filter.", "extensions.", "credential.")) or key in { + "core.worktree", "core.fsmonitor", "core.attributesfile", "core.sshcommand", "core.hookspath", + }: + raise ValueError("unreviewed Git configuration channel") + if preflight._git(repo, ["for-each-ref", "--format=%(refname)", "refs/replace/"]).strip(): + raise ValueError("Git replacement references are forbidden") + directory = Path(preflight._git(repo, ["rev-parse", "--absolute-git-dir"]).decode().strip()) + for name in ("objects/info/alternates", "info/grafts", "shallow"): + if os.path.lexists(directory / name): + raise ValueError("Git alternates, grafts and shallow revisions are forbidden") + if preflight._git(repo, ["cat-file", "-t", revision]).strip() != b"commit": + raise ValueError("Git revision must be an actual commit") + tree = preflight._git(repo, ["rev-parse", revision + "^{tree}"]).decode().strip() + if expected_tree is not None and expected_tree != tree: + raise ValueError("Git revision tree mismatch") + return tree + + +def materialize_source(repo, revision, destination): + """Export tracked object bytes/modes, with a sanitized private Git object store. + + Ignore archive attributes, index flags, ambient files and ambient Git metadata. + Only the fixed CLAUDE.md -> AGENTS.md link is allowed. Raise ValueError for + unsupported modes, unsafe paths, stale destinations or object disagreement. + """ + destination = Path(destination) + if os.path.lexists(destination): + raise ValueError("source destination must be fresh") + tree = verify_git_revision(repo, revision) + entries = [] + for raw in preflight._git(repo, ["ls-tree", "-rtz", "--full-tree", revision]).split(b"\0"): + if not raw: + continue + identity, filename = raw.split(b"\t", 1) + mode, kind, oid = identity.decode("ascii").split() + name = filename.decode("utf-8") + if (kind not in {"blob", "tree"} or mode not in {"040000", "100644", "100755", "120000"} + or name.startswith("/") or "\\" in name or any(part in {"", ".", "..", ".git"} + for part in name.split("/"))): + raise ValueError("unsupported source tree entry") + entries.append((mode, kind, oid, name)) + objects = {revision: "commit", tree: "tree"} + objects.update({oid: kind for _, kind, oid, _ in entries}) + if len(objects) > MAX_MEMBERS: + raise ValueError("source object bound exceeded") + data = io.BytesIO(preflight._git(repo, ["cat-file", "--batch"], + "".join(oid + "\n" for oid in objects).encode("ascii"))) + blobs = {} + total = 0 + for oid, kind in objects.items(): + header = data.readline().decode("ascii").strip().split() + if len(header) != 3 or header[:2] != [oid, kind]: + raise ValueError("source object disagreement") + size = int(header[2]) + total += size + if size > MAX_FILE or total > MAX_EXPANDED: + raise ValueError("source object size bound exceeded") + content = data.read(size) + if len(content) != size or data.read(1) != b"\n": + raise ValueError("incomplete source object") + blobs[oid] = content + if data.read(1): + raise ValueError("extra source objects") + for mode, _, oid, name in entries: + if mode == "120000" and (name != "CLAUDE.md" or blobs[oid] != b"AGENTS.md" + or not any(entry[3] == "AGENTS.md" and entry[1] == "blob" for entry in entries)): + raise ValueError("unsupported source link") + destination.mkdir(parents=True) + preflight._git(destination, ["init", "--template="]) + for oid, kind in objects.items(): + actual = preflight._git(destination, ["hash-object", "-w", "-t", kind, "--stdin"], blobs[oid]) + if actual.strip().decode("ascii") != oid: + raise ValueError("private Git object disagreement") + for mode, kind, oid, name in entries: + if kind == "tree": + continue + path = destination / name + path.parent.mkdir(parents=True, exist_ok=True) + if mode == "120000": + path.symlink_to("AGENTS.md") + else: + path.write_bytes(blobs[oid]) + path.chmod(0o755 if mode == "100755" else 0o644) + preflight._git(destination, ["update-ref", "HEAD", revision]) + return source_check(destination, revision) + + +def source_check(repo, revision, before=None): + """Check all tracked sources plus unexpected tests, data and configuration.""" + snapshot = preflight.source_snapshot(Path(repo), revision, {}) + expected = set(snapshot["files"]) + for directory, children, files in os.walk(repo, followlinks=False): + directory = Path(directory) + if directory == Path(repo): + children[:] = [name for name in children if name != ".git"] + if directory == Path(repo) / LIB: + children[:] = [name for name in children if name != "node_modules"] + for name in [*children, *files]: + path = directory / name + if path.is_file() or path.is_symlink(): + if str(path.relative_to(repo)) not in expected: + raise ValueError("unexpected source file") + if before is not None and snapshot != before: + raise ValueError("source changed during release execution") + return snapshot + + +def review_lifecycle(manifest, *, root=False, paths=()): + """Reject unreviewed commands and implicit native installs before npm runs.""" + scripts = manifest.get("scripts", {}) + if not isinstance(scripts, dict): + raise ValueError("lifecycle scripts require review") + if "binding.gyp" in paths and not scripts.get("install") and not scripts.get("preinstall"): + raise ValueError("unsupported implicit native lifecycle") + reviewed = {} + for event in ROOT_HOOKS if root else HOOKS: + command = scripts.get(event) + if command is None: + continue + if (root or not isinstance(command, str) or not command or + REVIEWED_HOOKS.get((manifest.get("name"), manifest.get("version"), event)) != command): + raise ValueError("lifecycle command requires explicit review") + reviewed[event] = command + return reviewed + + +def required_npm_materials(lock, platform, architecture): + """Select current-platform optional packages with mandatory native tools.""" + if platform not in {"darwin", "linux"} or architecture not in {"arm64", "x64"}: + raise ValueError("unsupported native platform") + if lock.get("lockfileVersion") != 3 or not isinstance(lock.get("packages"), dict): + raise ValueError("expected npm lock v3") + native = {f"node_modules/@esbuild/{platform}-{architecture}", + f"node_modules/@rollup/rollup-{platform}-{architecture}" + ("-gnu" if platform == "linux" else "")} + if not native.issubset(lock["packages"]): + raise ValueError("required native tools missing from lock") + selected = {} + for location, record in lock["packages"].items(): + if not location: + continue + if not re.fullmatch(r"node_modules/(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9._-]+" + r"(?:/node_modules/(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9._-]+)*", location): + raise ValueError("unsupported npm lock path") + url = urlsplit(record.get("resolved", "")) + if (record.get("link") is not None or url.scheme != "https" or url.netloc != "registry.npmjs.org" + or url.query or url.fragment or not url.path.endswith(".tgz")): + raise ValueError("unsupported npm registry source") + for selector, wanted in (("os", platform), ("cpu", architecture), ("libc", "glibc")): + values = record.get(selector, []) + if not isinstance(values, list) or any(not isinstance(value, str) for value in values): + raise ValueError("unsupported npm platform selector") + positive = [value for value in values if not value.startswith("!")] + if "!" + wanted in values or (positive and wanted not in positive): + if not record.get("optional"): + raise ValueError("required npm package has incompatible platform") + break + else: + selected[location] = record + if not native.issubset(selected): + raise ValueError("required native archives cannot be omitted") + return selected + + +class LocalMacBoundary: + """Actual local macOS denial, explicitly unqualified for hosted releases.""" + + def __init__(self, private_root, readonly): + if sys.platform != "darwin": + raise ValueError("local fixture boundary requires macOS; hosted qualification pending") + self.private_root = Path(private_root).resolve(strict=True) + self.readonly = [Path(path).resolve(strict=True) for path in readonly] + if any(self.private_root.is_relative_to(path) or path.is_relative_to(self.private_root) + for path in self.readonly): + raise ValueError("readonly tool paths must be disjoint from private workspace") + + def command(self, command): + quote = lambda path: deterministic_json_bytes(str(path)).decode().strip() + profile = ["(version 1)", "(deny default)", "(allow process*)", "(allow signal)", + "(allow sysctl-read)", + f"(allow file-read* file-write* (subpath {quote(self.private_root)}))", + '(allow file-read* (literal "/"))', + '(allow file-read* (literal "/private/var/select/sh"))', + '(allow file-read* file-write* (literal "/dev/null"))', + '(allow file-read* (literal "/dev/urandom") (literal "/dev/random"))'] + profile.extend(f"(allow file-read* (subpath {quote(path)}))" for path in self.readonly) + ancestors = {parent for path in [self.private_root, *self.readonly] for parent in path.parents} + profile.extend(f"(allow file-read-metadata (literal {quote(path)}))" for path in sorted(ancestors)) + # Network, other private workspaces and shared writes remain denied. + return ["/usr/bin/sandbox-exec", "-p", "".join(profile), *map(str, command)] + + +def checked_stage(repo, revision, command, environment, log, *, boundary=None, cwd=None): + """Run a real child and source checks before/after, preserving private logs.""" + before = source_check(repo, revision) + with Path(log).open("xb") as output: + result = subprocess.run(boundary.command(command) if boundary else command, + env=environment, cwd=cwd or repo, stdout=output, + stderr=subprocess.STDOUT, timeout=900, check=False) + source_check(repo, revision, before) + if result.returncode: + raise ValueError(f"release stage failed ({result.returncode}); inspect private log {log}") + + +def js_commands(workspace, node): + """Return mandatory local collector and Vitest commands, without npx fallback.""" + workspace = Path(workspace) + repo = workspace / "source" + return [ + [str(node), str(repo / "scripts/release/collect-js.mjs"), "--out-dir", str(workspace / "bundles"), + "--inventory", str(workspace / "inventory.json"), "--preparation", str(workspace / "preparation.json")], + [str(node), str(repo / LIB / "node_modules/vitest/vitest.mjs"), "run"], + ] + + +def local_environment(workspace, node, revision): + """Construct fresh local fixture environment; no qualified acquisition claim.""" + workspace = Path(workspace).resolve() + node = Path(node).resolve(strict=True) + preflight._commit_id(revision) + for name in ("home", "tmp"): + (workspace / name).mkdir(exist_ok=True) + developer_git = [directory for directory in (Path("/Applications/Xcode.app/Contents/Developer/usr/bin"), + Path("/Library/Developer/CommandLineTools/usr/bin")) + if (directory / "git").is_file()] + tool_paths = [str(node.parent), *(str(path) for path in developer_git), "/usr/bin", "/bin"] + return { + "HOME": str(workspace / "home"), "TMPDIR": str(workspace / "tmp"), + "PATH": os.pathsep.join(tool_paths), + "LANG": "C", "LC_ALL": "C", "TZ": "UTC", "CI": "true", + "NODE_ENV": "production", "TRUSTED_SERVER__RELEASE_BUILD": "1", + "TRUSTED_SERVER__GIT_VERSION": revision, + "SOURCE_DATE_EPOCH": str(source_check(workspace / "source", revision)["source_date_epoch"]), + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_NO_REPLACE_OBJECTS": "1", "GIT_OPTIONAL_LOCKS": "0", + } + + +def _npm_command(node): + return [str(node), str(Path(node).parent.parent / "lib/node_modules/npm/bin/npm-cli.js")] + + +def _check_local_versions(node, environment): + for command, expected in (([str(node), "--version"], "v24.12.0"), + (_npm_command(node) + ["--version"], "11.6.2")): + result = subprocess.run(command, env=environment, capture_output=True, timeout=30, check=False) + if result.returncode or result.stdout.decode("utf-8").strip() != expected: + raise ValueError("actual Node/npm version differs from pinned recipe") + + +def _owned_files(package, excluded): + files = {} + for directory, children, names in os.walk(package, followlinks=False): + directory = Path(directory) + children[:] = [name for name in children if directory / name not in excluded] + for name in children + names: + path = directory / name + if path.is_symlink(): + raise ValueError("unsupported lifecycle symlink") + if path.is_file(): + data = _regular_bytes(path, MAX_FILE) + files[str(path.relative_to(package))] = { + "size": len(data), "sha256": sha256_bytes(data), + "executable": bool(path.stat().st_mode & 0o111), + } + return files + + +def lifecycle_shell(arguments, node): + """Recipe-owned npm script shell observes actual invocation and completion. + + Npm's foreground option serializes dependency hooks. Failed/incomplete hooks + are retained and rejected even when npm swallows an optional package failure. + """ + if len(arguments) != 2 or arguments[0] != "-c": + raise ValueError("unexpected npm lifecycle shell arguments") + command = arguments[1] + package = Path.cwd().resolve() + # The generated script shell supplies only this fixed private workspace path. + workspace = Path(os.environ["TRUSTED_RELEASE_PRIVATE_WORKSPACE"]).resolve() + install_root = workspace / "source" / LIB + review = read_json_file(workspace / "reviewed-lifecycle.json", max_bytes=MAX_JSON) + location = str(package.relative_to(install_root)) + event = os.environ.get("npm_lifecycle_event") + if (location not in review or event not in HOOKS or review[location].get(event) != command + or os.environ.get("npm_lifecycle_script") != command): + raise ValueError("unreviewed actual lifecycle invocation") + manifest = read_json_file(package / "package.json", max_bytes=MAX_JSON) + if review_lifecycle(manifest).get(event) != command: + raise ValueError("lifecycle source script changed") + lock = read_json_file(install_root / "package-lock.json", max_bytes=MAX_JSON) + excluded = {install_root / name for name in lock["packages"] if name and name != location} + before = _owned_files(package, excluded) + identity = f"lifecycle:{location}:{event}" + record_path = workspace / "lifecycle" / (sha256_bytes(identity.encode()) + ".json") + record = {"id": identity, "lock_path": location, "event": event, + "script_sha256": sha256_bytes(command.encode()), "state": "started"} + with record_path.open("xb") as output: + output.write(deterministic_json_bytes(record)) + # npm prepends dependency .bin directories; remove tool-shadowing search paths. + child_environment = {**os.environ, "PATH": os.pathsep.join((str(Path(node).parent), "/usr/bin", "/bin"))} + result = subprocess.run(["/bin/sh", "-c", command], env=child_environment, check=False, timeout=120) + after = _owned_files(package, excluded) + if before.keys() - after.keys(): + raise ValueError("lifecycle deletion requires reviewed output representation") + record.update({"state": "completed", "returncode": result.returncode, + "outputs": [{"path": location + "/" + name, **facts} + for name, facts in sorted(after.items()) if before.get(name) != facts]}) + record_path.write_bytes(deterministic_json_bytes(record)) + return result.returncode + + +def observed_lifecycle(workspace): + """Convert successful actual execution observations to the Task 6 joins.""" + workspace = Path(workspace) + events, outputs = [], [] + for path in sorted((workspace / "lifecycle").glob("*.json")): + record = read_json_file(path, max_bytes=MAX_JSON) + if record.get("state") != "completed" or record.get("returncode") != 0: + raise ValueError("lifecycle execution failed or is incomplete") + event = {"kind": "lifecycle-event", "id": record["id"], + "package_material_id": "npm:" + record["lock_path"], "event": record["event"], + "script_sha256": record["script_sha256"]} + events.append(event) + for facts in record["outputs"]: + outputs.append({"kind": "lifecycle-output", "id": record["id"] + ":" + facts["path"], + "package_material_id": event["package_material_id"], "event": event["event"], + "script_sha256": event["script_sha256"], "path": facts["path"], + "size": facts["size"], "sha256": facts["sha256"]}) + return events, outputs + + +def install_npm(workspace, revision, archives, node, environment, *, boundary=None): + """Seed a fresh private cache through npm, then execute reviewed offline ci. + + No restored installation/cache is accepted. Each selected archive is checked + again and every lifecycle declaration is reviewed before any installation. + The caller must supply the selected execution boundary for release-shaped + local fixtures; the official CLI cannot qualify this helper by itself. + """ + workspace = Path(workspace).resolve() + repo = workspace / "source" + library = repo / LIB + node = Path(node).resolve(strict=True) + if any(os.path.lexists(path) for path in (library / "node_modules", workspace / "cache", + workspace / "lifecycle", workspace / "script-shell")): + raise ValueError("npm installation and cache must be fresh") + _check_local_versions(node, environment) + review_lifecycle(read_json_file(library / "package.json", max_bytes=MAX_JSON), root=True) + if os.path.lexists(library / ".npmrc"): + raise ValueError("unreviewed source npm configuration") + lock = read_json_file(library / "package-lock.json", max_bytes=MAX_JSON) + review = {} + for location, checked in archives.items(): + verified = verify_archive(checked.path, checked.integrity) + record = lock["packages"].get(location) + name = location.rsplit("node_modules/", 1)[-1] + if (not record or record.get("integrity") != verified.integrity or verified.manifest is None + or verified.manifest.get("name") != name or verified.manifest.get("version") != record.get("version")): + raise ValueError("authenticated npm archive/lock identity mismatch") + review[location] = review_lifecycle(verified.manifest, paths=verified.paths) + (workspace / "reviewed-lifecycle.json").write_bytes(deterministic_json_bytes(review)) + (workspace / "cache").mkdir() + (workspace / "lifecycle").mkdir() + for name in ("npm-user.conf", "npm-global.conf"): + (workspace / name).write_bytes(b"") + wrapper = (f"#!{sys.executable} -I\n" + "import sys\n" + "sys.dont_write_bytecode = True\n" + f"sys.path.insert(0, {str(repo / 'scripts/release')!r})\n" + "import prepare\n" + f"sys.exit(prepare.lifecycle_shell(sys.argv[1:], {str(node)!r}))\n").encode() + (workspace / "script-shell").write_bytes(wrapper) + (workspace / "script-shell").chmod(0o755) + environment = {**environment, "TRUSTED_RELEASE_PRIVATE_WORKSPACE": str(workspace)} + base = _npm_command(node) + ["--cache", str(workspace / "cache"), "--userconfig", + str(workspace / "npm-user.conf"), "--globalconfig", + str(workspace / "npm-global.conf"), "--offline", "--no-audit", "--no-fund"] + unique = sorted({str(checked.path) for checked in archives.values()}) + for index in range(0, len(unique), 32): + checked_stage(repo, revision, base + ["cache", "add", "--ignore-scripts", *unique[index:index + 32]], + environment, workspace / f"cache-{index // 32}.log", boundary=boundary, cwd=library) + checked_stage(repo, revision, base + ["ci", "--include=dev", "--foreground-scripts", + "--script-shell", str(workspace / "script-shell")], environment, + workspace / "install.log", boundary=boundary, cwd=library) + # Optional hook failures must not be hidden by npm's success exit status. + events, _ = observed_lifecycle(workspace) + installed = {location for location in review if (library / location).is_dir()} + expected = {f"lifecycle:{location}:{event}" for location in installed for event in review[location]} + if {event["id"] for event in events} != expected: + raise ValueError("missing or unexpected actual lifecycle observations") + if (workspace / "script-shell").read_bytes() != wrapper: + raise ValueError("recipe-owned lifecycle shell changed") + + +# Reuse Task 6's tree and executable identity measurements, not a second hash dialect. +MEASURE_JS = """ +import fs from 'node:fs'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; +const [root, output, observations] = process.argv.slice(1); +const {installedOrigins, installedMaterials, actualToolFiles} = await import( + pathToFileURL(path.join(root, 'crates/trusted-server-js/lib/release-composition.mjs'))); +const installRoot = path.join(root, 'crates/trusted-server-js/lib'); +const owners = installedOrigins({repoRoot: root, lockFile: path.join(installRoot, 'package-lock.json')}); +const observed = JSON.parse(fs.readFileSync(observations, 'utf8')); +const actual = actualToolFiles({installRoot}); +const distributions = actual.map(({name, version, sha256, path: file}) => ({ + material: {kind:'tool-distribution', id:'tool:'+name, name, version, sha256}, path:file})); +const tools = actual.map(({name, version, role}) => ({name, version, role, material_id:'tool:'+name})); +fs.writeFileSync(output, JSON.stringify({schema_version:1, packages:installedMaterials(owners), + distributions, tools, lifecycle_events:observed.events, lifecycle_outputs:observed.outputs})+'\\n', {flag:'wx'}); +""" + + +def preparation_document(workspace, revision, node, environment, *, boundary): + """Measure installed packages and actual tools after genuine preparation.""" + workspace = Path(workspace) + events, outputs = observed_lifecycle(workspace) + observations = workspace / "observations.json" + observations.write_bytes(deterministic_json_bytes({"events": events, "outputs": outputs})) + destination = workspace / "preparation.json" + repo = workspace / "source" + checked_stage(repo, revision, [str(node), "--input-type=module", "-e", MEASURE_JS, + str(repo), str(destination), str(observations)], environment, + workspace / "measurement.log", boundary=boundary, cwd=repo / LIB) + prepared = read_json_file(destination, max_bytes=MAX_JSON) + versions = {"node": "24.12.0", "npm": "11.6.2", "vite": "7.3.1", "rollup": "4.57.1", + "esbuild": "0.27.3", "vitest": "4.0.18"} + for tool in prepared["tools"]: + if tool["version"] != versions[tool["name"].split(":")[0]]: + raise ValueError("actual JS tool version differs from reviewed pins") + shell_version = subprocess.run(["/bin/sh", "--version"], env=environment, capture_output=True, + timeout=30, check=True).stdout.decode("utf-8").splitlines()[0] + git_file = Path(shutil.which("git", path=environment["PATH"])).resolve(strict=True) + git_version = subprocess.run([str(git_file), "--version"], env=environment, capture_output=True, + timeout=30, check=True).stdout.decode("utf-8").strip() + for name, file, version in (("python", Path(sys.executable).resolve(), + ".".join(str(part) for part in sys.version_info[:3])), + ("shell", Path("/bin/sh").resolve(), shell_version), + ("git", git_file, git_version)): + material = {"kind": "tool-distribution", "id": "tool:" + name, "name": name, + "version": version, "sha256": sha256_bytes(_regular_bytes(file, MAX_ARCHIVE))} + prepared["distributions"].append({"material": material, "path": str(file)}) + prepared["tools"].append({"name": name, "version": version, "role": "runtime", "material_id": material["id"]}) + destination.write_bytes(deterministic_json_bytes(prepared)) + return prepared + + +def local_boundary(workspace, node, archive_pool): + """Select explicit read-only runtime/archive paths for a local denial fixture.""" + developer = [path for path in (Path("/Applications/Xcode.app/Contents/Developer"), + Path("/Library/Developer/CommandLineTools")) if path.is_dir()] + return LocalMacBoundary(workspace, [Path("/bin"), Path("/usr/bin"), Path("/usr/lib"), Path("/System"), + Path(node).resolve().parent.parent, Path(sys.base_prefix).resolve(), + Path(archive_pool).resolve(), *developer]) + + +def _expected_modules(repo, revision): + names = preflight._git(repo, ["ls-tree", "-rz", "--name-only", revision]).decode("utf-8").split("\0") + prefix = str(LIB) + "/src/" + expected = [] + for name in names: + if name == prefix + "core/index.ts": + expected.append("core") + elif re.fullmatch(re.escape(prefix) + r"integrations/[a-z][a-z0-9_-]*/index\.ts", name): + expected.append(name.split("/")[-2]) + if "core" not in expected or len(expected) != len(set(expected)): + raise ValueError("invalid tracked JS entry points") + return sorted(expected) + + +def _build_js_pair(repo, revision, archive_pool, work, output, node, boundary_factory): + preflight._commit_id(revision) + work = Path(work).resolve() + if os.path.lexists(work) or os.path.lexists(output): + raise ValueError("JS work and publication directories must be fresh") + archive_pool = Path(archive_pool).resolve(strict=True) + node = Path(node).resolve(strict=True) + if work.is_relative_to(archive_pool) or archive_pool.is_relative_to(work): + raise ValueError("shared archives must be outside private workspaces") + work.mkdir(parents=True) + expected = _expected_modules(repo, revision) + for label in ("A", "B"): + workspace = work / label + workspace.mkdir() + materialize_source(repo, revision, workspace / "source") + environment = local_environment(workspace, node, revision) + boundary = boundary_factory(workspace, node, archive_pool) + lock = read_json_file(workspace / "source" / LIB / "package-lock.json", max_bytes=MAX_JSON) + native = subprocess.run([str(node), "-e", "console.log(JSON.stringify([process.platform,process.arch]))"], + env=environment, capture_output=True, timeout=30, check=True) + platform, architecture = load_json_bytes(native.stdout, max_bytes=MAX_JSON) + selected = required_npm_materials(lock, platform, architecture) + archives = {} + for location, record in selected.items(): + integrity = record.get("integrity") + if not isinstance(integrity, str) or not re.fullmatch(r"sha512-[A-Za-z0-9+/]{86}==", integrity): + raise ValueError("missing locked npm archive integrity") + digest = base64.b64decode(integrity[7:], validate=True).hex() + archives[location] = verify_archive(archive_pool / (digest + ".tgz"), integrity) + install_npm(workspace, revision, archives, node, environment, boundary=boundary) + preparation_document(workspace, revision, node, environment, boundary=boundary) + for command, log in zip(js_commands(workspace, node), ("build.log", "tests.log")): + checked_stage(workspace / "source", revision, command, environment, workspace / log, + boundary=boundary, cwd=workspace / "source" / LIB) + compare_and_publish(work / "A", work / "B", expected, output) + return {"source_commit": revision, "modules": expected, "output": str(output)} + + +def local_js_pair(repo, revision, archive_pool, work, output, node): + """Prepare/build/test/compare actual A/B under local denial, never official. + + Archive pool files use their locked SHA-512 hexadecimal digest plus .tgz. + This API intentionally has no official CLI bypass. Task 8 must qualify + authenticated acquisitions and hosted boundary selection before official use. + """ + result = _build_js_pair(repo, revision, archive_pool, work, output, node, local_boundary) + return {"qualification": "local-unqualified", **result} + + +def qualified_js_selection(tools): + """Fail closed until Task 8 supplies source-owned authenticated path selection.""" + preflight.validate_tools(tools, require_qualified=True) + raise ValueError("release qualification pending: authenticated hosted JS paths/boundary required") + + +def compare_and_publish(a, b, expected, output): + """Publish only A's final complete bundles and inventory after byte equality.""" + expected = sorted(f"tsjs-{name}.js" for name in expected) + if not expected or len(expected) != len(set(expected)) or any( + not re.fullmatch(r"tsjs-[a-z][a-z0-9_-]*\.js", name) for name in expected): + raise ValueError("invalid expected final module set") + measured = [] + for workspace in (a, b): + directory = Path(workspace) / "bundles" + if directory.is_symlink() or sorted(path.name for path in directory.iterdir()) != expected: + raise ValueError("final bundle set mismatch") + files = {name: _regular_bytes(directory / name, MAX_FILE) for name in expected} + if any(not data for data in files.values()): + raise ValueError("empty final bundle") + measured.append(files) + if measured[0] != measured[1]: + raise ValueError("independent final JS bytes differ") + inventory = _regular_bytes(Path(a) / "inventory.json", MAX_JSON) + document = load_json_bytes(inventory, max_bytes=MAX_JSON) + modules = document.get("modules") if isinstance(document, dict) else None + if (not isinstance(modules, list) or sorted(record.get("filename", "") for record in modules) != expected + or any(record.get("id") != record.get("filename", "")[5:-3] + or record.get("size") != len(measured[0][record["filename"]]) + or record.get("sha256") != sha256_bytes(measured[0][record["filename"]]) for record in modules)): + raise ValueError("final inventory does not match published A bytes") + output = Path(output) + if os.path.lexists(output): + raise ValueError("publication destination must be fresh") + temporary = output.with_name(output.name + ".pending") + temporary.mkdir() + try: + (temporary / "bundles").mkdir() + for name, data in measured[0].items(): + (temporary / "bundles" / name).write_bytes(data) + (temporary / "tsjs-inventory.json").write_bytes(inventory) + temporary.rename(output) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + + +def main(): + """Official entry: pending acquisition/boundary qualification always refuses.""" + parser = argparse.ArgumentParser(description=__doc__) + for name in ("source", "commit", "archives", "work", "output"): + parser.add_argument("--" + name, required=True) + args = parser.parse_args() + preflight.audit_environment(os.environ) + tools = read_json_file(Path(__file__).with_name("release-tools.json"), max_bytes=MAX_JSON) + node, boundary_factory = qualified_js_selection(tools) + _build_js_pair(Path(args.source), args.commit, Path(args.archives), Path(args.work), Path(args.output), + node, boundary_factory) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, tarfile.TarError) as error: + logging.error("Release JS preparation refuses: %s", error) + sys.exit(1) diff --git a/scripts/release/release-tools.json b/scripts/release/release-tools.json index 4ea243eef9..a01f157aa2 100644 --- a/scripts/release/release-tools.json +++ b/scripts/release/release-tools.json @@ -140,21 +140,22 @@ { "id": "tsjs", "qualification": "pending", - "script": "scripts/release/prepare-js.mjs", + "script": "scripts/release/build-js.sh", "settings": { - "aws_lc_target_selector": true, - "aws_lc_use_system": false, - "cargo_config": ".cargo/config.toml", - "cargo_home": "private-prepared", - "cargo_locked": true, - "fresh_target": true, + "compare": "all-tracked-final-module-bytes", + "fresh_installations": 2, + "fresh_private_npm_caches": true, + "fresh_source": "tracked-objects-only", + "mandatory_tests": true, "network_boundary": "qualification-pending", - "npm_lifecycle": "reviewed-preparation-only", - "release_debug": 1, - "rust_toolchain": "1.95.0", - "sqlite_features": ["bundled"], - "sqlite_use_pkg_config": false, - "sysroot": "authenticated-sdk-required", + "node_version": "24.12.0", + "npm_audit": false, + "npm_fund": false, + "npm_include_dev": true, + "npm_lifecycle": "reviewed-observed-foreground", + "npm_offline": true, + "npm_version": "11.6.2", + "publish": "A-only-after-equality", "tool_paths": "authenticated-absolute-paths-required", "vite_config_loading": false, "vite_env_loading": false, diff --git a/scripts/release/tests/test_js_release.py b/scripts/release/tests/test_js_release.py new file mode 100644 index 0000000000..6da03018cb --- /dev/null +++ b/scripts/release/tests/test_js_release.py @@ -0,0 +1,431 @@ +"""JS recipe failure tests; synthetic bundles never claim release qualification.""" + +import importlib.util +import base64 +import json +import hashlib +import logging +import re +import os +import http.server +import threading +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from test_prepare import RELEASE, archive, git, source +import preflight + +prepare = __import__("prepare") if importlib.util.find_spec("prepare") else None + + +def pinned_node(test): + executable = shutil.which("node") + if not executable or subprocess.run([executable, "--version"], capture_output=True).stdout.strip() != b"v24.12.0": + test.skipTest("exact local Node fixture unavailable; hosted selection belongs to Task 8") + return Path(executable).resolve() + + +class JsReleaseTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(prepare, "should provide independent JS release orchestration") + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.a = self.root / "A" + self.b = self.root / "B" + for workspace in (self.a, self.b): + (workspace / "bundles").mkdir(parents=True) + for name in ("tsjs-core.js", "tsjs-example.js"): + (workspace / "bundles" / name).write_bytes(b"console.log('synthetic');") + modules = [{"id": name, "filename": f"tsjs-{name}.js", "size": 25, + "sha256": hashlib.sha256(b"console.log('synthetic');").hexdigest(), "contributions": []} + for name in ("core", "example")] + (workspace / "inventory.json").write_bytes(json.dumps({"schema_version": 1, + "source_commit": "a" * 40, "modules": modules, "tools": [], "materials": []}).encode()) + self.expected = ["core", "example"] + + def test_equal_final_bytes_publish_only_a_and_its_inventory(self): + (self.b / "inventory.json").write_bytes(b'{"synthetic":true,"path":"B"}\n') + output = self.root / "published" + prepare.compare_and_publish(self.a, self.b, self.expected, output) + self.assertEqual(sorted(path.name for path in output.iterdir()), ["bundles", "tsjs-inventory.json"]) + self.assertEqual(sorted(path.name for path in (output / "bundles").iterdir()), + ["tsjs-core.js", "tsjs-example.js"]) + self.assertEqual((output / "tsjs-inventory.json").read_bytes(), (self.a / "inventory.json").read_bytes()) + + def test_one_byte_difference_publishes_nothing(self): + (self.b / "bundles/tsjs-example.js").write_bytes(b"console.log('synthetic')!") + output = self.root / "published" + with self.assertRaisesRegex(ValueError, "differ"): + prepare.compare_and_publish(self.a, self.b, self.expected, output) + self.assertFalse(output.exists(), "should not publish any unequal build") + + def test_inventory_changed_after_build_cannot_publish(self): + inventory = json.loads((self.a / "inventory.json").read_bytes()) + inventory["modules"][0]["sha256"] = "0" * 64 + (self.a / "inventory.json").write_text(json.dumps(inventory)) + output = self.root / "published" + with self.assertRaisesRegex(ValueError, "inventory"): + prepare.compare_and_publish(self.a, self.b, self.expected, output) + self.assertFalse(output.exists()) + + def test_missing_extra_symlink_and_empty_bundles_are_rejected(self): + for change in ("missing", "extra", "symlink", "empty"): + path = self.b / "bundles/tsjs-example.js" + original = path.read_bytes() + if change == "missing": + path.unlink() + elif change == "extra": + (self.b / "bundles/extra.js").write_bytes(b"x") + elif change == "symlink": + path.unlink() + path.symlink_to(self.a / "bundles/tsjs-example.js") + else: + path.write_bytes(b"") + with self.subTest(change=change), self.assertRaises(ValueError): + prepare.compare_and_publish(self.a, self.b, self.expected, self.root / "published") + if change == "extra": + (self.b / "bundles/extra.js").unlink() + if path.is_symlink(): + path.unlink() + path.write_bytes(original) + + def test_official_cli_fails_closed_pending_qualification(self): + recipe = RELEASE / "build-js.sh" + self.assertTrue(recipe.exists(), "should provide executable build recipe") + result = subprocess.run(["/bin/sh", str(recipe), "--source", str(RELEASE.parents[1]), + "--commit", "a" * 40, "--archives", str(self.root), + "--work", str(self.root / "work"), "--output", str(self.root / "output")], + capture_output=True, env={"PATH": "/usr/bin:/bin", "HOME": str(self.root)}) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"qualification pending", result.stderr) + self.assertFalse((self.root / "work").exists()) + + def test_no_official_local_bypass_argument(self): + result = subprocess.run(["/bin/sh", str(RELEASE / "build-js.sh"), "--local-fixture"], + capture_output=True, env={"PATH": "/usr/bin:/bin"}) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn(b'"qualification":"qualified"', result.stdout) + + def test_wrapper_cannot_execute_inherited_path_or_python_shell_injection(self): + marker = self.root / "injection-ran" + script = self.root / "dirname" + script.write_text(f"#!/bin/sh\ntouch '{marker}'\n") + script.chmod(0o755) + loader = self.root / "loader" + loader.write_text(f"touch '{marker}'\n") + result = subprocess.run(["/bin/sh", str(RELEASE / "build-js.sh"), "--source", "/example", + "--commit", "a" * 40, "--archives", "/example", "--work", "/example", + "--output", "/example"], capture_output=True, + env={"PATH": str(self.root), "PYTHONPATH": str(self.root), + "BASH_ENV": str(loader), "ENV": str(loader)}) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(marker.exists(), "should not execute caller hooks before guard") + + def test_changed_source_checks_test_data_and_build_config(self): + repo = self.root / "source" + commit = source(repo, {"src/index.ts": b"example", "test/data.json": b"{}", + "vitest.config.ts": b"example"}) + before = prepare.source_check(repo, commit) + for name in ("src/index.ts", "test/data.json", "vitest.config.ts"): + path = repo / name + original = path.read_bytes() + path.write_bytes(b"mutated") + with self.subTest(name=name), self.assertRaisesRegex(ValueError, "source"): + prepare.source_check(repo, commit, before) + path.write_bytes(original) + (repo / "test/extra.ts").write_bytes(b"untracked") + with self.assertRaisesRegex(ValueError, "source"): + prepare.source_check(repo, commit, before) + + def test_failed_child_and_changed_source_block_build(self): + # Genuine subprocess failures and mutations, not mocked command success. + repo = self.root / "source" + commit = source(repo, {"src/index.ts": b"example"}) + with self.assertRaisesRegex(ValueError, "failed"): + prepare.checked_stage(repo, commit, ["/bin/sh", "-c", "exit 23"], {}, self.root / "failure.log") + with self.assertRaisesRegex(ValueError, "source"): + prepare.checked_stage(repo, commit, ["/bin/sh", "-c", "echo mutated > src/index.ts"], + {}, self.root / "mutation.log") + + def test_mandatory_test_command_cannot_follow_local_skip(self): + commands = prepare.js_commands(self.root, self.root / "node") + self.assertTrue(any("vitest.mjs" in str(arg) for cmd in commands for arg in cmd), + "should unconditionally run release Vitest") + self.assertFalse(any("npx" in str(arg) for cmd in commands for arg in cmd), + "should never fetch a substitute tool") + + def test_required_native_tools_cannot_be_optional(self): + lock = {"lockfileVersion": 3, "packages": {"": {"name": "example", "version": "1.0.0"}}} + with self.assertRaisesRegex(ValueError, "native"): + prepare.required_npm_materials(lock, "darwin", "arm64") + + def test_recipe_declares_actual_js_settings(self): + recipe = next(record for record in preflight.fixed_tools()["recipes"] if record["id"] == "tsjs") + self.assertEqual(recipe["script"], "scripts/release/build-js.sh") + self.assertNotIn("cargo_locked", recipe["settings"]) + self.assertTrue(recipe["settings"]["mandatory_tests"]) + + def test_release_vitest_uses_literal_host_without_dns_resolution(self): + config = (RELEASE.parents[1] / prepare.LIB / "vitest.config.ts").read_text() + self.assertIn("host: '127.0.0.1'", config, "should avoid localhost resolver access under denial") + + +class LocalBoundaryTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(prepare, "should provide actual local confinement probes") + if sys.platform != "darwin" or os.environ.get("TS_RELEASE_BOUNDARY_TESTS") != "1": + self.skipTest("actual macOS local probes run explicitly outside the outer sandbox") + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.a = self.root / "A" + self.b = self.root / "B" + self.a.mkdir() + self.b.mkdir() + (self.b / "generated.js").write_text("synthetic output from B") + self.boundary = prepare.LocalMacBoundary(self.a, [Path("/bin"), Path("/usr/bin"), Path("/usr/lib"), + Path("/System"), Path(sys.executable).resolve().parent.parent]) + + def run_confined(self, script): + return subprocess.run(self.boundary.command(["/bin/sh", "-c", script]), capture_output=True, + cwd=self.a, env={"PATH": "/usr/bin:/bin", "HOME": str(self.a)}) + + def test_no_a_b_generated_output_exchange_including_symlink_and_parent(self): + (self.a / "link").symlink_to(self.b) + for access in (f"cat '{self.b}/generated.js'", "cat ../B/generated.js", "cat link/generated.js", + f"/usr/bin/stat '{self.b}/generated.js'", f"echo exchanged > '{self.b}/generated.js'"): + with self.subTest(access=access): + self.assertNotEqual(self.run_confined(access).returncode, 0) + self.assertEqual((self.b / "generated.js").read_text(), "synthetic output from B") + self.assertEqual(self.run_confined("echo own > own.js && cat own.js").returncode, 0) + + def test_network_and_writes_to_shared_tools_are_denied(self): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.BaseHTTPRequestHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + endpoint = f"http://127.0.0.1:{server.server_port}/" + command = ["/usr/bin/curl", "--noproxy", "*", "--max-time", "2", endpoint] + positive = subprocess.run(command, capture_output=True) + self.assertEqual(positive.returncode, 0, "should prove the same local endpoint is reachable without denial") + self.assertNotEqual(self.run_confined(f"/usr/bin/curl --noproxy '*' --max-time 2 {endpoint}").returncode, 0) + shared = self.root / "shared" + shared.mkdir() + (shared / "tool").write_text("synthetic readonly tool") + boundary = prepare.LocalMacBoundary(self.a, [Path("/bin"), shared]) + result = subprocess.run(boundary.command(["/bin/sh", "-c", f"echo changed > '{shared}/tool'"]), + capture_output=True, cwd=self.a) + self.assertNotEqual(result.returncode, 0) + self.assertEqual((shared / "tool").read_text(), "synthetic readonly tool") + + +class OfflineLifecycleTests(unittest.TestCase): + """Run actual pinned npm on small authenticated synthetic package archives.""" + + def setUp(self): + self.assertIsNotNone(prepare, "should provide actual installation observations") + self.assertTrue(hasattr(prepare, "install_npm"), "should freshly install and observe actual npm execution") + self.node = pinned_node(self) + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + + def fixture(self, name="example", script=None, body=b"", *, optional=False, shadow_node=False): + manifest = {"name": name, "version": "0.27.3" if name == "esbuild" else "1.0.0"} + if script: + manifest["scripts"] = {"postinstall": script} + if shadow_node: + manifest["bin"] = {"node": "node-shadow.sh"} + package = self.root / (name + ".tgz") + members = [("package/package.json", json.dumps(manifest).encode()), + ("package/install.js", body), ("package/index.js", b"// synthetic")] + if shadow_node: + members.append(("package/node-shadow.sh", b"#!/bin/sh\necho shadow > shadow-ran\nexit 99\n")) + integrity = archive(package, members) + checked = prepare.verify_archive(package, integrity) + root_manifest = {"name": "synthetic", "version": "1.0.0", "dependencies": {name: manifest["version"]}} + if optional: + root_manifest["optionalDependencies"] = root_manifest.pop("dependencies") + locked_registry = json.loads((RELEASE.parents[1] / prepare.LIB / "package-lock.json").read_bytes()) + registry = locked_registry["packages"]["node_modules/esbuild"]["resolved"].split("/")[2] + record = {"version": manifest["version"], "resolved": f"https://{registry}/{name}/-/{name}-{manifest['version']}.tgz", + "integrity": integrity} + if script: + record["hasInstallScript"] = True + if optional: + record["optional"] = True + lock = {"name": "synthetic", "version": "1.0.0", "lockfileVersion": 3, + "packages": {"": root_manifest, "node_modules/" + name: record}} + repo = self.root / "repository" + files = {str(prepare.LIB / "package.json"): json.dumps(root_manifest).encode(), + str(prepare.LIB / "package-lock.json"): json.dumps(lock).encode()} + for file in ("prepare.py", "common.py", "preflight.py"): + files["scripts/release/" + file] = (RELEASE / file).read_bytes() + commit = source(repo, files) + return repo, commit, {"node_modules/" + name: checked} + + def test_fresh_private_cache_installs_without_restored_modules(self): + repo, commit, archives = self.fixture() + for label in ("A", "B"): + workspace = self.root / label + workspace.mkdir() + prepare.materialize_source(repo, commit, workspace / "source") + environment = prepare.local_environment(workspace, self.node, commit) + prepare.install_npm(workspace, commit, archives, self.node, environment) + self.assertTrue((workspace / "source" / prepare.LIB / "node_modules/example/index.js").is_file()) + self.assertEqual(prepare.observed_lifecycle(workspace), ([], [])) + self.assertNotEqual(os.stat(self.root / "A/cache").st_ino, os.stat(self.root / "B/cache").st_ino) + with self.assertRaisesRegex(ValueError, "fresh"): + prepare.install_npm(self.root / "A", commit, archives, self.node, + prepare.local_environment(self.root / "A", self.node, commit)) + + def test_actual_execution_records_no_output_hook_and_changes(self): + for body in (b"// genuine execution with no output", b"require('fs').writeFileSync('generated.js', 'synthetic output');"): + with self.subTest(body=body): + # Each iteration uses a different genuine Git/npm fixture. + case = self.root / ("output" if b"writeFile" in body else "no-output") + case.mkdir() + previous = self.root + self.root = case + repo, commit, archives = self.fixture("esbuild", "node install.js", body) + workspace = case / "A" + workspace.mkdir() + prepare.materialize_source(repo, commit, workspace / "source") + prepare.install_npm(workspace, commit, archives, self.node, + prepare.local_environment(workspace, self.node, commit)) + events, outputs = prepare.observed_lifecycle(workspace) + self.assertEqual(len(events), 1, "should observe a completed actual hook even without outputs") + self.assertEqual(events[0]["event"], "postinstall") + self.assertEqual(len(outputs), 1 if b"writeFile" in body else 0) + self.root = previous + + def test_failed_hook_is_rejected_even_if_npm_ignores_optional_failure(self): + repo, commit, archives = self.fixture("esbuild", "node install.js", b"process.exit(23);", optional=True) + workspace = self.root / "A" + workspace.mkdir() + prepare.materialize_source(repo, commit, workspace / "source") + with self.assertRaisesRegex(ValueError, "lifecycle execution failed"): + prepare.install_npm(workspace, commit, archives, self.node, + prepare.local_environment(workspace, self.node, commit)) + with self.assertRaisesRegex(ValueError, "failed"): + prepare.observed_lifecycle(workspace) + + def test_lifecycle_executes_selected_node_despite_npm_bin_shadow(self): + repo, commit, archives = self.fixture("esbuild", "node install.js", b"// actual pinned Node", shadow_node=True) + workspace = self.root / "A" + workspace.mkdir() + prepare.materialize_source(repo, commit, workspace / "source") + shadow = workspace / "npm-prepended-bin" + shadow.mkdir() + marker = workspace / "shadow-ran" + executable = shadow / "node" + executable.write_text(f"#!/bin/sh\necho shadow > '{marker}'\nexit 99\n") + executable.chmod(0o755) + environment = prepare.local_environment(workspace, self.node, commit) + environment["PATH"] = str(shadow) + os.pathsep + environment["PATH"] + positive = subprocess.run(["/bin/sh", "-c", "node --version"], env=environment, capture_output=True) + self.assertEqual(positive.returncode, 99, "should prove the same shadow path would replace bare node") + self.assertTrue(marker.exists()) + marker.unlink() + prepare.install_npm(workspace, commit, archives, self.node, environment) + self.assertFalse(marker.exists(), "should bind hooks to selected Node even after npm extends PATH") + self.assertFalse((workspace / "source" / prepare.LIB / "node_modules/esbuild/shadow-ran").exists()) + self.assertEqual(len(prepare.observed_lifecycle(workspace)[0]), 1) + + +class FullLocalJsTests(unittest.TestCase): + """Actual full-source npm/Vite/Vitest under local denial; never official proof.""" + + def setUp(self): + self.assertIsNotNone(prepare, "should implement the release recipe") + if os.environ.get("TS_RELEASE_FULL_JS_TESTS") != "1": + self.skipTest("explicit full local recipe probe; hosted qualification remains pending") + self.assertTrue(hasattr(prepare, "local_js_pair"), "should orchestrate both actual independent installations") + + def test_two_actual_offline_builds_and_tests_match_all_tracked_modules(self): + self.node = pinned_node(self) + repository = RELEASE.parents[1] + temporary = tempfile.TemporaryDirectory(prefix="ts-release-js-local-", dir="/private/tmp") + self.addCleanup(temporary.cleanup) + root = Path(temporary.name).resolve() + files = {} + tracked = git(repository, "ls-tree", "-r", "--name-only", "HEAD").splitlines() + for name in tracked: + path = repository / name + if path.is_symlink(): + continue + files[name] = path.read_bytes() + for name in ("prepare.py", "build-js.sh", "tests/test_prepare.py", "tests/test_js_release.py"): + files["scripts/release/" + name] = (RELEASE / name).read_bytes() + # This transient root commit is explicitly synthetic, not release evidence. + fixture = root / "synthetic-source" + revision = source(fixture, files) + pool = root / "archives" + pool.mkdir() + lock = json.loads((fixture / prepare.LIB / "package-lock.json").read_bytes()) + native = json.loads(subprocess.run([str(self.node), "-e", "console.log(JSON.stringify([process.platform, process.arch]))"], + capture_output=True, check=True).stdout) + selected = prepare.required_npm_materials(lock, *native) + for record in selected.values(): + digest = base64.b64decode(record["integrity"][7:]).hex() + ambient = Path.home() / ".npm/_cacache/content-v2/sha512" / digest[:2] / digest[2:4] / digest[4:] + destination = pool / (digest + ".tgz") + if not destination.exists(): + destination.write_bytes(ambient.read_bytes()) + work = root / "private-pair" + published = root / "local-unqualified-output" + try: + result = prepare.local_js_pair(fixture, revision, pool, work, published, self.node) + except ValueError as error: + logs = [path for path in work.glob("*/**/*.log") if path.parent.name in {"A", "B"}] + tail = max(logs, key=lambda path: path.stat().st_mtime_ns).read_text()[-5000:] if logs else "" + self.fail(f"{error}\nPrivate local-fixture diagnostic:\n{tail}") + self.assertEqual(result["qualification"], "local-unqualified") + self.assertEqual(len(list((published / "bundles").glob("tsjs-*.js"))), 13) + summary = {"qualification": result["qualification"], "source_commit": revision, + "published_layout": ["bundles/", "tsjs-inventory.json"], + "inventory_module_count": len(json.loads((published / "tsjs-inventory.json").read_bytes())["modules"])} + for label in ("A", "B"): + workspace = work / label + test_log = re.sub(r"\x1b\[[0-9;]*m", "", (workspace / "tests.log").read_text()) + counts = re.search(r"Test Files\s+(\d+) passed.*?Tests\s+(\d+) passed.*?Type Errors\s+no errors", test_log, re.S) + self.assertIsNotNone(counts, "should prove complete Vitest and typecheck success for each build") + self.assertEqual(counts.groups(), ("51", "1253")) + summary[label] = {"test_files": int(counts[1]), "tests": int(counts[2]), "type_errors": 0, + "installed_packages": len(json.loads((workspace / "preparation.json").read_bytes())["packages"])} + self.assertEqual(len(prepare.observed_lifecycle(workspace)[0]), 2) + logging.info("Actual local fixture evidence: %s", json.dumps(summary, sort_keys=True)) + self.assertNotEqual(os.stat(work / "A/cache").st_ino, os.stat(work / "B/cache").st_ino) + # Remove every usable native binary, including esbuild's postinstall hardlink. + a = work / "A" + library = a / "source" / prepare.LIB + native_root = library / "node_modules/@esbuild" / "-".join(native) + shutil.rmtree(native_root) + for path in (library / "node_modules/esbuild/bin/esbuild",): + path.unlink(missing_ok=True) + for path in (library / "node_modules/esbuild/lib").glob("downloaded-*"): + path.unlink() + boundary = prepare.local_boundary(a, self.node, pool) + environment = prepare.local_environment(a, self.node, revision) + command = [str(self.node), "--input-type=module", "-e", + "import {build} from 'vite'; await build({configFile:false,envDir:false," + "build:{write:false,lib:{entry:'src/core/index.ts',formats:['iife'],name:'Synthetic'}}});"] + with self.assertRaisesRegex(ValueError, "failed"): + prepare.checked_stage(a / "source", revision, command, environment, + a / "missing-native.log", boundary=boundary, cwd=library) + with self.assertRaisesRegex(ValueError, "failed"): + prepare.checked_stage(a / "source", revision, [str(self.node), "node_modules/esbuild/install.js"], + environment, a / "missing-native-installer.log", boundary=boundary, cwd=library) + self.assertFalse(native_root.exists(), "should not fetch a substitute native package") + self.assertFalse((library / "node_modules/esbuild/bin/esbuild").exists()) + self.assertEqual(list((library / "node_modules/esbuild/lib").glob("downloaded-*")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release/tests/test_prepare.py b/scripts/release/tests/test_prepare.py new file mode 100644 index 0000000000..c884ba8b98 --- /dev/null +++ b/scripts/release/tests/test_prepare.py @@ -0,0 +1,282 @@ +"""Preparation negatives use authenticated synthetic archives and Git commits.""" + +import base64 +import gzip +import hashlib +import importlib.util +import io +import json +import os +import subprocess +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RELEASE)) +prepare = __import__("prepare") if importlib.util.find_spec("prepare") else None + + +def archive(path, files, *, kind=None): + with tarfile.open(path, "w:gz") as stream: + for name, contents in files: + entry = tarfile.TarInfo(name) + entry.size = len(contents) + if kind: + entry.type = kind + entry.linkname = "../escape" + stream.addfile(entry, io.BytesIO(contents)) + return "sha512-" + base64.b64encode(hashlib.sha512(path.read_bytes()).digest()).decode() + + +def git(repo, *args): + result = subprocess.run(["/usr/bin/git", "-C", str(repo), *args], check=True, + capture_output=True, env={"PATH": "/usr/bin:/bin", + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull}) + return result.stdout.decode().strip() + + +def source(repo, files): + repo.mkdir() + git(repo, "init", "--template=") + for name, data in files.items(): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + git(repo, "add", ".") + git(repo, "-c", "user.name=Example", "-c", "user.email=example@example.com", + "-c", "commit.gpgsign=false", "commit", "-m", "Synthetic preparation fixture") + return git(repo, "rev-parse", "HEAD") + + +class PreparationTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(prepare, "should provide authenticated fresh preparation") + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.archive = self.root / "example.tgz" + self.manifest = {"name": "example", "version": "1.0.0"} + self.integrity = archive(self.archive, [ + ("package/package.json", json.dumps(self.manifest).encode()), + ("package/index.js", b"export const example = 1;"), + ]) + + def test_registry_archive_authenticates_original_bytes(self): + checked = prepare.verify_archive(self.archive, self.integrity) + self.assertEqual(checked.manifest, self.manifest) + self.assertEqual(checked.integrity, self.integrity) + with self.assertRaisesRegex(ValueError, "integrity"): + prepare.verify_archive(self.archive, "sha512-" + "A" * 86 + "==") + + def test_archive_supports_crates_sha256_and_exact_root(self): + digest = hashlib.sha256(self.archive.read_bytes()).hexdigest() + prepare.verify_archive(self.archive, digest, algorithm="sha256", expected_root="package") + with self.assertRaisesRegex(ValueError, "root"): + prepare.verify_archive(self.archive, digest, algorithm="sha256", expected_root="example-1.0.0") + + def test_tar_nonstandard_single_root_is_supported(self): + integrity = archive(self.archive, [("example-v1/package.json", b"{}")]) + self.assertEqual(prepare.verify_archive(self.archive, integrity).root, "example-v1") + + def test_unknown_dot_segments_and_alias_duplicates_are_rejected(self): + integrity = archive(self.archive, [("package/./index.js", b"synthetic")]) + with self.assertRaisesRegex(ValueError, "alias"): + prepare.verify_archive(self.archive, integrity) + integrity = archive(self.archive, [("package/./a", b"x"), ("package/a", b"y")]) + with self.assertRaises(ValueError): + prepare.verify_archive(self.archive, integrity) + integrity = archive(self.archive, [("package/./directory/", b"")], kind=tarfile.DIRTYPE) + with self.assertRaisesRegex(ValueError, "alias"): + prepare.verify_archive(self.archive, integrity) + + def test_only_pinned_identical_alias_pairs_can_be_merged(self): + self.assertTrue(hasattr(prepare, "_approved_alias"), "should verify explicit identical alias exceptions") + integrity = "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + first = tarfile.TarInfo("package/./dist/index.js") + second = tarfile.TarInfo("package/dist/index.js") + first.size = second.size = 1 + self.assertTrue(prepare._approved_alias(integrity, first, second, b"x", b"x")) + for change in ("bytes", "mode", "path", "integrity", "type", "size"): + altered = tarfile.TarInfo(second.name) + altered.size = 1 + if change == "mode": + altered.mode = 0o755 + elif change == "path": + altered.name = "package/dist/other.js" + elif change == "type": + altered.type = tarfile.SYMTYPE + elif change == "size": + altered.size = 2 + with self.subTest(change=change): + self.assertFalse(prepare._approved_alias("sha512-" + "A" * 86 + "==" if change == "integrity" else integrity, + first, altered, b"x", b"y" if change == "bytes" else b"x")) + + def test_rejects_traversal_duplicates_links_and_collisions(self): + for files, kind in [ + ([("package/../escape", b"x")], None), + ([("/package/escape", b"x")], None), + ([("package/a", b"x"), ("package/a", b"y")], None), + ([("package/a", b"x"), ("package/a/b", b"y")], None), + ([("package/a", b"")], tarfile.SYMTYPE), + ([("package/a", b"")], tarfile.LNKTYPE), + ([("package/a", b"")], tarfile.CHRTYPE), + ]: + with self.subTest(files=files, kind=kind): + integrity = archive(self.archive, files, kind=kind) + with self.assertRaises(ValueError): + prepare.verify_archive(self.archive, integrity) + + def test_rejects_expanded_size_and_member_count(self): + for limits in ({"max_expanded": 1}, {"max_members": 1}, {"max_bytes": 1}): + with self.subTest(limits=limits), self.assertRaises(ValueError): + prepare.verify_archive(self.archive, self.integrity, **limits) + + def test_rejects_metadata_bomb_and_privileged_archive_modes(self): + data = gzip.compress(b"x" * 200000) + self.archive.write_bytes(data) + integrity = "sha512-" + base64.b64encode(hashlib.sha512(data).digest()).decode() + with self.assertRaisesRegex(ValueError, "byte size"): + prepare.verify_archive(self.archive, integrity, max_expanded=10000) + with tarfile.open(self.archive, "w:gz", format=tarfile.PAX_FORMAT) as stream: + entry = tarfile.TarInfo("package/file") + entry.pax_headers = {"comment": "x" * 100000} + stream.addfile(entry, io.BytesIO(b"")) + integrity = "sha512-" + base64.b64encode(hashlib.sha512(self.archive.read_bytes()).digest()).decode() + with self.assertRaisesRegex(ValueError, "byte size"): + prepare.verify_archive(self.archive, integrity, max_expanded=10000) + with tarfile.open(self.archive, "w:gz") as stream: + entry = tarfile.TarInfo("package/file") + entry.mode = 0o4755 + stream.addfile(entry, io.BytesIO(b"")) + integrity = "sha512-" + base64.b64encode(hashlib.sha512(self.archive.read_bytes()).digest()).decode() + with self.assertRaisesRegex(ValueError, "mode"): + prepare.verify_archive(self.archive, integrity) + + def test_registry_input_rejects_fifo_and_symlink_without_opening_stream(self): + fifo = self.root / "fifo" + os.mkfifo(fifo) + with self.assertRaises(ValueError): + prepare.verify_archive(fifo, self.integrity) + link = self.root / "link" + link.symlink_to(self.archive) + with self.assertRaises(ValueError): + prepare.verify_archive(link, self.integrity) + + def test_materialization_rejects_stale_tree_and_archive_mutation(self): + checked = prepare.verify_archive(self.archive, self.integrity) + destination = self.root / "extracted" + prepare.materialize_archive(checked, destination) + self.assertEqual((destination / "index.js").read_bytes(), b"export const example = 1;") + with self.assertRaisesRegex(ValueError, "fresh"): + prepare.materialize_archive(checked, destination) + self.archive.write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "integrity"): + prepare.materialize_archive(checked, self.root / "other") + + def test_git_revision_and_whole_tree_are_exact(self): + repo = self.root / "repository" + revision = source(repo, {"src/example.rs": b"pub fn example() {}"}) + tree = git(repo, "rev-parse", revision + "^{tree}") + self.assertEqual(prepare.verify_git_revision(repo, revision, tree), tree) + for rev, expected in (("HEAD", tree), (revision, "a" * 40), ("b" * 40, tree)): + with self.subTest(rev=rev), self.assertRaises(ValueError): + prepare.verify_git_revision(repo, rev, expected) + + def test_git_materialization_ignores_export_filters_and_ambient_outputs(self): + repo = self.root / "repository" + revision = source(repo, {".gitattributes": b"src/* export-ignore\n", + "src/example.rs": b"$Format:%H$"}) + (repo / "node_modules").mkdir() + (repo / "node_modules/stale").write_bytes(b"stale") + (repo / "src/example.rs").write_bytes(b"ambient mutation") + snapshot = self.root / "fresh" + prepare.materialize_source(repo, revision, snapshot) + self.assertEqual((snapshot / "src/example.rs").read_bytes(), b"$Format:%H$") + self.assertFalse((snapshot / "node_modules").exists()) + self.assertEqual(git(snapshot, "ls-tree", "-r", revision), git(repo, "ls-tree", "-r", revision)) + self.assertFalse((snapshot / ".git/hooks").exists()) + with self.assertRaisesRegex(ValueError, "fresh"): + prepare.materialize_source(repo, revision, snapshot) + + def test_private_git_without_parent_history_keeps_exact_committer_epoch(self): + repo = self.root / "repository" + source(repo, {"file": b"first"}) + (repo / "file").write_bytes(b"second") + git(repo, "add", "file") + git(repo, "-c", "user.name=Example", "-c", "user.email=example@example.com", + "commit", "-m", "Second synthetic commit") + revision = git(repo, "rev-parse", "HEAD") + snapshot = self.root / "fresh" + measured = prepare.materialize_source(repo, revision, snapshot) + self.assertEqual(measured["source_date_epoch"], int(git(repo, "show", "-s", "--format=%ct", revision))) + parent = git(repo, "rev-parse", revision + "^") + result = subprocess.run(["/usr/bin/git", "-C", str(snapshot), "cat-file", "-e", parent], capture_output=True) + self.assertNotEqual(result.returncode, 0, "should never export/traverse parent history") + + def test_selected_commit_rejects_malformed_or_duplicate_committer_timestamps(self): + repo = self.root / "repository" + revision = source(repo, {"file": b"example"}) + tree = git(repo, "rev-parse", revision + "^{tree}") + for committer in ("committer Example invalid +0000\n", + "committer Example 1 +0000\n" * 2): + data = f"tree {tree}\nauthor Example 1 +0000\n{committer}\nSynthetic\n" + result = subprocess.run(["/usr/bin/git", "-C", str(repo), "hash-object", "-t", "commit", + "--literally", "-w", "--stdin"], input=data.encode(), capture_output=True, check=True) + with self.subTest(committer=committer), self.assertRaisesRegex(ValueError, "timestamp"): + prepare.source_check(repo, result.stdout.decode().strip()) + + def test_source_export_only_allows_fixed_claude_link(self): + repo = self.root / "repository" + revision = source(repo, {"AGENTS.md": b"example"}) + (repo / "CLAUDE.md").symlink_to("AGENTS.md") + git(repo, "add", "CLAUDE.md") + git(repo, "-c", "user.name=Example", "-c", "user.email=example@example.com", + "commit", "-m", "Add fixed link") + revision = git(repo, "rev-parse", "HEAD") + prepare.materialize_source(repo, revision, self.root / "fixed") + (repo / "escape").symlink_to("../escape") + git(repo, "add", "escape") + git(repo, "-c", "user.name=Example", "-c", "user.email=example@example.com", + "commit", "-m", "Add prohibited link") + with self.assertRaisesRegex(ValueError, "link"): + prepare.materialize_source(repo, git(repo, "rev-parse", "HEAD"), self.root / "bad") + + def test_git_rejects_configuration_replacements_and_alternates(self): + for channel in ("filter", "replacement", "alternates"): + repo = self.root / channel + revision = source(repo, {"file": b"example"}) + if channel == "filter": + git(repo, "config", "filter.example.clean", "example-command") + elif channel == "replacement": + git(repo, "update-ref", "refs/replace/" + "a" * 40, revision) + else: + (repo / ".git/objects/info/alternates").write_text("/example/objects\n") + with self.subTest(channel=channel), self.assertRaises(ValueError): + prepare.verify_git_revision(repo, revision) + + def test_unreviewed_install_fetch_and_root_hooks_fail_before_execution(self): + for scripts in ({"postinstall": "curl https://example.com/tool"}, + {"prepare": "node fetch.js"}, {"install": "npm install example"}): + with self.subTest(scripts=scripts), self.assertRaisesRegex(ValueError, "review"): + prepare.review_lifecycle({"name": "example", "version": "1.0.0", "scripts": scripts}, + root=True) + with self.assertRaisesRegex(ValueError, "review"): + prepare.review_lifecycle({"name": "example", "version": "1.0.0", + "scripts": {"postinstall": "node fetch.js"}}) + with self.assertRaisesRegex(ValueError, "implicit"): + prepare.review_lifecycle(self.manifest, paths=["binding.gyp"]) + + def test_only_exact_pinned_registry_lifecycle_commands_are_reviewed(self): + prepare.review_lifecycle({"name": "esbuild", "version": "0.27.3", + "scripts": {"postinstall": "node install.js"}}) + for version, command in (("0.27.4", "node install.js"), ("0.27.3", "node fetch.js")): + with self.subTest(version=version), self.assertRaises(ValueError): + prepare.review_lifecycle({"name": "esbuild", "version": version, + "scripts": {"postinstall": command}}) + + +if __name__ == "__main__": + unittest.main() From 3e7fdc3dd266e824e05d79207bfc8b05fa48700a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 13 Oct 2026 23:47:13 +0530 Subject: [PATCH 028/153] Bound release Git acquisition and validate JS inventory sizes --- scripts/release/js-composition.md | 9 +++ scripts/release/preflight.py | 92 +++++++++++++++++++----- scripts/release/prepare.py | 51 +++++++------ scripts/release/tests/test_js_release.py | 47 ++++++++++++ scripts/release/tests/test_prepare.py | 53 ++++++++++++++ 5 files changed, 209 insertions(+), 43 deletions(-) diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index a44f9c4eae..6472b5636a 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -126,6 +126,12 @@ entries must have identical size, bytes, type and mode, and their canonical file is materialized once. Other duplicates, dot aliases, traversal, links, devices and privileged modes fail. SHA-256/exact-root registry validation and exact Git revision/whole-tree validation are also reusable by later Cargo preparation. +Source acquisition and repeated source checks first query all selected object +metadata in bounded batches, rejecting objects above 32 MiB or aggregate bodies +above 512 MiB before requesting any bodies. Body headers then rejoin the checked +OID, type and exact integer size. This precheck assumes the sanitized trusted Git +tool reads an immutable object store; it does not claim race-proof streaming +against a mutable or corrupted store. Each private cache is seeded via pinned npm's public `cache add` API using only verified archives, offline and with scripts disabled. Fresh pinned npm ci runs @@ -149,6 +155,9 @@ existing config. Only final bundle bytes must match: all tracked integrations an are compared. A's bundles and inventory are published together only after equality, in one atomic parent with `bundles/` and sibling `tsjs-inventory.json`. B's inventory can legitimately contain different virtual-code hashes. +Publication rechecks the closed root/module shape and exact integer sizes and +digests against A's final bytes, retaining A's original inventory encoding. +Contribution, package and tool semantics remain the collector's responsibility. Run ordinary fixtures with `python3 -B -m unittest discover -s scripts/release/tests -p 'test_*.py' -v`. On macOS, explicitly enable actual local diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py index 0b3800a6e0..525e9d3c79 100644 --- a/scripts/release/preflight.py +++ b/scripts/release/preflight.py @@ -21,6 +21,10 @@ from common import deterministic_json_bytes, read_json_file, require_nonnegative_integer, sha256_bytes LIMIT = 1048576 +GIT_OBJECT_LIMIT = 33554432 +GIT_TOTAL_LIMIT = 536870912 +GIT_MEMBER_LIMIT = 100000 +GIT_METADATA_BATCH = 256 SOURCE_DIRECTORIES = ("src", "build_support", "tests", "benches", "examples") PLATFORMS = ( "aarch64-apple-darwin", @@ -436,6 +440,66 @@ def _git(repo, arguments, data=None): return result.stdout +def read_git_objects(repo, objects, *, max_object=GIT_OBJECT_LIMIT, + max_total=GIT_TOTAL_LIMIT, max_members=GIT_MEMBER_LIMIT): + """Check pinned object sizes before capturing any selected Git bodies. + + Use the sanitized trusted Git executable and immutable object store, never + replacement/alternate channels. At most 256 validated full OIDs are queried + per metadata request; Git's fixed SHA-1/type/64-bit-size format bounds each + response. Reject oversized objects or aggregate bodies before requesting + them, then rejoin body headers to the measured OID, type and size. This is + metadata prechecking, not race-proof streaming against a mutable/corrupt + object store. Raise ValueError for invalid pins, metadata, limits or bodies. + """ + for bound in (max_object, max_total, max_members): + require_nonnegative_integer(bound) + if not isinstance(objects, dict) or len(objects) > max_members: + raise ValueError("source object member bound exceeded") + for oid, kind in objects.items(): + if (not isinstance(oid, str) or not re.fullmatch(r"[0-9a-f]{40}", oid) + or not isinstance(kind, str) or kind not in {"blob", "tree", "commit"}): + raise ValueError("invalid pinned source object identity") + selected = list(objects) + sizes = {} + total = 0 + for start in range(0, len(selected), GIT_METADATA_BATCH): + batch = selected[start:start + GIT_METADATA_BATCH] + metadata = _git(repo, ["cat-file", "--batch-check"], + "".join(oid + "\n" for oid in batch).encode("ascii")) + if len(metadata) > 80 * len(batch): + raise ValueError("source object metadata bound exceeded") + lines = metadata.splitlines() + if len(lines) != len(batch): + raise ValueError("source object metadata membership mismatch") + for oid, line in zip(batch, lines): + fields = line.decode("ascii").split() + if (len(fields) != 3 or fields[:2] != [oid, objects[oid]] + or not re.fullmatch(r"[0-9]+", fields[2])): + raise ValueError("source object metadata identity/type/size mismatch") + size = int(fields[2]) + total += size + if size > max_object or total > max_total: + raise ValueError("source object size bound exceeded") + sizes[oid] = size + if not selected: + return {} + bodies = io.BytesIO(_git(repo, ["cat-file", "--batch"], + "".join(oid + "\n" for oid in selected).encode("ascii"))) + result = {} + for oid, size in sizes.items(): + header = bodies.readline().decode("ascii").strip().split() + if header != [oid, objects[oid], str(size)]: + raise ValueError("source object body disagrees with checked metadata") + content = bodies.read(size) + if len(content) != size or bodies.read(1) != b"\n": + raise ValueError("incomplete source object body") + result[oid] = content + if bodies.read(1): + raise ValueError("extra source object bodies") + return result + + def _audit_configuration(repo, inherited): configuration = _git(repo, ["config", "--local", "--no-includes", "--name-only", "--list"]) for key in configuration.decode("utf-8").splitlines(): @@ -580,13 +644,6 @@ def source_snapshot(repo, commit, inherited): _audit_configuration(repo, inherited) if _git(repo, ["cat-file", "-t", commit]).strip() != b"commit": raise ValueError("source revision must be an actual commit object") - # Read the selected object directly; tracked snapshots need no parent history. - header = _git(repo, ["cat-file", "commit", commit]).split(b"\n\n", 1)[0] - committers = [line for line in header.splitlines() if line.startswith(b"committer ")] - if len(committers) != 1 or not re.search(rb" [0-9]+ [+-][0-9]{4}$", committers[0]): - raise ValueError("invalid committed source timestamp") - epoch = int(committers[0].rsplit(b" ", 2)[1]) - require_nonnegative_integer(epoch) entries = {} for record in _git(repo, ["ls-tree", "-rz", "--full-tree", commit]).split(b"\0"): if not record: @@ -600,16 +657,19 @@ def source_snapshot(repo, commit, inherited): raise ValueError("unsafe source tree path") entries[name] = (mode, object_id) _unexpected_sources(repo, entries) - payload = "".join(f"{object_id}\n" for _, object_id in entries.values()).encode("ascii") - blobs = io.BytesIO(_git(repo, ["cat-file", "--batch"], payload)) + objects = {commit: "commit"} + objects.update({oid: "blob" for _, oid in entries.values()}) + blobs = read_git_objects(repo, objects) + # Read the selected object directly; tracked snapshots need no parent history. + header = blobs[commit].split(b"\n\n", 1)[0] + committers = [line for line in header.splitlines() if line.startswith(b"committer ")] + if len(committers) != 1 or not re.search(rb" [0-9]+ [+-][0-9]{4}$", committers[0]): + raise ValueError("invalid committed source timestamp") + epoch = int(committers[0].rsplit(b" ", 2)[1]) + require_nonnegative_integer(epoch) files = {} for name, (mode, object_id) in entries.items(): - header = blobs.readline().decode("ascii").strip().split() - if len(header) != 3 or header[0] != object_id or header[1] != "blob": - raise ValueError("unexpected committed source object") - expected = blobs.read(int(header[2])) - if blobs.read(1) != b"\n": - raise ValueError("incomplete committed source object") + expected = blobs[object_id] path = repo / name _check_source_parents(repo, path) try: @@ -636,8 +696,6 @@ def source_snapshot(repo, commit, inherited): "permissions": stat.S_IMODE(information.st_mode), "sha256": sha256_bytes(actual), } - if blobs.read(1): - raise ValueError("extra committed source objects") return {"source_commit": commit, "source_date_epoch": epoch, "files": files} diff --git a/scripts/release/prepare.py b/scripts/release/prepare.py index 1a1102d5b1..8154cfa012 100644 --- a/scripts/release/prepare.py +++ b/scripts/release/prepare.py @@ -26,7 +26,7 @@ # -I excludes both caller Python paths and this script directory. sys.path.insert(0, str(Path(__file__).resolve().parent)) from common import (deterministic_json_bytes, load_json_bytes, read_bounded, - read_json_file, sha256_bytes) + read_json_file, require_nonnegative_integer, sha256_bytes) import preflight MAX_JSON = 1048576 @@ -264,26 +264,7 @@ def materialize_source(repo, revision, destination): entries.append((mode, kind, oid, name)) objects = {revision: "commit", tree: "tree"} objects.update({oid: kind for _, kind, oid, _ in entries}) - if len(objects) > MAX_MEMBERS: - raise ValueError("source object bound exceeded") - data = io.BytesIO(preflight._git(repo, ["cat-file", "--batch"], - "".join(oid + "\n" for oid in objects).encode("ascii"))) - blobs = {} - total = 0 - for oid, kind in objects.items(): - header = data.readline().decode("ascii").strip().split() - if len(header) != 3 or header[:2] != [oid, kind]: - raise ValueError("source object disagreement") - size = int(header[2]) - total += size - if size > MAX_FILE or total > MAX_EXPANDED: - raise ValueError("source object size bound exceeded") - content = data.read(size) - if len(content) != size or data.read(1) != b"\n": - raise ValueError("incomplete source object") - blobs[oid] = content - if data.read(1): - raise ValueError("extra source objects") + blobs = preflight.read_git_objects(repo, objects) for mode, _, oid, name in entries: if mode == "120000" and (name != "CLAUDE.md" or blobs[oid] != b"AGENTS.md" or not any(entry[3] == "AGENTS.md" and entry[1] == "blob" for entry in entries)): @@ -766,11 +747,29 @@ def compare_and_publish(a, b, expected, output): raise ValueError("independent final JS bytes differ") inventory = _regular_bytes(Path(a) / "inventory.json", MAX_JSON) document = load_json_bytes(inventory, max_bytes=MAX_JSON) - modules = document.get("modules") if isinstance(document, dict) else None - if (not isinstance(modules, list) or sorted(record.get("filename", "") for record in modules) != expected - or any(record.get("id") != record.get("filename", "")[5:-3] - or record.get("size") != len(measured[0][record["filename"]]) - or record.get("sha256") != sha256_bytes(measured[0][record["filename"]]) for record in modules)): + if (not isinstance(document, dict) or set(document) != { + "schema_version", "source_commit", "modules", "tools", "materials"} + or type(document["schema_version"]) is not int or document["schema_version"] != 1 + or not isinstance(document["source_commit"], str) + or not re.fullmatch(r"[0-9a-f]{40}", document["source_commit"]) + or any(not isinstance(document[field], list) for field in ("modules", "tools", "materials"))): + raise ValueError("invalid final inventory root") + modules = document["modules"] + for record in modules: + if (not isinstance(record, dict) or set(record) != { + "id", "filename", "size", "sha256", "contributions"} + or not isinstance(record["filename"], str) or record["filename"] not in measured[0] + or record["id"] != record["filename"][5:-3] + or not isinstance(record["contributions"], list)): + raise ValueError("invalid final inventory module") + try: + size = require_nonnegative_integer(record["size"]) + except ValueError as error: + raise ValueError("invalid final inventory module size") from error + if (size != len(measured[0][record["filename"]]) + or record["sha256"] != sha256_bytes(measured[0][record["filename"]])): + raise ValueError("final inventory does not match published A bytes") + if sorted(record["filename"] for record in modules) != expected: raise ValueError("final inventory does not match published A bytes") output = Path(output) if os.path.lexists(output): diff --git a/scripts/release/tests/test_js_release.py b/scripts/release/tests/test_js_release.py index 6da03018cb..9d16f8e450 100644 --- a/scripts/release/tests/test_js_release.py +++ b/scripts/release/tests/test_js_release.py @@ -73,6 +73,53 @@ def test_inventory_changed_after_build_cannot_publish(self): prepare.compare_and_publish(self.a, self.b, self.expected, output) self.assertFalse(output.exists()) + def test_inventory_rejects_float_boolean_sizes_and_nonobject_modules(self): + original = json.loads((self.a / "inventory.json").read_bytes()) + for size, payload in ((25.0, b"console.log('synthetic');"), (True, b"x")): + inventory = json.loads(json.dumps(original)) + for module in inventory["modules"]: + module["size"] = size + module["sha256"] = hashlib.sha256(payload).hexdigest() + for workspace in (self.a, self.b): + (workspace / "bundles" / module["filename"]).write_bytes(payload) + (self.a / "inventory.json").write_text(json.dumps(inventory)) + output = self.root / ("published-boolean" if size is True else "published-float") + with self.subTest(size=size): + with self.assertRaisesRegex(ValueError, "inventory"): + prepare.compare_and_publish(self.a, self.b, self.expected, output) + self.assertFalse(output.exists()) + for item in (None, True, "module", [], 1): + inventory = json.loads(json.dumps(original)) + inventory["modules"][0] = item + (self.a / "inventory.json").write_text(json.dumps(inventory)) + with self.subTest(item=item), self.assertRaisesRegex(ValueError, "inventory"): + prepare.compare_and_publish(self.a, self.b, self.expected, self.root / "malformed") + + def test_inventory_rejects_nonclosed_root_and_module_fields(self): + original = json.loads((self.a / "inventory.json").read_bytes()) + for change in ("schema", "commit", "extra", "missing", "module-extra", "module-missing", "contributions"): + inventory = json.loads(json.dumps(original)) + if change == "schema": + inventory["schema_version"] = True + elif change == "commit": + inventory["source_commit"] = "HEAD" + elif change == "extra": + inventory["extra"] = True + elif change == "missing": + del inventory["materials"] + elif change == "module-extra": + inventory["modules"][0]["extra"] = True + elif change == "module-missing": + del inventory["modules"][0]["contributions"] + else: + inventory["modules"][0]["contributions"] = None + (self.a / "inventory.json").write_text(json.dumps(inventory)) + output = self.root / change + with self.subTest(change=change): + with self.assertRaisesRegex(ValueError, "inventory"): + prepare.compare_and_publish(self.a, self.b, self.expected, output) + self.assertFalse(output.exists()) + def test_missing_extra_symlink_and_empty_bundles_are_rejected(self): for change in ("missing", "extra", "symlink", "empty"): path = self.b / "bundles/tsjs-example.js" diff --git a/scripts/release/tests/test_prepare.py b/scripts/release/tests/test_prepare.py index c884ba8b98..65d142651c 100644 --- a/scripts/release/tests/test_prepare.py +++ b/scripts/release/tests/test_prepare.py @@ -13,6 +13,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock RELEASE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RELEASE)) @@ -185,6 +186,58 @@ def test_git_revision_and_whole_tree_are_exact(self): with self.subTest(rev=rev), self.assertRaises(ValueError): prepare.verify_git_revision(repo, rev, expected) + def test_oversized_source_rejected_before_any_batch_body_request(self): + repo = self.root / "repository" + revision = source(repo, {"oversized.txt": b"x" * (prepare.MAX_FILE + 1)}) + for operation in (lambda: prepare.materialize_source(repo, revision, self.root / "fresh"), + lambda: prepare.source_check(repo, revision)): + with self.subTest(operation=operation), mock.patch.object(prepare.preflight, "_git", wraps=prepare.preflight._git) as observed: + with self.assertRaisesRegex(ValueError, "size bound"): + operation() + self.assertFalse(any(call.args[1] == ["cat-file", "--batch"] for call in observed.call_args_list), + "should reject metadata before capturing any oversized batch body") + + def test_git_object_reader_binds_bounded_metadata_to_selected_objects(self): + self.assertTrue(hasattr(prepare.preflight, "read_git_objects"), "should inspect bounded metadata before object bodies") + repo = self.root / "repository" + revision = source(repo, {"file": b"example"}) + oid = git(repo, "rev-parse", revision + ":file") + self.assertEqual(prepare.preflight.read_git_objects(repo, {oid: "blob"})[oid], b"example") + for identities, limits in (({oid: "tree"}, {}), ({"f" * 40: "blob"}, {}), + ({oid: "blob"}, {"max_object": 6}), + ({oid: "blob", revision: "commit"}, {"max_total": 7}), + ({oid: "blob"}, {"max_members": 0}), + ({oid: "blob"}, {"max_object": True})): + with self.subTest(identities=identities, limits=limits), self.assertRaises(ValueError): + prepare.preflight.read_git_objects(repo, identities, **limits) + + def test_git_metadata_malformed_pin_kind_and_size_fail_before_body(self): + self.assertTrue(hasattr(prepare.preflight, "read_git_objects"), "should provide checked Git metadata joins") + oid = "a" * 40 + for response in (f"{'b' * 40} blob 1\n", f"{oid} tree 1\n", f"{oid} blob -1\n", + f"{oid} blob True\n", f"{oid} blob 1.0\n", f"{oid} missing\n", + f"{oid} blob 1\n{oid} blob 1\n", "x" * 65537): + with self.subTest(response=response[:80]), mock.patch.object(prepare.preflight, "_git", return_value=response.encode()) as observed: + with self.assertRaises(ValueError): + prepare.preflight.read_git_objects(self.root, {oid: "blob"}) + self.assertEqual(len(observed.call_args_list), 1, "should not request bodies after invalid metadata") + + def test_git_metadata_batches_are_bounded_before_body_capture(self): + repo = self.root / "repository" + revision = source(repo, {f"file-{index}.txt": f"example-{index}".encode() for index in range(257)}) + objects = {line.split()[2]: "blob" for line in git(repo, "ls-tree", "-r", revision).splitlines()} + with mock.patch.object(prepare.preflight, "_git", wraps=prepare.preflight._git) as observed: + actual = prepare.preflight.read_git_objects(repo, objects) + self.assertEqual(len(actual), 257) + calls = observed.call_args_list + self.assertEqual([call.args[1] for call in calls], + [["cat-file", "--batch-check"], ["cat-file", "--batch-check"], ["cat-file", "--batch"]]) + self.assertEqual([len(call.args[2].splitlines()) for call in calls[:2]], [256, 1]) + with mock.patch.object(prepare.preflight, "_git", wraps=prepare.preflight._git) as observed: + with self.assertRaisesRegex(ValueError, "size bound"): + prepare.preflight.read_git_objects(repo, objects, max_total=1) + self.assertFalse(any(call.args[1] == ["cat-file", "--batch"] for call in observed.call_args_list)) + def test_git_materialization_ignores_export_filters_and_ambient_outputs(self): repo = self.root / "repository" revision = source(repo, {".gitattributes": b"src/* export-ignore\n", From c27d50f36e63e9b7ef8d6cf2f0ec97d09ce5a6b9 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 07:22:01 +0530 Subject: [PATCH 029/153] Record independent JS build verification --- ...26-10-13-attested-release-implementation.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 2f14de5956..5c8c16e7a6 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -141,11 +141,11 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** `scripts/release/build-js.sh`, `prepare.py`, `tests/test_prepare.py`, `tests/test_js_release.py`. -- [ ] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_*release.py' -v`; expect missing orchestration. -- [ ] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Task 7 initially proves this with local fixtures; Task 8 must qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Bundle under qualified network denial; compilation-time downloads fail. -- [ ] Compare final core and all tracked integration files byte-for-byte. Publish only A plus inventory after equality. Run release JS tests regardless of a local test flag; verify source-only trees remained intact. -- [ ] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Commit `Build and compare shared release JS inputs`. +- [x] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. +- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_*release.py' -v`; expect missing orchestration. +- [x] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Task 7 initially proves this with local fixtures; Task 8 must qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Bundle under qualified network denial; compilation-time downloads fail. +- [x] Compare final core and all tracked integration files byte-for-byte. Publish only A plus inventory after equality. Run release JS tests regardless of a local test flag; verify source-only trees remained intact. +- [x] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Commit `Build and compare shared release JS inputs`. ### Task 8: Qualify real platform environments before fixing recipes @@ -444,3 +444,11 @@ Task 5 is implemented in `e1606fa3d`. Independent spec and quality reviews pass. Task 3 source-check follow-up is implemented in `27080c540`. Independent spec and quality reviews pass after closing the ignored nested Vitest-input gap: the entire JS library is checked, with only its direct real `node_modules` directory and the exact empty regular single-link Cargo coordination file exempted. Nested dependency-directory lookalikes, links, nonempty coordination state and otherwise untracked validation inputs fail. The controller freshly ran all 78 Python tests. This does not approve the existing ignored operator/OS files or weaken tracked-byte, mode, environment or qualification checks. Task 6 is implemented in `eeb68f918`, with review corrections in `c9683ba30` and `9684fd868`. Independent spec and quality reviews pass. The controller freshly ran all 42 composition tests, the complete 51-file/1,253-test JS suite with type checking, and all 78 Python tests. Recording preserves all 13 final bundle files byte-for-byte. The producer joins conservative post-Rollup contributions to source, locked npm ownership and known virtual producers, measures final files, and checks closed preparation records and actual selected tool identities. Release Vite and Vitest suppress ambient environment-file loading. Lifecycle outputs cannot be claimed by parent packages when owned by separately locked children; installed-tree file headers and mutation checks use held-descriptor metadata, with two deterministic mode-race regressions. Recorded tool entry hashes and synthetic lifecycle fixtures are interface evidence, not authenticated acquisition or proof of execution history. Fresh installation, actual lifecycle observations, A/B isolation and comparison remain Task 7; actual hosted qualification remains Task 8. No signing, publication or provider action is enabled. + +Task 7 is implemented in `223687649`, with review corrections in `3e7fdc3dd`. Independent spec and quality reviews pass. On 2026-10-14 the controller freshly ran all 122 Python tests (three separately selected local probes skipped), the two actual local confinement controls, and the complete local A/B pipeline. Each fresh offline installation contains 602 packages and passes all 51 JS test files, 1,253 tests and type checks; all 13 final module files match byte-for-byte. Publication atomically retains only A’s checked raw inventory alongside a `bundles/` directory containing only JS files. Actual Vite and pinned esbuild installer probes fail after removing every prepared native copy, with no replacement remaining. Parent `TSJS_RUN_TESTS=0` cannot suppress the mandatory suites. These full-recipe tests use an explicitly synthetic source commit and return `local-unqualified`. + +Preparation authenticates original archive bytes, bounded decompressed tar metadata and exact Git object identities before fresh materialization. The source-owned exception for three exact locked npm archives permits only their identical `package/./dist/index.js` and `package/dist/index.js` pair; unknown aliases, differing bytes/modes/types/sizes and other duplicates fail. The controller and reviewers independently authenticated those originals. Actual lifecycle starts/completions, no-output hooks, changed owned files and swallowed optional failures are checked separately from archive and installed-tree identities. Effective lifecycle Node selection excludes package-bin shadowing. Private A/B resources, peer metadata/read/write/parent/symlink denial, shared-write denial and positive unconfined/negative confined TCP controls are exercised. + +Review corrections require exact integer inventory sizes and closed root/module shapes, and precheck every selected Git object’s identity/type/size and cumulative budget before requesting bodies. Export and repeated source checks share that reader, including direct commit-object timestamp inspection without parent history. Its guarantee explicitly assumes the sanitized trusted Git executable and an immutable object store; it does not claim adversarial race-proof streaming. Release Vitest uses a literal host and a narrowly scoped native Node-import resolver to preserve production-mode validation without DNS or browser builtin substitution; ordinary development and the separate IIFE collector retain their behavior. + +The controller’s first post-review full run was interrupted by overnight system sleep and failed closed with worker/test timeouts and unexpected temporary output. Power-log and clock inspection identified the interruption; an unchanged rerun with a process-scoped idle-sleep assertion passes in 226.755 seconds. No timeout, source exception or network permission was relaxed. Actual hosted OS/tool/SDK acquisition and boundary qualification, authenticated Python bootstrap/path selection and official recipe enablement remain Task 8; the public producer command continues to refuse unqualified execution. From 481f8c7fabcd11e9c75692b1a492e573cc319f49 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 08:34:56 +0530 Subject: [PATCH 030/153] Add release environment qualification checks --- scripts/release/preflight.py | 39 +- scripts/release/qualification/README.md | 200 ++++ scripts/release/qualification/SECCOMP-LICENSE | 202 ++++ scripts/release/qualification/boundaries.py | 189 ++++ .../hosted-workflow.proposal.yml | 51 + scripts/release/qualification/qualify.py | 300 ++++++ .../release/qualification/results.schema.json | 554 +++++++++++ .../qualification/seccomp-default.json | 875 ++++++++++++++++++ scripts/release/qualification/socket_probe.py | 135 +++ scripts/release/release-tools.json | 62 +- scripts/release/tests/test_qualification.py | 478 ++++++++++ 11 files changed, 3063 insertions(+), 22 deletions(-) create mode 100644 scripts/release/qualification/README.md create mode 100644 scripts/release/qualification/SECCOMP-LICENSE create mode 100644 scripts/release/qualification/boundaries.py create mode 100644 scripts/release/qualification/hosted-workflow.proposal.yml create mode 100644 scripts/release/qualification/qualify.py create mode 100644 scripts/release/qualification/results.schema.json create mode 100644 scripts/release/qualification/seccomp-default.json create mode 100644 scripts/release/qualification/socket_probe.py create mode 100644 scripts/release/tests/test_qualification.py diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py index 525e9d3c79..c7d09f6545 100644 --- a/scripts/release/preflight.py +++ b/scripts/release/preflight.py @@ -102,20 +102,34 @@ def fixed_tools(): "acquisition": None, "qualification": "pending", }) - environments = [{ - "id": identity, - "image_sha256": None, - "sdk_sha256": None, - "acquisition": None, - "qualification": "pending", - } for identity in ( - "debian12-x86_64", "macos15-aarch64", "macos15-x86_64", - "ubi9-x86_64", "ubuntu22.04-x86_64", - )] + # Hosted image versions are platform observations, never VM byte hashes. + environments = [] + for identity in ("debian12-x86_64", "gnu-builder-x86_64", "macos15-aarch64", + "macos15-x86_64", "ubi9-x86_64", "ubuntu22.04-x86_64"): + mac = identity.startswith("macos") + builder = identity == "gnu-builder-x86_64" + environment = { + "id": identity, + "kind": "github-hosted-macos" if mac else "oci-builder" if builder else "oci-runtime", + "role": "compilation" if mac or builder else "compatibility-runtime", + "acquisition": None, + "qualification": "pending", + } + if mac: + environment.update(runner_label="macos-15" if identity.endswith("aarch64") + else "macos-15-intel", runner_image_version=None, sdk_sha256=None) + else: + environment.update(image_reference=None, oci_index_sha256=None, oci_manifest_sha256=None) + if builder: + environment.update(sdk_sha256=None, glibc_maximum="2.34") + environments.append(environment) recipes = [] for artifact in fixed_artifacts(): recipes.append({ "id": artifact["name"], + "environment_id": "macos15-aarch64" if artifact["target"] == "aarch64-apple-darwin" + else "macos15-x86_64" if artifact["target"] == "x86_64-apple-darwin" + else "gnu-builder-x86_64", "script": "scripts/release/build.sh", "sha256": None, "qualification": "pending", @@ -123,6 +137,7 @@ def fixed_tools(): }) recipes.append({ "id": "tsjs", + "environment_id": "gnu-builder-x86_64", "script": "scripts/release/build-js.sh", "sha256": None, "qualification": "pending", @@ -294,7 +309,7 @@ def validate_tools(tools, *, require_qualified=False): # Digest strings alone do not confer qualification. Task 8 owns evidence. for section, digest_fields in ( ("tools", ("sha256",)), - ("environments", ("image_sha256", "sdk_sha256")), + ("environments", ("oci_index_sha256", "oci_manifest_sha256", "sdk_sha256")), ("recipes", ("sha256",)), ): records = tools.get(section) if isinstance(tools, dict) else None @@ -304,6 +319,8 @@ def validate_tools(tools, *, require_qualified=False): if not isinstance(record, dict): raise ValueError(f"tools.{section}: expected record") for field in digest_fields: + if field not in wanted: + continue digest = record.get(field) if digest is not None and ( not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) diff --git a/scripts/release/qualification/README.md b/scripts/release/qualification/README.md new file mode 100644 index 0000000000..eaa518334b --- /dev/null +++ b/scripts/release/qualification/README.md @@ -0,0 +1,200 @@ +# Disposable release qualification framework + +Status: **local framework only; Task 8 qualification remains pending**. No tool, +SDK, OCI image or recipe has become qualified. `release-tools.json` retains null +measurement slots. A source checksum and a successful unit test cannot supply +hosted execution or acquisition authenticity. + +The locally authorized work consists of a strict observation schema, semantic +checks, source-owned candidate socket boundaries and an inert hosted workflow +proposal. It creates no repository, dispatches no job, registers no workflow, +publishes no tag, signs no release and deploys no publisher service. + +## Local checks + +Use a private Python 3.11+ environment and install the complete locked validator +set before running tests: + +```sh +python3 -m venv /private/tmp/ts-qualification-venv +/private/tmp/ts-qualification-venv/bin/python -m pip install --require-hashes --only-binary=:all: -r scripts/release/requirements.txt +/private/tmp/ts-qualification-venv/bin/python -m pip check +/private/tmp/ts-qualification-venv/bin/python -B -m unittest discover -s scripts/release/tests -p 'test_qualification.py' -v +``` + +The real unconfined probe requires local socket permission. On macOS, set +`TS_RELEASE_BOUNDARY_TESTS=1` to additionally execute the actual candidate +sandbox-exec profile. Run those tests outside an enclosing application sandbox. +The child and grandchild execute the same probe through inherited confinement, +with private HOME/TMP and closed inherited descriptors. A successful local test +does not qualify macOS 15, GNU, Intel execution, a whole SDK or a tool acquisition. + +`qualify.py validate-observation ` applies the bounded closed schema and +semantic joins, but prints an explicit pending qualification diagnostic even +when the observations are coherent. `require_qualified` always refuses. The +production `preflight.validate_tools(require_qualified=True)` and +`prepare.qualified_js_selection` also remain closed. Fields asserting `passed`, +caller commands and unknown keys are rejected. Actual logs, signed acquisition +evidence, trust roots and run identities still require independent verification; +no JSON field authenticates itself. + +## Closed identities and fingerprints + +The GNU builder selection is a distinct `gnu-builder-x86_64` slot, with no image +chosen or digest invented. UBI 9 is an initial candidate to investigate; its +availability, glibc, compiler and SDK must be measured before selecting it. UBI 9, +Ubuntu 22.04 and Debian 12 are three separate compatibility runtime slots. They +do not pretend to supply a build SDK. + +macOS records GitHub's actual runner image version and the Apple SDK identity. +No VM byte hash is available through this contract, so a hash of a version string +is never labeled an image digest. The source-owned labels are `macos-15` for ARM64 +and `macos-15-intel` for native Intel. Native `uname`, `sw_vers`, translation +`sysctl`, Xcode build version and SDK version/tree identity must be recorded. +The actual startup OS is 15; the Mach-O loader floor is explicitly 13.0. Neither +Rosetta nor startup on a newer developer Mac establishes this matrix. + +The recipe closure is SHA-256 of +`trusted-server-recipe-closure-v1\n` followed by deterministic JSON containing +sorted reviewed file path/executable-bit/SHA-256 records and effective settings. +The execution selection digest uses +`trusted-server-execution-selection-v1\n` followed by deterministic JSON of the +tool, environment and recipe selections. It includes acquisition trust roots, +archive identities and selected SDK/image identities. Only derived qualification +status and recipe-result digests are excluded. Complete raw `release-tools.json` +bytes remain an independently recorded build/preflight input. + +This avoids a self-hash cycle: a result cannot hash a future tools file containing +that result's digest. The observation records its own source commit; the later +reviewed measured-pin commit has different raw tools bytes. Recipe/profile/helper +changes or changed executable/acquisition selections invalidate qualification. +`candidate_fingerprints` refuses missing shared Task 9 helpers. The existing +Git reader assumes a trusted immutable object store; the actual staging boundary +must make Git objects and tracked source files read-only while permitting the +private installation's required node_modules/output writes. This framework does +not claim race-proof reads against mutable Git storage. + +The reviewed closure explicitly includes the Rust build scripts and their +shared git-version/JS prebuilt/inventory/bundle-set helpers, Cargo configuration +and manifests, and the executable JS build-all/module-options/composition/Vitest +pipeline. Ordinary application source and dependencies have separate commit and +dependency identities. Future imported build helpers must enter this reviewed +closure before execution; the candidate selector cannot supply its own path list. + +## Candidate boundaries + +The Linux seccomp baseline is the exact Moby `seccomp/v0.2.1` source revision +`e295db0a4d7b67853387fef0b2ef0a29e796b1c1`, file +`seccomp/default.json`, SHA-256 +`536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74`. +Its Apache license is retained in `SECCOMP-LICENSE`. The baseline's default deny, +architecture mapping and other syscall conditions are preserved. Every socket, +socketpair and socketcall allowance is removed; only socket/socketpair with +argument zero equal to AF_UNIX is added. This avoids inventing a syscall allowlist. +The file hash is checked before generating the effective profile. + +The candidate container command combines `--network none` with that seccomp +profile; network namespace isolation alone permits loopback sockets. It also +drops all capabilities, sets no-new-privileges, uses private IPC/PID namespaces, +a read-only root, an unprivileged fixed UID, bounded memory/CPU/processes, and +separate controlled read-only tool/source mounts plus a private writable root. +No host daemon socket is mounted. Preparation must establish writable ownership +for UID 65532. Container/profile command construction is tested locally; Linux +execution remains unmeasured because no local Docker proof exists. + +Linux positive controls execute in the same selected OCI image/interpreter with +the checked default seccomp baseline and its ordinary private network namespace; +confined controls use the AF_UNIX-only profile and network-none namespace. +Both map the private host workspace to `/work`. Mount scanning rejects sockets +and symlink members escaping the approved roots; preparation must keep those +prepared trees immutable while the command executes. No local scan claims to +prevent an adversarial concurrent mount mutation. + +The macOS candidate reuses Task 7's locally exercised file isolation and adds +AF_UNIX/private-path rules. INET, arbitrary Mach lookup and AppleEvent delegation +remain denied by default. The same generated profile must wrap probes and the +complete Cargo/compiler/proc-macro/build-script/native-tool invocation. This file +does not replace Task 7's independent JS boundary or qualify hosted recipes. + +The candidate macOS profile can mark actual tracked source files and Git-object +directories inside the private root immutable, while leaving node_modules and +output writes available. This separation is locally exercised with actual deny +and success controls; the full recipe must pass its reviewed tracked-object set. + +Both boundaries require actual positive then confined TCP and UDP bind/connect/ +send probes for IPv4/IPv6, loopback/documentation-address destinations, across +parent/child/grandchild, plus private Unix exchange. External routing failures and +unsupported unconfined IPv6 are recorded as unavailable, never successful +controls. Positive IPv4 loopback operations must succeed, every confined INET +operation must return EPERM/EACCES, and private Unix exchange must succeed. A +socket helper is only boundary evidence; full recipe success is a separate gate. + +## Reviewable hosted resource proposal + +The adjacent `hosted-workflow.proposal.yml` is inert outside `.github/workflows`. +Its only trigger is manual workflow_dispatch with no inputs. It uses three fresh +GitHub-hosted jobs: `ubuntu-24.04`, `macos-15`, `macos-15-intel`; 120 minutes each, +no matrix fail-fast, no concurrent cancellation, contents-read only, no OIDC, +attestation/provider/release permissions, credentials, cache restore or publish +steps. Logs are retained for seven days. Action versions and full revisions were +read from primary GitHub tag refs for checkout 4.2.2, setup-python 5.6.0 and +upload-artifact 4.6.2; they are source-reviewed code selections, not independently +authenticated tool archives. Runner labels were checked against primary GitHub +runner documentation on 2026-10-14; availability must be rechecked at dispatch. + +This proposal intentionally stops at `check-ready`. It cannot falsely report a +complete qualification with version-only probes. The reviewed executable staging +steps may replace that stop only when all prerequisites below are complete: + +1. Task 9 implements the shared full production recipes, native inspection and + exact-final-Wasm core-ABI/publisher-response validation. The staging selector + must invoke those same source-owned functions with independently authenticated + candidate selections. It cannot require its own future production-qualified + result, accept downloaded commands or introduce a public local-fixture bypass. +2. Review the bootstrap chain and actually acquire Python 3.12.6, Git/shell, + rustup/Rust 1.95.0 components/sysroot, compiler/linker/archiver, Node 24.12.0, + npm 11.6.2 and the selected runtime tools through source-owned authenticators. + Node uses signed checksums and independently reviewed keys; Rust uses signed + manifests/components; gh uses its verified archive and independent bootstrap. + A colocated checksum alone does not authenticate Fastly or an SDK. Retain raw + signature/checksum evidence, reviewed trust roots, archive and extracted-tree + digests and actual selected file identities. SDK/platform trust needs an + explicit vendor/platform evidence chain. Ambient PATH or PYTHONPATH cannot + select the interpreter before isolation. setup-python alone supplies no + qualifying bootstrap proof. +3. Acquire the candidate GNU image by authenticated content identity, record both + OCI index and selected AMD64 manifest digests, and measure glibc <=2.34 inside + that builder. Select the three runtime-only images by their actual digests. + Record native archive/link/sysroot identities: aws-lc-sys 0.42 target-suffix + overrides and generic/target system-use=0, AWS-LC 5.1 source, ring 0.17.14 + pregenerated source membership, and SQLite 0.30.1 bundled 3.46/pkg-config=0. +4. Network-enabled authenticated acquisition/lifecycle preparation happens before + compilation. Close source/object-store writes and run positive/negative probes, + full working compilation, actual Cargo/npm download-attempt negatives and + actual missing-tool negatives through the same selected boundary. Retain + outcomes and attempted fetch evidence even when a script catches denial. +5. Outside the compile-denial boundary, run the exact CLI bytes outside checkout + on clean UBI 9/Ubuntu 22.04/Debian 12 and actual native macOS 15 ARM/Intel. + Verify GNU symbol ceiling/libraries and Mach-O LC_BUILD_VERSION 13.0. Validate + the exact final Fastly Wasm with pinned Wasmtime and complete publisher-origin + response/injection through pinned Viceroy, retaining pre/post byte hashes. + No health-only success, cross-Intel inference or changed cohort is accepted. +6. Collect the closed observations with actual repository/owner/run/attempt/job + identities and raw retained evidence. Independently authenticate and review + those identities, acquisitions, source closure and selections. Only then + update the source-owned measured pins and implement their production selector. + +New workflow files on a feature branch may be unavailable to manual dispatch +until registered on a repository's default branch. The concrete safe option is +an explicitly authorized disposable mirror whose default branch contains the +approved source commit plus the reviewed staging workflow. Record its actual +repository and owner IDs as disposable observations. Do not merge into this +project's main branch to register the proposal. A mirror's identity never satisfies +the fixed production IAB repository/SAN/policy, and no production policy changes +are authorized by rehearsal. Repository creation/default-branch registration, +resource spending and dispatch remain separate permission gates. + +Missing native Intel or the GNU baseline stops this cohort. Only after actual +hosted qualification may the final Task 8 commit use the title `Qualify controlled +release build environments`. The local framework commit must use an accurate +title such as `Add release environment qualification checks`. diff --git a/scripts/release/qualification/SECCOMP-LICENSE b/scripts/release/qualification/SECCOMP-LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/scripts/release/qualification/SECCOMP-LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/scripts/release/qualification/boundaries.py b/scripts/release/qualification/boundaries.py new file mode 100644 index 0000000000..f3ccb549a2 --- /dev/null +++ b/scripts/release/qualification/boundaries.py @@ -0,0 +1,189 @@ +"""Source-owned candidate boundaries; local probes never qualify hosted builds.""" + +import copy +import re +import subprocess +import sys +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RELEASE)) + +from common import deterministic_json_bytes, load_json_bytes, sha256_bytes +from prepare import LocalMacBoundary + +SECCOMP_SOURCE_REVISION = "e295db0a4d7b67853387fef0b2ef0a29e796b1c1" +SECCOMP_SOURCE_SHA256 = "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74" + + +def linux_seccomp_profile(): + """Augment the exact reviewed Moby default baseline with AF_UNIX only. + + Preserve its default deny and other syscall conditions. Remove every socket, + socketpair and old socketcall allowance before adding argument-zero filtering. + Linux execution remains unmeasured until the authorized hosted run. + """ + data = (Path(__file__).parent / "seccomp-default.json").read_bytes() + if sha256_bytes(data) != SECCOMP_SOURCE_SHA256: + raise ValueError("reviewed default seccomp baseline bytes changed") + baseline = load_json_bytes(data, max_bytes=1048576) + if baseline["defaultAction"] != "SCMP_ACT_ERRNO" or baseline.get("defaultErrnoRet", 1) != 1: + raise ValueError("default seccomp denial required") + profile = copy.deepcopy(baseline) + rules = [] + for rule in profile["syscalls"]: + rule["names"] = [name for name in rule["names"] if name not in {"socket", "socketpair", "socketcall"}] + if rule["names"]: + rules.append(rule) + rules.append({"names": ["socket", "socketpair"], "action": "SCMP_ACT_ALLOW", + "args": [{"index": 0, "value": 1, "op": "SCMP_CMP_EQ"}]}) + profile["syscalls"] = rules + return profile + + +def _roots(private_root, readonly): + root = Path(private_root).resolve(strict=True) + reads = [Path(path).resolve(strict=True) for path in readonly] + if (not root.is_dir() or root == Path("/") or Path(private_root).is_symlink() + or any(path == Path("/") or root.is_relative_to(path) or path.is_relative_to(root) + for path in reads)): + raise ValueError("private and readonly roots must be disjoint and confined") + return root, reads + + +def macos_profile(private_root, readonly, *, immutable=()): + """Reuse tested local file isolation and add private Unix sockets only. + + Default denial covers INET, Mach lookup and AppleEvent delegation. This is a + candidate profile: full hosted ARM/Intel recipes still need measurement. + """ + root, reads = _roots(private_root, readonly) + # Profile construction is inspectable on any host; only execution is macOS. + boundary = object.__new__(LocalMacBoundary) + boundary.private_root, boundary.readonly = root, reads + profile = boundary.command([])[2] + quoted = deterministic_json_bytes(str(root)).decode().strip() + for path in immutable: + path = Path(path) + resolved = path.resolve(strict=True) + if path.is_symlink() or not resolved.is_relative_to(root) or resolved == root: + raise ValueError("immutable source must be a real member of the private workspace") + predicate = "subpath" if resolved.is_dir() else "literal" + quoted_path = deterministic_json_bytes(str(resolved)).decode().strip() + profile += f"(deny file-write* ({predicate} {quoted_path}))" + return (profile + "(allow system-socket (socket-domain AF_UNIX))" + + f"(allow network-bind network-inbound (local unix-socket (subpath {quoted})))" + + f"(allow network-outbound (remote unix-socket (subpath {quoted})))") + + +class MacBoundary: + """Use the same generated candidate profile for probes and compilation.""" + + def __init__(self, private_root, readonly, *, immutable=()): + if sys.platform != "darwin": + raise ValueError("macOS candidate boundary requires an actual macOS host") + self.profile = macos_profile(private_root, readonly, immutable=immutable) + self.sha256 = sha256_bytes(self.profile.encode()) + + def command(self, command): + """Wrap source-owned argv, closing descriptors in the caller.""" + return ["/usr/bin/sandbox-exec", "-p", self.profile, *map(str, command)] + + +class LinuxBoundary: + """Construct a digest-selected isolated GNU candidate container. + + Only the later source-owned authenticated staging selector may instantiate + this class for recipes. The image argument is internal, never a CLI input. + Preparation must create writable ownership for the fixed unprivileged UID. + """ + + def __init__(self, private_root, readonly, image): + self.private_root, self.readonly = _roots(private_root, readonly) + if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+@sha256:[0-9a-f]{64}", image): + raise ValueError("selected OCI manifest digest required; tags are forbidden") + self.image = image + self.profile_path = self.private_root / "network-seccomp.json" + self.profile_path.write_bytes(deterministic_json_bytes(linux_seccomp_profile())) + self.profile_path.chmod(0o444) + self.sha256 = sha256_bytes(self.profile_path.read_bytes()) + self.control_profile_path = self.private_root / "control-seccomp.json" + self.control_profile_path.write_bytes((Path(__file__).parent / "seccomp-default.json").read_bytes()) + self.control_profile_path.chmod(0o444) + for root in [self.private_root, *self.readonly]: + for path in root.rglob("*"): + if path.is_symlink(): + resolved = path.resolve(strict=True) + if not any(resolved.is_relative_to(approved) for approved in [self.private_root, *self.readonly]): + raise ValueError("container mount symlink escapes approved roots") + if path.is_socket(): + raise ValueError("host Unix/daemon sockets must not enter container mounts") + + def command(self, command): + """Use no host namespace, daemon socket, privileges or writable tools.""" + return self._container_command(command, confined=True) + + def _container_command(self, command, *, confined): + for path in [self.private_root, *self.readonly]: + if "," in str(path) or "\n" in str(path): + raise ValueError("unsupported bind mount pathname") + result = ["/usr/bin/docker", "run", "--rm", "--pull=never", "--platform=linux/amd64", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges=true", + "--security-opt", f"seccomp={self.profile_path if confined else self.control_profile_path}", + "--read-only", "--ipc=private", + "--pid=private", "--user", "65532:65532", "--pids-limit", "512", "--memory", "6g", + "--cpus", "4", "--workdir", "/work", "--env", "HOME=/work/home", "--env", "TMPDIR=/work/tmp", + "--mount", f"type=bind,src={self.private_root},dst=/work"] + if confined: + result.extend(["--network", "none"]) + for path in self.readonly: + result.extend(["--mount", f"type=bind,src={path},dst={path},readonly"]) + return [*result, self.image, *map(str, command)] + + +def probe_commands(boundary, private_root, python): + """Construct controls in the same OS/image and map private container paths. + + No public denial toggle exists. Only this source-owned control orchestration + invokes the internal baseline container command; compilation uses command(). + """ + root = Path(private_root).resolve(strict=True) + python = Path(python) + if isinstance(boundary, LinuxBoundary): + # This identity is selected and measured inside the OCI builder. Resolving + # a host Python symlink could select a different container executable. + if not python.is_absolute() or ".." in python.parts: + raise ValueError("explicit in-container prepared Python path required") + else: + python = python.resolve(strict=True) + command = [str(python), "-I", "-B", str(Path(__file__).parent / "socket_probe.py"), + "--private-root", "/work" if isinstance(boundary, LinuxBoundary) else str(root)] + if isinstance(boundary, LinuxBoundary): + if root != boundary.private_root: + raise ValueError("probe workspace differs from selected private container mount") + return {"unconfined": boundary._container_command(command, confined=False), + "confined": boundary.command(command)} + if not isinstance(boundary, MacBoundary): + raise ValueError("unknown source-owned candidate boundary") + return {"unconfined": command, "confined": boundary.command(command)} + + +def probe_controls(boundary, private_root, python): + """Measure positive then denied probes with this same boundary instance. + + This local helper does not perform compilation or qualification. The shared + staging/production orchestrator must run its full recipe through command(). + """ + root = Path(private_root).resolve(strict=True) + python = Path(python) + if not isinstance(boundary, LinuxBoundary) and not python.resolve(strict=True).is_file(): + raise ValueError("explicit prepared Python executable required") + observations = {} + for phase, argv in probe_commands(boundary, root, python).items(): + result = subprocess.run(argv, env={"HOME": str(root), "TMPDIR": str(root), "LANG": "C", "LC_ALL": "C"}, + stdin=subprocess.DEVNULL, close_fds=True, capture_output=True, + timeout=30, check=False) + if result.returncode or len(result.stdout) > 65536 or len(result.stderr) > 65536: + raise ValueError(f"{phase} probe failed; actual local boundary unavailable") + observations[phase] = load_json_bytes(result.stdout, max_bytes=65536) + return observations diff --git a/scripts/release/qualification/hosted-workflow.proposal.yml b/scripts/release/qualification/hosted-workflow.proposal.yml new file mode 100644 index 0000000000..0d078f66b7 --- /dev/null +++ b/scripts/release/qualification/hosted-workflow.proposal.yml @@ -0,0 +1,51 @@ +# Inert proposal: this file is deliberately outside .github/workflows. +# Register only in a separately authorized disposable mirror after Task 9 and +# independently authenticated candidate acquisitions are reviewed. +name: Disposable release environment qualification +on: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: disposable-release-qualification + cancel-in-progress: false +jobs: + qualification: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + environment: gnu-builder-x86_64 + - runner: macos-15 + environment: macos15-aarch64 + - runner: macos-15-intel + environment: macos15-x86_64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + fetch-depth: 1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12.6' + - name: Refuse incomplete shared recipes and staging acquisitions + shell: bash + run: | + set -eu + mkdir -p "$RUNNER_TEMP/qualification-logs" + python -I -B scripts/release/qualification/qualify.py check-ready > "$RUNNER_TEMP/qualification-logs/readiness.log" 2>&1 + - name: Retain diagnostic logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: qualification-${{ matrix.environment }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/qualification-logs/ + if-no-files-found: error + retention-days: 7 + compression-level: 0 diff --git a/scripts/release/qualification/qualify.py b/scripts/release/qualification/qualify.py new file mode 100644 index 0000000000..757fae23b6 --- /dev/null +++ b/scripts/release/qualification/qualify.py @@ -0,0 +1,300 @@ +"""Validate closed disposable hosted observations without conferring trust. + +Production qualification remains pending authenticated hosted resources and the +shared complete production recipes. JSON observations never select commands. +""" + +import argparse +import errno +import logging +import re +import stat +import sys +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +sys.dont_write_bytecode = True +sys.path.insert(0, str(RELEASE)) + +from common import deterministic_json_bytes, read_json_file, require_nonnegative_integer, sha256_bytes +import preflight + +BASE_TOOLS = {"python", "git", "shell", "rustup", "rustc", "cargo", "compiler", "linker", + "archiver", "node", "npm"} +RECIPE_ENVIRONMENTS = { + "ts-aarch64-apple-darwin": "macos15-aarch64", + "ts-x86_64-apple-darwin": "macos15-x86_64", + "ts-x86_64-unknown-linux-gnu": "gnu-builder-x86_64", + "trusted-server-fastly.wasm": "gnu-builder-x86_64", "tsjs": "gnu-builder-x86_64", +} +SHARED_CLOSURE = ( + ".cargo/config.toml", ".tool-versions", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", + "scripts/release/common.py", "scripts/release/preflight.py", "scripts/release/prepare.py", + "scripts/release/build-js.sh", "scripts/release/collect-js.mjs", "scripts/release/requirements.txt", + "scripts/release/qualification/qualify.py", "scripts/release/qualification/boundaries.py", + "scripts/release/qualification/socket_probe.py", "scripts/release/qualification/results.schema.json", + "scripts/release/qualification/seccomp-default.json", + "crates/trusted-server-core/build.rs", "crates/trusted-server-core/build_support/git_version.rs", + "crates/trusted-server-cli/build.rs", "crates/trusted-server-adapter-fastly/build.rs", + "crates/trusted-server-js/build.rs", "crates/trusted-server-js/build/bundle_set.rs", + "crates/trusted-server-js/build/inventory.rs", "crates/trusted-server-js/build/prebuilt.rs", + "crates/trusted-server-js/lib/build-all.mjs", "crates/trusted-server-js/lib/build-module-options.mjs", + "crates/trusted-server-js/lib/release-composition.mjs", "crates/trusted-server-js/lib/vitest.config.ts", + "crates/trusted-server-js/lib/vite.config.ts", "crates/trusted-server-js/lib/tsconfig.json", + "crates/trusted-server-js/lib/package.json", "crates/trusted-server-js/lib/package-lock.json", + "crates/trusted-server-cli/Cargo.toml", "crates/trusted-server-core/Cargo.toml", + "crates/trusted-server-js/Cargo.toml", "crates/trusted-server-adapter-fastly/Cargo.toml", +) +RUST_CLOSURE = ("scripts/release/build.sh", "scripts/release/inspect-native.py", + "scripts/release/validate-fastly.py") + + +def _version(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9]+\.[0-9]+(?:\.[0-9]+)?", value): + raise ValueError("unknown or nonnumeric compatibility version") + return tuple(map(int, value.split("."))) + + +def validate_catalog(tools): + """Validate source-owned selections; null observations stay pending.""" + preflight.validate_tools(tools) + + +def selection_digest(tools): + """Bind executable selections, excluding derived recipe/evidence digests. + + The raw whole tools JSON digest is separately recorded. Including it in this + fingerprint would require a result to hash the future file containing itself. + """ + selected = { + "schema_version": tools["schema_version"], + "tools": [{key: value for key, value in tool.items() + if key != "qualification"} for tool in tools["tools"]], + "environments": [{key: value for key, value in environment.items() + if key != "qualification"} + for environment in tools["environments"]], + "recipes": [{key: value for key, value in recipe.items() + if key not in {"sha256", "qualification"}} for recipe in tools["recipes"]], + } + return sha256_bytes(b"trusted-server-execution-selection-v1\n" + deterministic_json_bytes(selected)) + + +def candidate_fingerprints(root, tools, recipe_id): + """Bind the reviewed recipe closure; absent Task 9 helpers refuse staging.""" + validate_catalog(tools) + if recipe_id not in RECIPE_ENVIRONMENTS: + raise ValueError("unknown fixed recipe identity") + # The complete cohort needs the shared production Rust/runtime gates even + # when inspecting its JS leg. No version-only substitute closes this gate. + paths = (*SHARED_CLOSURE, *RUST_CLOSURE) + if any(not (Path(root) / path).is_file() for path in paths): + raise ValueError("Task 9 missing reviewed closure files") + recipe = next(recipe for recipe in tools["recipes"] if recipe["id"] == recipe_id) + return {"closure_sha256": closure_digest(root, paths, recipe["settings"]), + "selection_sha256": selection_digest(tools)} + + +def closure_digest(root, paths, settings): + """Hash a reviewed sorted regular-file closure and effective settings. + + The caller is source code, never a downloaded descriptor. Symlinks, escaped + paths, duplicate names and derived tools/results files are rejected. + """ + root = Path(root).resolve(strict=True) + names = sorted(paths) + if len(names) != len(set(names)) or not names: + raise ValueError("closure requires unique reviewed files") + files = [] + for name in names: + path = root / name + if (not isinstance(name, str) or Path(name).is_absolute() or ".." in Path(name).parts + or path.is_symlink() or not path.resolve(strict=True).is_relative_to(root) + or any(parent.is_symlink() for parent in path.parents if parent.is_relative_to(root)) + or path.name in {"release-tools.json", "results.json"}): + raise ValueError("unsafe or self-referencing closure path") + information = path.stat() + if not stat.S_ISREG(information.st_mode) or information.st_size > 33554432: + raise ValueError("closure requires bounded regular files") + files.append({"path": name, "executable": bool(information.st_mode & 0o111), + "sha256": sha256_bytes(path.read_bytes())}) + return sha256_bytes(b"trusted-server-recipe-closure-v1\n" + + deterministic_json_bytes({"files": files, "settings": settings})) + + +def _schema_validate(result): + try: + from jsonschema import Draft202012Validator + except ImportError as error: + raise ValueError("locked qualification schema validator is required") from error + schema = read_json_file(RELEASE / "qualification/results.schema.json", max_bytes=1048576) + Draft202012Validator.check_schema(schema) + errors = sorted(Draft202012Validator(schema).iter_errors(result), key=lambda error: str(error.path)) + if errors: + raise ValueError(f"qualification result schema: {errors[0].message}") + + +def _probe_key(probe): + return tuple(probe[key] for key in ("depth", "family", "protocol", "operation", "destination")) + + +def validate_socket_pair(positive, confined): + """Require actual denial across the process tree and private Unix success. + + Unsupported external/IPv6 positive operations remain unavailable; they never + count as successful controls. Every confined INET operation must deny with + EPERM/EACCES, even when its matching positive routing test was unavailable. + """ + expected = {(depth, family, protocol, operation, destination) for depth in range(3) + for family in ("ipv4", "ipv6") for protocol in ("tcp", "udp") + for operation in ("bind", "connect", "send") + for destination in ("loopback", "external")} + expected.update((depth, "unix", "unix", "exchange", "private") for depth in range(3)) + for records in (positive, confined): + if len(records) != len(expected) or {_probe_key(probe) for probe in records} != expected: + raise ValueError("missing or duplicate socket controls/descendant coverage") + controls = {_probe_key(probe): probe for probe in positive} + for probe in confined: + control = controls[_probe_key(probe)] + for observation in (probe, control): + if ((observation["outcome"] == "allowed" and observation["errno"] != 0) + or (observation["outcome"] != "allowed" and observation["errno"] == 0)): + raise ValueError("socket outcome contradicts actual errno") + if probe["family"] == "unix": + if any(p["outcome"] != "allowed" or p["errno"] != 0 for p in (probe, control)): + raise ValueError("private Unix IPC must succeed") + else: + phases = {"socket", probe["operation"]} + if probe["operation"] == "send" and probe["protocol"] == "tcp": + phases.add("connect") + if probe["phase"] not in phases: + raise ValueError("socket denial happened at an unrelated helper operation") + if probe["outcome"] != "denied" or probe["errno"] not in {errno.EACCES, errno.EPERM}: + raise ValueError("confined internet operation was not denied") + if control["outcome"] == "denied" or control["errno"] in {errno.EACCES, errno.EPERM}: + raise ValueError("unconfined control was denied") + if control["family"] == "ipv4" and control["destination"] == "loopback": + if control["outcome"] != "allowed" or control["errno"] != 0: + raise ValueError("unconfined positive IPv4 loopback control required") + + +def validate_observation(result): + """Reject incomplete or contradictory hosted observations with ValueError. + + This checks claims and their joins, not remote authenticity. All referenced + logs, archives, signatures and run identities still need independent review. + """ + _schema_validate(result) + for value in (result["schema_version"], result["run"]["attempt"], result["recipe"]["exit_code"], + *(runtime["exit_code"] for runtime in result["runtime"]), + *(probe[field] for phase in ("unconfined", "confined") + for probe in result["recipe"][phase] for field in ("depth", "errno"))): + require_nonnegative_integer(value) + host, recipe = result["host"], result["recipe"] + if host["environment_id"] != RECIPE_ENVIRONMENTS[recipe["id"]]: + raise ValueError("recipe builder mismatch") + mac = host["environment_id"].startswith("macos") + architecture = "aarch64" if host["environment_id"] == "macos15-aarch64" else "x86_64" + if (host["architecture"] != architecture or host["kernel_architecture"] != architecture + or host["translated"]): + raise ValueError("wrong or translated host architecture") + if mac: + if (_version(host["os_version"])[0] != 15 or host["kind"] != "github-hosted-macos" + or not host["runner_image_version"] or host["oci_index_sha256"] is not None + or host["oci_manifest_sha256"] is not None or host["glibc_version"] is not None + or result["sdk"]["kind"] != "apple-sdk" or recipe["deployment_target"] != "13.0" + or recipe["glibc_symbols"]): + raise ValueError("macOS hosted identity/SDK/loader floor mismatch") + else: + if (host["kind"] != "oci-builder" or not host["oci_index_sha256"] + or not host["oci_manifest_sha256"] or host["runner_image_version"] is not None + or _version(host["glibc_version"]) > (2, 34) + or result["sdk"]["kind"] != "gnu-sysroot" or recipe["deployment_target"] is not None): + raise ValueError("unqualified GNU builder/sysroot identity") + for symbol in recipe["glibc_symbols"]: + if not symbol.startswith("GLIBC_") or _version(symbol[6:]) > (2, 34): + raise ValueError("GNU symbol exceeds glibc 2.34 floor") + names = [tool["name"] for tool in result["tools"]] + required = BASE_TOOLS | ({"viceroy", "wasmtime"} if recipe["id"] == "trusted-server-fastly.wasm" else set()) + if set(names) != required or len(names) != len(required): + raise ValueError("missing, duplicate or unselected tool identity") + if recipe["exit_code"] or not recipe["full_recipe"] or not recipe["same_boundary"]: + raise ValueError("full recipe must succeed inside the same boundary") + if recipe["download_attempts"] != {"cargo": "denied", "npm": "denied", "missing_tool": "failed"}: + raise ValueError("download/missing-tool negative fixtures required") + validate_socket_pair(recipe["unconfined"], recipe["confined"]) + if recipe["id"] == "trusted-server-fastly.wasm": + expected = {"fastly-viceroy"} + runtime_kind = "publisher-request" + runtime_architecture = "wasm32" + elif recipe["id"] == "tsjs": + expected = {"js-pair"} + runtime_kind = "js-tests-and-equality" + runtime_architecture = architecture + else: + expected = {host["environment_id"]} if mac else {"ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64"} + runtime_kind = "cli-startup" + runtime_architecture = architecture + runtimes = result["runtime"] + if {runtime["environment_id"] for runtime in runtimes} != expected or len(runtimes) != len(expected): + raise ValueError("missing actual compatibility/application execution") + for runtime in runtimes: + if (runtime["exit_code"] or not runtime["outside_checkout"] or runtime["kind"] != runtime_kind + or runtime["architecture"] != runtime_architecture + or runtime["executable_sha256"] != recipe["executable_sha256"] + or runtime["executable_after_sha256"] != recipe["executable_sha256"]): + raise ValueError("failed startup/application request or changed final bytes") + if mac and (_version(runtime["os_version"])[0] != 15 + or runtime["os_version"] != host["os_version"]): + raise ValueError("invented macOS compatibility") + if not mac and runtime_kind == "cli-startup": + version = _version(runtime["os_version"]) + expected_version = {"ubi9-x86_64": (9,), "ubuntu22.04-x86_64": (22, 4), + "debian12-x86_64": (12,)}[runtime["environment_id"]] + if version[:len(expected_version)] != expected_version: + raise ValueError("invented Linux runtime compatibility") + if runtime_kind != "cli-startup" and runtime["response_sha256"] is None: + raise ValueError("complete application/equality response evidence required") + return result + + +def require_qualified(result): + """Refuse self-conferred qualification even for complete observations.""" + validate_observation(result) + raise ValueError("qualification pending: independently authenticated hosted evidence and source approval required") + + +def check_execution_prerequisites(root, output): + """Refuse execution before shared Task 9 recipes and authentic pins exist.""" + root = Path(root).resolve(strict=True) + for name in ("build.sh", "inspect-native.py", "validate-fastly.py"): + if not (root / "scripts/release" / name).is_file(): + raise ValueError("Task 9 shared production recipe/runtime gates are not implemented") + tools = read_json_file(root / "scripts/release/release-tools.json", max_bytes=1048576) + preflight.validate_tools(tools) + # Staging cannot require production qualification: it measures candidate + # selections before the later source-reviewed result pins exist. + raise ValueError("authenticated candidate staging dispatcher pending resource authorization") + + +def main(): + """Offer bounded observation validation and a closed readiness check.""" + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + validate = commands.add_parser("validate-observation") + validate.add_argument("result", type=Path) + commands.add_parser("check-ready") + arguments = parser.parse_args() + try: + if arguments.command == "validate-observation": + validate_observation(read_json_file(arguments.result, max_bytes=1048576)) + sys.stdout.write("valid observation; release qualification remains pending\n") + else: + check_execution_prerequisites(RELEASE.parents[1], None) + except (ValueError, OSError) as error: + logging.error("Qualification refuses input: %s", error) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release/qualification/results.schema.json b/scripts/release/qualification/results.schema.json new file mode 100644 index 0000000000..9ca1ef7072 --- /dev/null +++ b/scripts/release/qualification/results.schema.json @@ -0,0 +1,554 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/release/qualification-results-v1.schema.json", + "title": "Disposable release environment observations (not qualification authority)", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "const": 1, + "type": "integer" + }, + "purpose": { + "const": "disposable-rehearsal-observation" + }, + "source_commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "closure_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "selection_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "raw_tools_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "dependency_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "run": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository_id": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "owner_id": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "run_id": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "attempt": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "job": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "runner": { + "const": "github-hosted" + } + }, + "required": [ + "repository_id", + "owner_id", + "run_id", + "attempt", + "job", + "runner" + ] + }, + "host": { + "type": "object", + "additionalProperties": false, + "properties": { + "environment_id": { + "enum": ["gnu-builder-x86_64", "macos15-aarch64", "macos15-x86_64"] + }, + "kind": { + "enum": ["oci-builder", "github-hosted-macos"] + }, + "architecture": { + "enum": ["aarch64", "x86_64"] + }, + "kernel_architecture": { + "enum": ["aarch64", "x86_64"] + }, + "os_version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "translated": { + "type": "boolean" + }, + "runner_image_version": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "oci_index_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "oci_manifest_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "glibc_version": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "identity_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "environment_id", + "kind", + "architecture", + "kernel_architecture", + "os_version", + "translated", + "runner_image_version", + "oci_index_sha256", + "oci_manifest_sha256", + "glibc_version", + "identity_log_sha256" + ] + }, + "sdk": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "enum": ["apple-sdk", "gnu-sysroot"] + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "build_version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "tree_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "vendor_evidence_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": [ + "kind", + "version", + "build_version", + "tree_sha256", + "vendor_evidence_sha256" + ] + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "file_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "archive_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "tree_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "authentication": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { + "enum": [ + "vendor-signature", + "github-attestation", + "hosted-platform-sdk" + ] + }, + "evidence_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "trust_root_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + }, + "required": ["method", "evidence_sha256", "trust_root_sha256"] + } + }, + "required": [ + "name", + "version", + "file_sha256", + "archive_sha256", + "tree_sha256", + "authentication" + ] + }, + "maxItems": 32 + }, + "recipe": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "enum": [ + "ts-aarch64-apple-darwin", + "ts-x86_64-apple-darwin", + "ts-x86_64-unknown-linux-gnu", + "trusted-server-fastly.wasm", + "tsjs" + ] + }, + "boundary_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "preparation_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "compile_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "native_log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "exit_code": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "full_recipe": { + "type": "boolean" + }, + "same_boundary": { + "type": "boolean" + }, + "executable_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "native_materials_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "deployment_target": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "glibc_symbols": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "maxItems": 8192 + }, + "unconfined": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 2 + }, + "family": { + "enum": ["ipv4", "ipv6", "unix"] + }, + "operation": { + "enum": ["bind", "connect", "send", "exchange"] + }, + "destination": { + "enum": ["loopback", "external", "private"] + }, + "outcome": { + "enum": ["allowed", "denied", "unavailable"] + }, + "errno": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "phase": { + "enum": ["socket", "bind", "connect", "send", "exchange"] + }, + "protocol": { + "enum": ["tcp", "udp", "unix"] + } + }, + "required": [ + "depth", + "family", + "operation", + "destination", + "outcome", + "errno", + "phase", + "protocol" + ] + }, + "maxItems": 75 + }, + "confined": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 2 + }, + "family": { + "enum": ["ipv4", "ipv6", "unix"] + }, + "operation": { + "enum": ["bind", "connect", "send", "exchange"] + }, + "destination": { + "enum": ["loopback", "external", "private"] + }, + "outcome": { + "enum": ["allowed", "denied", "unavailable"] + }, + "errno": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "phase": { + "enum": ["socket", "bind", "connect", "send", "exchange"] + }, + "protocol": { + "enum": ["tcp", "udp", "unix"] + } + }, + "required": [ + "depth", + "family", + "operation", + "destination", + "outcome", + "errno", + "phase", + "protocol" + ] + }, + "maxItems": 75 + }, + "download_attempts": { + "type": "object", + "additionalProperties": false, + "properties": { + "cargo": { + "enum": ["denied", "allowed", "unavailable"] + }, + "npm": { + "enum": ["denied", "allowed", "unavailable"] + }, + "missing_tool": { + "enum": ["failed", "succeeded", "unavailable"] + } + }, + "required": ["cargo", "npm", "missing_tool"] + } + }, + "required": [ + "id", + "boundary_sha256", + "preparation_log_sha256", + "compile_log_sha256", + "native_log_sha256", + "exit_code", + "full_recipe", + "same_boundary", + "executable_sha256", + "native_materials_sha256", + "deployment_target", + "glibc_symbols", + "unconfined", + "confined", + "download_attempts" + ] + }, + "runtime": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "environment_id": { + "enum": [ + "ubi9-x86_64", + "ubuntu22.04-x86_64", + "debian12-x86_64", + "macos15-aarch64", + "macos15-x86_64", + "fastly-viceroy", + "js-pair" + ] + }, + "architecture": { + "enum": ["aarch64", "x86_64", "wasm32"] + }, + "os_version": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "outside_checkout": { + "type": "boolean" + }, + "exit_code": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "kind": { + "enum": [ + "cli-startup", + "publisher-request", + "js-tests-and-equality" + ] + }, + "executable_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "executable_after_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "log_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "response_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "environment_id", + "architecture", + "os_version", + "outside_checkout", + "exit_code", + "kind", + "executable_sha256", + "executable_after_sha256", + "log_sha256", + "response_sha256" + ] + }, + "maxItems": 8 + } + }, + "required": [ + "schema_version", + "purpose", + "source_commit", + "closure_sha256", + "selection_sha256", + "raw_tools_sha256", + "dependency_sha256", + "run", + "host", + "sdk", + "tools", + "recipe", + "runtime" + ] +} diff --git a/scripts/release/qualification/seccomp-default.json b/scripts/release/qualification/seccomp-default.json new file mode 100644 index 0000000000..ea5a494afb --- /dev/null +++ b/scripts/release/qualification/seccomp-default.json @@ -0,0 +1,875 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_GT" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + } + ] +} \ No newline at end of file diff --git a/scripts/release/qualification/socket_probe.py b/scripts/release/qualification/socket_probe.py new file mode 100644 index 0000000000..36fef6c9a7 --- /dev/null +++ b/scripts/release/qualification/socket_probe.py @@ -0,0 +1,135 @@ +"""Measure real socket operations in this process and inherited descendants. + +No result here grants qualification. External destinations are documentation +addresses, not services: routing/refusal/timeout is recorded as unavailable. +""" + +import argparse +import errno +import json +import os +import socket +import subprocess +import sys +from pathlib import Path + +DENIAL = {errno.EPERM, errno.EACCES} + + +def internet_probe(depth, family, protocol, operation, destination): + """Attempt an operation, retaining the actual failing phase and errno.""" + address_family = socket.AF_INET if family == "ipv4" else socket.AF_INET6 + loopback = "127.0.0.1" if family == "ipv4" else "::1" + external = "192.0.2.1" if family == "ipv4" else "2001:db8::1" + record = {"depth": depth, "family": family, "protocol": protocol, "operation": operation, + "destination": destination, "outcome": "unavailable", "errno": 0, + "phase": "socket"} + sockets = [] + try: + transport = socket.SOCK_DGRAM if protocol == "udp" else socket.SOCK_STREAM + client = socket.socket(address_family, transport) + sockets.append(client) + client.settimeout(0.15) + address = loopback if destination == "loopback" else external + port = 9 + if protocol == "tcp" and operation in {"connect", "send"} and destination == "loopback": + # An unconfined local listener gives the client a positive endpoint. + # Its setup failure must never masquerade as client connect denial: + # still attempt the actual client syscall against the fallback port. + try: + server = socket.socket(address_family, socket.SOCK_STREAM) + sockets.append(server) + server.bind((loopback, 0)) + server.listen(1) + port = server.getsockname()[1] + except OSError: + pass + record["phase"] = operation + if operation == "bind": + client.bind((address, 0)) + elif operation == "connect": + client.connect((address, port)) + elif protocol == "udp": + client.sendto(b"qualification-probe", (address, port)) + else: + record["phase"] = "connect" + client.connect((address, port)) + record["phase"] = "send" + client.sendall(b"qualification-probe") + record["outcome"] = "allowed" + except OSError as error: + record["errno"] = error.errno or errno.ETIMEDOUT + record["outcome"] = "denied" if error.errno in DENIAL else "unavailable" + finally: + for connection in sockets: + connection.close() + return record + + +def unix_probe(depth, private_root): + """Exchange bytes over a filesystem Unix socket inside the private root.""" + record = {"depth": depth, "family": "unix", "operation": "exchange", + "destination": "private", "outcome": "unavailable", "errno": 0, + "phase": "socket", "protocol": "unix"} + path = str(private_root / f"probe-{depth}.sock") + sockets = [] + try: + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sockets.append(server) + record["phase"] = "bind" + server.bind(path) + server.listen(1) + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sockets.append(client) + record["phase"] = "connect" + client.connect(path) + accepted, _ = server.accept() + sockets.append(accepted) + record["phase"] = "exchange" + client.sendall(b"private") + if accepted.recv(7) == b"private": + record["outcome"] = "allowed" + except OSError as error: + record["errno"] = error.errno or errno.ETIMEDOUT + record["outcome"] = "denied" if error.errno in DENIAL else "unavailable" + finally: + for connection in sockets: + connection.close() + if os.path.exists(path): + os.unlink(path) + return record + + +def probe_tree(private_root, depth=0): + """Close inherited FDs for descendants and probe three generations.""" + results = [internet_probe(depth, family, protocol, operation, destination) + for family in ("ipv4", "ipv6") for protocol in ("tcp", "udp") + for operation in ("bind", "connect", "send") + for destination in ("loopback", "external")] + results.append(unix_probe(depth, private_root)) + if depth < 2: + child = subprocess.run([sys.executable, "-I", "-B", str(Path(__file__).resolve()), + "--private-root", str(private_root), "--depth", str(depth + 1)], + env={"HOME": str(private_root), "TMPDIR": str(private_root), + "LANG": "C", "LC_ALL": "C"}, close_fds=True, + capture_output=True, check=False, timeout=15) + if child.returncode or len(child.stdout) > 65536: + raise ValueError("socket descendant probe failed") + results.extend(json.loads(child.stdout)) + return results + + +def main(): + """Print observations only; no caller-supplied commands are accepted.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--private-root", type=Path, required=True) + parser.add_argument("--depth", type=int, choices=(0, 1, 2), default=0) + arguments = parser.parse_args() + root = arguments.private_root.resolve(strict=True) + if not root.is_dir() or arguments.private_root.is_symlink(): + raise ValueError("probe requires a private existing directory") + sys.stdout.write(json.dumps(probe_tree(root, arguments.depth), separators=(",", ":")) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/release/release-tools.json b/scripts/release/release-tools.json index a01f157aa2..c1291fc7ec 100644 --- a/scripts/release/release-tools.json +++ b/scripts/release/release-tools.json @@ -3,41 +3,69 @@ { "acquisition": null, "id": "debian12-x86_64", - "image_sha256": null, + "image_reference": null, + "kind": "oci-runtime", + "oci_index_sha256": null, + "oci_manifest_sha256": null, "qualification": "pending", + "role": "compatibility-runtime" + }, + { + "acquisition": null, + "glibc_maximum": "2.34", + "id": "gnu-builder-x86_64", + "image_reference": null, + "kind": "oci-builder", + "oci_index_sha256": null, + "oci_manifest_sha256": null, + "qualification": "pending", + "role": "compilation", "sdk_sha256": null }, { "acquisition": null, "id": "macos15-aarch64", - "image_sha256": null, + "kind": "github-hosted-macos", "qualification": "pending", + "role": "compilation", + "runner_image_version": null, + "runner_label": "macos-15", "sdk_sha256": null }, { "acquisition": null, "id": "macos15-x86_64", - "image_sha256": null, + "kind": "github-hosted-macos", "qualification": "pending", + "role": "compilation", + "runner_image_version": null, + "runner_label": "macos-15-intel", "sdk_sha256": null }, { "acquisition": null, "id": "ubi9-x86_64", - "image_sha256": null, + "image_reference": null, + "kind": "oci-runtime", + "oci_index_sha256": null, + "oci_manifest_sha256": null, "qualification": "pending", - "sdk_sha256": null + "role": "compatibility-runtime" }, { "acquisition": null, "id": "ubuntu22.04-x86_64", - "image_sha256": null, + "image_reference": null, + "kind": "oci-runtime", + "oci_index_sha256": null, + "oci_manifest_sha256": null, "qualification": "pending", - "sdk_sha256": null + "role": "compatibility-runtime" } ], "recipes": [ { + "environment_id": "gnu-builder-x86_64", "id": "trusted-server-fastly.wasm", "qualification": "pending", "script": "scripts/release/build.sh", @@ -52,7 +80,9 @@ "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, "rust_toolchain": "1.95.0", - "sqlite_features": ["bundled"], + "sqlite_features": [ + "bundled" + ], "sqlite_use_pkg_config": false, "sysroot": "authenticated-sdk-required", "tool_paths": "authenticated-absolute-paths-required", @@ -63,6 +93,7 @@ "sha256": null }, { + "environment_id": "macos15-aarch64", "id": "ts-aarch64-apple-darwin", "qualification": "pending", "script": "scripts/release/build.sh", @@ -77,7 +108,9 @@ "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, "rust_toolchain": "1.95.0", - "sqlite_features": ["bundled"], + "sqlite_features": [ + "bundled" + ], "sqlite_use_pkg_config": false, "sysroot": "authenticated-sdk-required", "tool_paths": "authenticated-absolute-paths-required", @@ -88,6 +121,7 @@ "sha256": null }, { + "environment_id": "macos15-x86_64", "id": "ts-x86_64-apple-darwin", "qualification": "pending", "script": "scripts/release/build.sh", @@ -102,7 +136,9 @@ "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, "rust_toolchain": "1.95.0", - "sqlite_features": ["bundled"], + "sqlite_features": [ + "bundled" + ], "sqlite_use_pkg_config": false, "sysroot": "authenticated-sdk-required", "tool_paths": "authenticated-absolute-paths-required", @@ -113,6 +149,7 @@ "sha256": null }, { + "environment_id": "gnu-builder-x86_64", "id": "ts-x86_64-unknown-linux-gnu", "qualification": "pending", "script": "scripts/release/build.sh", @@ -127,7 +164,9 @@ "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, "rust_toolchain": "1.95.0", - "sqlite_features": ["bundled"], + "sqlite_features": [ + "bundled" + ], "sqlite_use_pkg_config": false, "sysroot": "authenticated-sdk-required", "tool_paths": "authenticated-absolute-paths-required", @@ -138,6 +177,7 @@ "sha256": null }, { + "environment_id": "gnu-builder-x86_64", "id": "tsjs", "qualification": "pending", "script": "scripts/release/build-js.sh", diff --git a/scripts/release/tests/test_qualification.py b/scripts/release/tests/test_qualification.py new file mode 100644 index 0000000000..27c8dc5d41 --- /dev/null +++ b/scripts/release/tests/test_qualification.py @@ -0,0 +1,478 @@ +"""Adversarial observational qualification fixtures; none confer release trust.""" + +import copy +import errno +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +ROOT = RELEASE.parents[1] +sys.path.insert(0, str(RELEASE / "qualification")) +sys.path.insert(0, str(RELEASE)) +if importlib.util.find_spec("qualify") is not None: + import qualify +else: + qualify = None + + +def observation(): + """Return fictional complete observations for semantic tests only.""" + digest = "a" * 64 + probes = [] + for depth in range(3): + for family in ("ipv4", "ipv6"): + for protocol in ("tcp", "udp"): + for operation in ("bind", "connect", "send"): + for destination in ("loopback", "external"): + probes.append({"depth": depth, "family": family, "protocol": protocol, + "operation": operation, "destination": destination, + "outcome": "denied", "errno": errno.EPERM, "phase": "socket"}) + probes.append({"depth": depth, "family": "unix", "operation": "exchange", + "destination": "private", "outcome": "allowed", "errno": 0, + "phase": "exchange", "protocol": "unix"}) + positive = copy.deepcopy(probes) + for probe in positive: + probe.update(outcome="allowed", errno=0, phase=probe["operation"]) + if probe["destination"] == "external": + probe.update(outcome="unavailable", errno=errno.ENETUNREACH) + return { + "schema_version": 1, "purpose": "disposable-rehearsal-observation", + "source_commit": "a" * 40, "closure_sha256": digest, + "selection_sha256": digest, "raw_tools_sha256": digest, + "dependency_sha256": digest, + "run": {"repository_id": "123", "owner_id": "456", "run_id": "789", + "attempt": 1, "job": "macos-arm", "runner": "github-hosted"}, + "host": {"environment_id": "macos15-aarch64", "kind": "github-hosted-macos", + "architecture": "aarch64", "kernel_architecture": "aarch64", + "os_version": "15.7", "translated": False, "runner_image_version": "20261001.1", + "oci_index_sha256": None, "oci_manifest_sha256": None, "glibc_version": None, + "identity_log_sha256": digest}, + "sdk": {"kind": "apple-sdk", "version": "15.5", "build_version": "24F74", + "tree_sha256": digest, "vendor_evidence_sha256": digest}, + "tools": [{"name": name, "version": "fixture", "file_sha256": digest, + "archive_sha256": digest, "tree_sha256": digest, + "authentication": {"method": "vendor-signature", "evidence_sha256": digest, + "trust_root_sha256": digest}} + for name in ("python", "git", "shell", "rustup", "rustc", "cargo", + "compiler", "linker", "archiver", "node", "npm")], + "recipe": {"id": "ts-aarch64-apple-darwin", "boundary_sha256": digest, + "preparation_log_sha256": digest, "compile_log_sha256": digest, + "native_log_sha256": digest, "exit_code": 0, "full_recipe": True, + "same_boundary": True, "executable_sha256": digest, + "native_materials_sha256": digest, "deployment_target": "13.0", + "glibc_symbols": [], "unconfined": positive, "confined": probes, + "download_attempts": {"cargo": "denied", "npm": "denied", + "missing_tool": "failed"}}, + "runtime": [{"environment_id": "macos15-aarch64", "architecture": "aarch64", + "os_version": "15.7", "outside_checkout": True, "exit_code": 0, + "kind": "cli-startup", "executable_sha256": digest, + "executable_after_sha256": digest, "log_sha256": digest, + "response_sha256": None}], + } + + +class QualificationTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(qualify, "should provide local qualification framework") + self.result = observation() + + def test_complete_observation_is_structurally_valid_without_qualifying(self): + qualify.validate_observation(self.result) + with self.assertRaisesRegex(ValueError, "authenticated.*hosted|pending"): + qualify.require_qualified(self.result) + + def test_rejects_wrong_architecture_and_translation(self): + for field, value in (("architecture", "x86_64"), ("kernel_architecture", "x86_64"), + ("translated", True), ("os_version", "26.0")): + with self.subTest(field=field): + changed = copy.deepcopy(self.result) + changed["host"][field] = value + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + + def test_rejects_missing_sdk_tool_and_authentication(self): + for change in ("sdk", "tool", "missing", "checksum"): + with self.subTest(change=change): + changed = copy.deepcopy(self.result) + if change == "sdk": + changed["sdk"]["tree_sha256"] = None + elif change == "tool": + changed["tools"][0]["file_sha256"] = None + elif change == "missing": + changed["tools"].pop() + else: + changed["tools"][0]["authentication"]["method"] = "raw-checksum" + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + + def test_rejects_failed_startup_changed_bytes_and_health_only(self): + for field, value in (("exit_code", 1), ("kind", "health"), + ("outside_checkout", False), ("executable_after_sha256", "b" * 64)): + with self.subTest(field=field): + changed = copy.deepcopy(self.result) + changed["runtime"][0][field] = value + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + + def test_rejects_invented_compatibility_and_missing_actual_runtime(self): + changed = copy.deepcopy(self.result) + changed["runtime"] = [] + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + self.result["runtime"][0]["os_version"] = "13.0" + with self.assertRaises(ValueError): + qualify.validate_observation(self.result) + + def test_rejects_permitted_confined_socket_and_missing_descendant(self): + for change in ("allowed", "timeout", "missing", "unix"): + with self.subTest(change=change): + changed = copy.deepcopy(self.result) + if change in ("allowed", "timeout"): + changed["recipe"]["confined"][0]["outcome"] = change + elif change == "missing": + changed["recipe"]["confined"] = [p for p in changed["recipe"]["confined"] + if p["depth"] != 2] + else: + changed["recipe"]["confined"][-1]["outcome"] = "denied" + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + + def test_rejects_failed_positive_control_and_incomplete_compilation(self): + for field, value in (("exit_code", 1), ("full_recipe", False), ("same_boundary", False)): + changed = copy.deepcopy(self.result) + changed["recipe"][field] = value + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + self.result["recipe"]["unconfined"][0].update(outcome="denied", errno=1) + with self.assertRaises(ValueError): + qualify.validate_observation(self.result) + + def test_closed_schema_rejects_unknown_commands_boolean_integers_and_status(self): + for changed in (dict(self.result, command="example-command"), + dict(self.result, qualification="passed"), + dict(self.result, schema_version=True)): + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + self.result["recipe"]["confined"][0]["depth"] = True + with self.assertRaises(ValueError): + qualify.validate_observation(self.result) + + def test_pending_catalog_distinguishes_sdk_and_runtime_identities(self): + import preflight + tools = preflight.fixed_tools() + qualify.validate_catalog(tools) + mac = next(e for e in tools["environments"] if e["id"] == "macos15-aarch64") + self.assertEqual(mac["kind"], "github-hosted-macos") + self.assertNotIn("image_sha256", mac, "should not label image-version hash as image bytes") + runtime = next(e for e in tools["environments"] if e["id"] == "debian12-x86_64") + self.assertEqual(runtime["role"], "compatibility-runtime") + self.assertNotIn("sdk_sha256", runtime) + + def test_hashes_do_not_self_reference_derived_result_pins(self): + import preflight + tools = preflight.fixed_tools() + first = qualify.selection_digest(tools) + changed = copy.deepcopy(tools) + changed["recipes"][0]["sha256"] = "a" * 64 + self.assertEqual(first, qualify.selection_digest(changed)) + changed["recipes"][0]["settings"]["release_debug"] = 2 + self.assertNotEqual(first, qualify.selection_digest(changed)) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "helper.py" + path.write_bytes(b"fixture\n") + first = qualify.closure_digest(Path(directory), ("helper.py",), {"mode": "fixture"}) + path.write_bytes(b"changed\n") + self.assertNotEqual(first, qualify.closure_digest(Path(directory), ("helper.py",), + {"mode": "fixture"})) + + def test_real_unconfined_probe_covers_child_and_grandchild(self): + probe = RELEASE / "qualification/socket_probe.py" + with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: + result = subprocess.run([sys.executable, "-I", "-B", str(probe), "--private-root", directory], + check=False, capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, "should execute actual probes") + probes = json.loads(result.stdout) + self.assertEqual({p["depth"] for p in probes}, {0, 1, 2}) + self.assertEqual(len(probes), 75) + self.assertEqual({p["protocol"] for p in probes}, {"tcp", "udp", "unix"}) + self.assertTrue(all(p["outcome"] == "allowed" for p in probes + if p["destination"] in ("loopback", "private") and p["family"] != "ipv6")) + + def test_full_recipe_gate_stops_before_creating_work(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "work" + with self.assertRaisesRegex(ValueError, "Task 9|production recipe"): + qualify.check_execution_prerequisites(ROOT, output) + self.assertFalse(output.exists(), "should fail before materializing work") + + def test_linux_rejects_unknown_libc_and_above_floor_symbols(self): + result = copy.deepcopy(self.result) + result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", + architecture="x86_64", kernel_architecture="x86_64", + os_version="9.6", runner_image_version=None, + oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, + glibc_version="2.34") + result["sdk"]["kind"] = "gnu-sysroot" + result["recipe"].update(id="ts-x86_64-unknown-linux-gnu", deployment_target=None, + glibc_symbols=["GLIBC_2.34"]) + result["runtime"] = [dict(self.result["runtime"][0], environment_id=identity, + architecture="x86_64", os_version=version) + for identity, version in (("ubi9-x86_64", "9.6"), + ("ubuntu22.04-x86_64", "22.04"), + ("debian12-x86_64", "12.11"))] + qualify.validate_observation(result) + for version in ("2.35", "unknown", "2.34vendor"): + changed = copy.deepcopy(result) + changed["host"]["glibc_version"] = version + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + result["recipe"]["glibc_symbols"] = ["GLIBC_2.35"] + with self.assertRaises(ValueError): + qualify.validate_observation(result) + + def test_linux_rejects_claiming_new_runtime_as_old_baseline(self): + result = copy.deepcopy(self.result) + result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", + architecture="x86_64", kernel_architecture="x86_64", + os_version="9.6", runner_image_version=None, + oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, + glibc_version="2.34") + result["sdk"]["kind"] = "gnu-sysroot" + result["recipe"].update(id="ts-x86_64-unknown-linux-gnu", deployment_target=None, + glibc_symbols=["GLIBC_2.34"]) + result["runtime"] = [dict(self.result["runtime"][0], environment_id=identity, + architecture="x86_64", os_version="26.0") + for identity in ("ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64")] + with self.assertRaisesRegex(ValueError, "runtime|compatibility"): + qualify.validate_observation(result) + + def test_complete_fastly_publisher_observation_requires_runtime_tools_and_response(self): + result = copy.deepcopy(self.result) + result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", + architecture="x86_64", kernel_architecture="x86_64", + os_version="9.6", runner_image_version=None, + oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, + glibc_version="2.34") + result["sdk"]["kind"] = "gnu-sysroot" + result["recipe"].update(id="trusted-server-fastly.wasm", deployment_target=None) + result["tools"].extend(dict(result["tools"][0], name=name) for name in ("viceroy", "wasmtime")) + result["runtime"][0].update(environment_id="fastly-viceroy", architecture="wasm32", + os_version="fixture", kind="publisher-request", response_sha256="a" * 64) + qualify.validate_observation(result) + for field, value in (("kind", "cli-startup"), ("response_sha256", None)): + changed = copy.deepcopy(result) + changed["runtime"][0][field] = value + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + + def test_selection_digest_binds_acquisition_authentication_roots(self): + import preflight + tools = preflight.fixed_tools() + digest = qualify.selection_digest(tools) + tools["tools"][0]["acquisition"] = {"trust_root_sha256": "a" * 64} + self.assertNotEqual(digest, qualify.selection_digest(tools), + "should invalidate qualification when acquisition trust changes") + + def test_source_owned_complete_closure_refuses_missing_recipe_files(self): + self.assertTrue(hasattr(qualify, "candidate_fingerprints"), "should define reviewed closure") + import preflight + with self.assertRaisesRegex(ValueError, "Task 9|missing.*closure"): + qualify.candidate_fingerprints(ROOT, preflight.fixed_tools(), "ts-aarch64-apple-darwin") + + def test_linux_profile_restricts_socket_domains_on_pinned_default_baseline(self): + self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") + import boundaries + profile = boundaries.linux_seccomp_profile() + self.assertEqual(profile["defaultAction"], "SCMP_ACT_ERRNO") + rules = [r for r in profile["syscalls"] if set(r["names"]) & {"socket", "socketpair", "socketcall"}] + self.assertEqual(len(rules), 1) + self.assertEqual(set(rules[0]["names"]), {"socket", "socketpair"}) + self.assertEqual(rules[0]["args"], [{"index": 0, "value": 1, "op": "SCMP_CMP_EQ"}]) + + def test_linux_boundary_drops_caps_and_uses_private_mounts(self): + self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") + import boundaries + with tempfile.TemporaryDirectory() as directory: + private = Path(directory) / "private" + readonly = Path(directory) / "readonly" + private.mkdir() + readonly.mkdir() + boundary = boundaries.LinuxBoundary(private, [readonly], + "example.com/builder@sha256:" + "a" * 64) + command = boundary.command(["/usr/bin/python3", "-I", "-B", "/work/probe.py"]) + self.assertIn("none", command) + self.assertIn("ALL", command) + self.assertIn("no-new-privileges=true", command) + self.assertIn("--read-only", command) + self.assertIn("--ipc=private", command) + self.assertIn("--pid=private", command) + self.assertNotIn("--privileged", command) + self.assertTrue(any("readonly" in arg for arg in command if arg.startswith("type=bind"))) + with self.assertRaises(ValueError): + boundaries.LinuxBoundary(private, [Path("/")], "example.com/builder:latest") + + def test_linux_control_uses_same_container_and_maps_private_root(self): + import boundaries + with tempfile.TemporaryDirectory() as directory: + private = Path(directory) / "private" + readonly = Path(directory) / "readonly" + private.mkdir() + readonly.mkdir() + boundary = boundaries.LinuxBoundary(private, [readonly], + "example.com/builder@sha256:" + "a" * 64) + commands = boundaries.probe_commands(boundary, private, Path("/usr/bin/python3")) + for phase in ("unconfined", "confined"): + self.assertEqual(commands[phase][0], "/usr/bin/docker") + self.assertIn(boundary.image, commands[phase]) + self.assertEqual(commands[phase][-1], "/work") + self.assertIn("none", commands["confined"]) + self.assertNotIn("none", commands["unconfined"]) + self.assertTrue(any("control-seccomp.json" in item for item in commands["unconfined"])) + + def test_closure_includes_transitive_source_owned_build_helpers(self): + required = { + "crates/trusted-server-js/build.rs", "crates/trusted-server-js/build/prebuilt.rs", + "crates/trusted-server-js/build/inventory.rs", "crates/trusted-server-js/build/bundle_set.rs", + "crates/trusted-server-core/build.rs", "crates/trusted-server-core/build_support/git_version.rs", + "crates/trusted-server-cli/build.rs", "crates/trusted-server-adapter-fastly/build.rs", + "crates/trusted-server-js/lib/build-all.mjs", "crates/trusted-server-js/lib/build-module-options.mjs", + "crates/trusted-server-js/lib/release-composition.mjs", "crates/trusted-server-js/lib/vitest.config.ts", + } + self.assertTrue(required.issubset(set(qualify.SHARED_CLOSURE)), + "should invalidate qualification when any source-owned build helper changes") + + def test_socket_failure_phase_and_errno_cannot_masquerade_as_denial(self): + for phase, outcome, error in (("exchange", "denied", errno.EPERM), + ("socket", "denied", errno.ENETUNREACH)): + changed = copy.deepcopy(self.result) + changed["recipe"]["confined"][0].update(phase=phase, outcome=outcome, errno=error) + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + self.result["recipe"]["unconfined"][1].update(outcome="unavailable", errno=0) + with self.assertRaises(ValueError): + qualify.validate_observation(self.result) + + def test_rejects_integral_floats_for_integer_identities_and_statuses(self): + for selector in ("schema", "attempt", "depth", "errno", "compile", "runtime"): + with self.subTest(selector=selector): + changed = copy.deepcopy(self.result) + if selector == "schema": + changed["schema_version"] = 1.0 + elif selector == "attempt": + changed["run"]["attempt"] = 1.0 + elif selector in {"depth", "errno"}: + changed["recipe"]["confined"][0][selector] = 0.0 if selector == "depth" else 1.0 + elif selector == "compile": + changed["recipe"]["exit_code"] = 0.0 + else: + changed["runtime"][0]["exit_code"] = 0.0 + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + + def test_container_mounts_reject_symlink_directory_escape(self): + import boundaries + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + private, readonly, outside = (root / name for name in ("private", "readonly", "outside")) + for path in (private, readonly, outside): + path.mkdir() + (readonly / "escape").symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink|escape"): + boundaries.LinuxBoundary(private, [readonly], "example.com/builder@sha256:" + "a" * 64) + + def test_recipe_closure_rejects_internal_symlink_parent(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + actual = root / "actual" + actual.mkdir() + (actual / "helper.py").write_text("fixture\n") + (root / "alias").symlink_to(actual, target_is_directory=True) + with self.assertRaises(ValueError): + qualify.closure_digest(root, ("alias/helper.py",), {"mode": "fixture"}) + + def test_macos_profile_rejects_shared_write_roots_and_has_private_unix_only(self): + self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") + import boundaries + with tempfile.TemporaryDirectory() as directory: + private = Path(directory) / "private" + readonly = Path(directory) / "readonly" + private.mkdir() + readonly.mkdir() + profile = boundaries.macos_profile(private, [readonly]) + self.assertIn("(deny default)", profile) + self.assertIn("(local unix-socket", profile) + self.assertIn("(remote unix-socket", profile) + self.assertNotIn("(allow network*)", profile) + self.assertNotIn("mach-lookup", profile) + self.assertNotIn("appleevent", profile) + with self.assertRaises(ValueError): + boundaries.macos_profile(private, [Path(directory)]) + + def test_macos_profile_protects_tracked_source_and_git_with_private_install_writes(self): + import boundaries + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + manifest = root / "package.json" + objects = root / "git-objects" + manifest.write_text("{}\n") + objects.mkdir() + profile = boundaries.macos_profile(root, [], immutable=[manifest, objects]) + self.assertIn(f'(deny file-write* (literal "{manifest}"))', profile) + self.assertIn(f'(deny file-write* (subpath "{objects}"))', profile) + + @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", + "explicit actual local macOS immutable source probe") + def test_actual_source_writes_denied_while_node_modules_writes_allowed(self): + import boundaries + with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: + root = Path(directory).resolve() + manifest = root / "package.json" + objects = root / "git-objects" + manifest.write_text("{}\n") + objects.mkdir() + node_modules = root / "node_modules" + node_modules.mkdir() + python = Path(sys.executable).resolve() + boundary = boundaries.MacBoundary(root, [python.parents[1], Path("/usr/lib"), + Path("/System/Library")], immutable=[manifest, objects]) + script = """import errno, pathlib, sys +root = pathlib.Path(sys.argv[1]) +for path in (root / 'package.json', root / 'git-objects' / 'new-object'): + try: + path.write_text('mutation') + except OSError as error: + if error.errno not in (errno.EACCES, errno.EPERM): + raise + else: + sys.exit('immutable source was writable') +(root / 'node_modules' / 'generated').write_text('private-install-output') +""" + result = subprocess.run(boundary.command([str(python), "-I", "-B", "-c", script, str(root)]), + env={"HOME": str(root), "TMPDIR": str(root)}, stdin=subprocess.DEVNULL, + capture_output=True, close_fds=True, timeout=30, check=False) + self.assertEqual(result.returncode, 0, "should permit only private installation writes") + self.assertEqual(manifest.read_text(), "{}\n") + self.assertEqual((node_modules / "generated").read_text(), "private-install-output") + + @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", + "explicit actual local macOS profile probe") + def test_actual_local_mac_profile_denies_descendants_and_permits_private_unix(self): + self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") + import boundaries + with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: + root = Path(directory).resolve() + readonly = [RELEASE, Path(sys.executable).resolve().parents[1], + Path("/usr/lib"), Path("/System/Library")] + boundary = boundaries.MacBoundary(root, readonly) + probes = boundaries.probe_controls(boundary, root, Path(sys.executable).resolve()) + qualify.validate_socket_pair(probes["unconfined"], probes["confined"]) + + +if __name__ == "__main__": + unittest.main() From 7c8f279c6b4c91b6f84ac8006732eaf1bf7a7808 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 08:41:54 +0530 Subject: [PATCH 031/153] Validate qualification probe phases and numeric floors --- scripts/release/qualification/qualify.py | 9 +++- scripts/release/tests/test_qualification.py | 53 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/release/qualification/qualify.py b/scripts/release/qualification/qualify.py index 757fae23b6..0739044b02 100644 --- a/scripts/release/qualification/qualify.py +++ b/scripts/release/qualification/qualify.py @@ -50,9 +50,12 @@ def _version(value): - if not isinstance(value, str) or not re.fullmatch(r"[0-9]+\.[0-9]+(?:\.[0-9]+)?", value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,2}", value): raise ValueError("unknown or nonnumeric compatibility version") - return tuple(map(int, value.split("."))) + components = list(map(int, value.split("."))) + while len(components) > 1 and components[-1] == 0: + components.pop() + return tuple(components) def validate_catalog(tools): @@ -159,6 +162,8 @@ def validate_socket_pair(positive, confined): if ((observation["outcome"] == "allowed" and observation["errno"] != 0) or (observation["outcome"] != "allowed" and observation["errno"] == 0)): raise ValueError("socket outcome contradicts actual errno") + if observation["outcome"] == "allowed" and observation["phase"] != observation["operation"]: + raise ValueError("successful socket observation must complete its intended operation") if probe["family"] == "unix": if any(p["outcome"] != "allowed" or p["errno"] != 0 for p in (probe, control)): raise ValueError("private Unix IPC must succeed") diff --git a/scripts/release/tests/test_qualification.py b/scripts/release/tests/test_qualification.py index 27c8dc5d41..2db8ff8560 100644 --- a/scripts/release/tests/test_qualification.py +++ b/scripts/release/tests/test_qualification.py @@ -358,6 +358,59 @@ def test_socket_failure_phase_and_errno_cannot_masquerade_as_denial(self): with self.assertRaises(ValueError): qualify.validate_observation(self.result) + def test_successful_socket_controls_must_complete_the_intended_operation(self): + for phase, index, wrong_phase in (("unconfined", 0, "exchange"), + ("unconfined", 2, "bind"), + ("unconfined", 4, "connect"), + ("unconfined", 24, "bind"), + ("confined", 24, "bind")): + with self.subTest(phase=phase, index=index, wrong_phase=wrong_phase): + changed = copy.deepcopy(self.result) + changed["recipe"][phase][index]["phase"] = wrong_phase + with self.assertRaisesRegex(ValueError, "operation|exchange"): + qualify.validate_observation(changed) + + def test_tcp_send_denial_can_record_an_earlier_actual_client_phase(self): + for phase in ("socket", "connect", "send"): + with self.subTest(phase=phase): + changed = copy.deepcopy(self.result) + changed["recipe"]["confined"][4]["phase"] = phase + qualify.validate_observation(changed) + + def test_major_debian_and_zero_suffixed_glibc_preserve_numeric_floors(self): + result = copy.deepcopy(self.result) + result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", + architecture="x86_64", kernel_architecture="x86_64", + os_version="9.6", runner_image_version=None, + oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, + glibc_version="2.34.0") + result["sdk"]["kind"] = "gnu-sysroot" + result["recipe"].update(id="ts-x86_64-unknown-linux-gnu", deployment_target=None, + glibc_symbols=["GLIBC_2.34.0"]) + result["runtime"] = [dict(self.result["runtime"][0], environment_id=identity, + architecture="x86_64", os_version=version) + for identity, version in (("ubi9-x86_64", "9.6"), + ("ubuntu22.04-x86_64", "22.04"), + ("debian12-x86_64", "12"))] + for version in ("2.34", "2.34.0"): + with self.subTest(valid_glibc=version): + changed = copy.deepcopy(result) + changed["host"]["glibc_version"] = version + changed["recipe"]["glibc_symbols"] = [f"GLIBC_{version}"] + qualify.validate_observation(changed) + for value in ("2.34.1", "2.35.0", "unknown", "2.34vendor"): + with self.subTest(glibc=value): + changed = copy.deepcopy(result) + changed["host"]["glibc_version"] = value + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + for value in ("11", "13", "12vendor"): + with self.subTest(debian=value): + changed = copy.deepcopy(result) + changed["runtime"][-1]["os_version"] = value + with self.assertRaises(ValueError): + qualify.validate_observation(changed) + def test_rejects_integral_floats_for_integer_identities_and_statuses(self): for selector in ("schema", "attempt", "depth", "errno", "compile", "runtime"): with self.subTest(selector=selector): From b48b92c38fdeae86819e30ebe93e71baef3cc287 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 08:50:47 +0530 Subject: [PATCH 032/153] Protect immutable source ancestors in macOS qualification --- scripts/release/qualification/README.md | 7 ++- scripts/release/qualification/boundaries.py | 9 ++++ scripts/release/tests/test_qualification.py | 60 +++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/scripts/release/qualification/README.md b/scripts/release/qualification/README.md index eaa518334b..fce496eb91 100644 --- a/scripts/release/qualification/README.md +++ b/scripts/release/qualification/README.md @@ -118,8 +118,11 @@ does not replace Task 7's independent JS boundary or qualify hosted recipes. The candidate macOS profile can mark actual tracked source files and Git-object directories inside the private root immutable, while leaving node_modules and -output writes available. This separation is locally exercised with actual deny -and success controls; the full recipe must pass its reviewed tracked-object set. +output writes available. It also denies unlink/rename of their ancestors so a +writable ancestor cannot move a protected file outside its literal deny rule. +This separation is locally exercised with actual ancestor rename/removal, +hard-link, source-write and private-output controls; the full recipe must pass +its reviewed tracked-object set. Both boundaries require actual positive then confined TCP and UDP bind/connect/ send probes for IPv4/IPv6, loopback/documentation-address destinations, across diff --git a/scripts/release/qualification/boundaries.py b/scripts/release/qualification/boundaries.py index f3ccb549a2..4bca4adcf1 100644 --- a/scripts/release/qualification/boundaries.py +++ b/scripts/release/qualification/boundaries.py @@ -63,6 +63,7 @@ def macos_profile(private_root, readonly, *, immutable=()): boundary.private_root, boundary.readonly = root, reads profile = boundary.command([])[2] quoted = deterministic_json_bytes(str(root)).decode().strip() + protected_ancestors = set() for path in immutable: path = Path(path) resolved = path.resolve(strict=True) @@ -71,6 +72,14 @@ def macos_profile(private_root, readonly, *, immutable=()): predicate = "subpath" if resolved.is_dir() else "literal" quoted_path = deterministic_json_bytes(str(resolved)).decode().strip() profile += f"(deny file-write* ({predicate} {quoted_path}))" + protected_ancestors.update(parent for parent in resolved.parents if parent.is_relative_to(root)) + # Path-based write denial must remain attached to the same source pathname. + # Deny unlink/rename of its ancestors while permitting writes to their private + # node_modules/output children. Broad ancestor file-write denial would block + # the required private installations as well. + for ancestor in sorted(protected_ancestors): + quoted_ancestor = deterministic_json_bytes(str(ancestor)).decode().strip() + profile += f"(deny file-write-unlink (literal {quoted_ancestor}))" return (profile + "(allow system-socket (socket-domain AF_UNIX))" + f"(allow network-bind network-inbound (local unix-socket (subpath {quoted})))" + f"(allow network-outbound (remote unix-socket (subpath {quoted})))") diff --git a/scripts/release/tests/test_qualification.py b/scripts/release/tests/test_qualification.py index 2db8ff8560..2ada53c116 100644 --- a/scripts/release/tests/test_qualification.py +++ b/scripts/release/tests/test_qualification.py @@ -479,6 +479,66 @@ def test_macos_profile_protects_tracked_source_and_git_with_private_install_writ self.assertIn(f'(deny file-write* (literal "{manifest}"))', profile) self.assertIn(f'(deny file-write* (subpath "{objects}"))', profile) + @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", + "explicit actual macOS ancestor alias protection probe") + def test_actual_immutable_ancestors_deny_rename_removal_and_hardlink_aliases(self): + import boundaries + with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: + root = Path(directory).resolve() + source = root / "crates/source" + source.mkdir(parents=True) + manifest = source / "Cargo.toml" + manifest.write_text("fixture\n") + objects = source / "git/objects" + objects.mkdir(parents=True) + (source / "node_modules").mkdir() + (source / "output").mkdir() + python = Path(sys.executable).resolve() + boundary = boundaries.MacBoundary(root, [python.parents[1], Path("/usr/lib"), + Path("/System/Library")], immutable=[manifest, objects]) + script = """import errno, os, pathlib, sys +root = pathlib.Path(sys.argv[1]) +source = root / 'crates/source' +for ancestor in (root / 'crates', source, source / 'git'): + moved = root / 'moved' + try: + ancestor.rename(moved) + except OSError as error: + if error.errno not in (errno.EACCES, errno.EPERM): + raise + else: + manifest = moved / ('source/Cargo.toml' if ancestor == root / 'crates' else 'Cargo.toml') + if manifest.is_file(): + manifest.write_text('mutation-via-ancestor-alias') + moved.rename(ancestor) + sys.exit('immutable ancestor rename was permitted') + try: + ancestor.rmdir() + except OSError as error: + if error.errno not in (errno.EACCES, errno.EPERM): + raise + else: + sys.exit('immutable ancestor removal was permitted') +try: + os.link(source / 'Cargo.toml', root / 'hardlink-alias') +except OSError as error: + if error.errno not in (errno.EACCES, errno.EPERM): + raise +else: + sys.exit('immutable hard-link alias was permitted') +(source / 'node_modules/generated').write_text('private-install-output') +(source / 'output/generated').write_text('private-build-output') +""" + result = subprocess.run(boundary.command([str(python), "-I", "-B", "-c", script, str(root)]), + env={"HOME": str(root), "TMPDIR": str(root)}, stdin=subprocess.DEVNULL, + capture_output=True, close_fds=True, timeout=30, check=False) + self.assertEqual(result.returncode, 0, + "should deny ancestor aliases/removal and permit private writes: " + + result.stderr.decode()) + self.assertEqual(manifest.read_text(), "fixture\n", "should retain original tracked source") + self.assertEqual((source / "node_modules/generated").read_text(), "private-install-output") + self.assertEqual((source / "output/generated").read_text(), "private-build-output") + @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", "explicit actual local macOS immutable source probe") def test_actual_source_writes_denied_while_node_modules_writes_allowed(self): From 54f6c6e8e61a7f6202931be54d06460027a4164e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 08:55:27 +0530 Subject: [PATCH 033/153] Record local qualification framework verification --- .../2026-10-13-attested-release-implementation.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 5c8c16e7a6..59dcba54a6 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -151,8 +151,8 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** `scripts/release/qualification/qualify.py`, `socket_probe.py`, `results.schema.json`, `scripts/release/tests/test_qualification.py`; update `release-tools.json` with measured pins. -- [ ] Write schema/preflight fixtures rejecting wrong architecture, missing SDK/tool digest, failed startup, permitted confined sockets and invented host compatibility. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_qualification.py' -v`; expect unimplemented qualification checks. +- [x] Write schema/preflight fixtures rejecting wrong architecture, missing SDK/tool digest, failed startup, permitted confined sockets and invented host compatibility. +- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_qualification.py' -v`; expect unimplemented qualification checks. - [ ] Execute throwaway full recipes on the actual proposed GitHub-hosted environments: digest-pinned GNU builder <=2.34, matching macOS 15 ARM/Intel, explicit loader floor 13.0. Record host, rustup paths, compiler/linker/SDK/native identities. Docker absence on a developer Mac is not replaced by an inferred Linux result. - [ ] On each recipe run an unconfined socket success control and a confined denied socket probe, then full compilation under the same qualified boundary. Linux container uses process network isolation; macOS profile must permit required filesystem/process/tool activity while denying sockets. Lifecycle preparation is separate and authenticated. - [ ] Record actual startup/link evidence and complete Fastly runtime request, not only host health. Missing Intel execution or GNU baseline stops qualification. A different platform/cohort requires reviewed spec changes, not a silent downgrade. @@ -160,6 +160,8 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Gate:** This task produces measured pins, not placeholder configuration. It can run only with the separately authorized disposable hosted resources. It does not publish official tags or deploy a real publisher service. +Implementation sequencing: review the local validation framework and candidate boundaries first, then implement the shared Task 9 recipes. Hosted qualification must invoke those same recipes with independently authenticated candidate selections; it cannot require its own future qualification result. Production selection remains closed until the hosted evidence and measured pins are reviewed. Local framework completion does not satisfy the hosted checkboxes above. + ### Task 9: Implement fixed fresh Rust recipes and native inspection **Create:** `scripts/release/build.sh`, `inspect-native.py`, `tests/test_recipe.py`, `tests/test_native.py`, `scripts/release/validate-fastly.py`, `tests/test_fastly_runtime.py`. @@ -452,3 +454,9 @@ Preparation authenticates original archive bytes, bounded decompressed tar metad Review corrections require exact integer inventory sizes and closed root/module shapes, and precheck every selected Git object’s identity/type/size and cumulative budget before requesting bodies. Export and repeated source checks share that reader, including direct commit-object timestamp inspection without parent history. Its guarantee explicitly assumes the sanitized trusted Git executable and an immutable object store; it does not claim adversarial race-proof streaming. Release Vitest uses a literal host and a narrowly scoped native Node-import resolver to preserve production-mode validation without DNS or browser builtin substitution; ordinary development and the separate IIFE collector retain their behavior. The controller’s first post-review full run was interrupted by overnight system sleep and failed closed with worker/test timeouts and unexpected temporary output. Power-log and clock inspection identified the interruption; an unchanged rerun with a process-scoped idle-sleep assertion passes in 226.755 seconds. No timeout, source exception or network permission was relaxed. Actual hosted OS/tool/SDK acquisition and boundary qualification, authenticated Python bootstrap/path selection and official recipe enablement remain Task 8; the public producer command continues to refuse unqualified execution. + +Task 8 has a reviewed local framework in `481f8c7fa`, with corrections in `7c8f279c6` and `b48b92c38`; Task 8 itself remains incomplete. Independent spec and quality reviews approve the local framework only. The controller freshly ran all 155 release-helper Python tests in 29.507 seconds, including all three actual macOS boundary controls; the separately selected full JS pipeline is the sole skipped test and retains its Task 7 evidence. The locked private validator environment installs with required hashes and binary distributions, and passes `pip check`. The controller also checked the retained primary-source Moby blob against the exact vendored seccomp bytes and recorded SHA-256. + +The local framework validates closed observations without authenticating their claims or conferring qualification. It distinguishes the GNU builder from the three compatibility runtimes and records actual hosted macOS image versions rather than inventing VM byte hashes. Domain-separated fingerprints bind the reviewed executable helper closure, settings and acquisition selections while excluding derived result pins; the raw tools-file hash remains a separate input. Actual TCP/UDP controls cover IPv4/IPv6, loopback/documentation destinations and three process generations, with successful private Unix communication. Unavailable routing is distinct from permission denial. Review corrections reject contradictory successful phases, normalize numeric compatibility versions, and prevent protected source/Git ancestors from being renamed or removed while preserving private installation/output writes. The ancestor bypass was reproduced before correction and independently rejected afterward. + +All tool, SDK, OCI and recipe qualification pins remain pending. The inert manual hosted-workflow proposal is outside `.github/workflows` and deliberately stops before incomplete shared recipes or unauthenticated staging can run. Actual GNU execution, native macOS 15 ARM/Intel compilation and startup, complete Fastly publisher-response validation, authenticated bootstrap/acquisition evidence, separately authorized hosted resources and reviewed measured pins remain required. No workflow registration, hosted dispatch, signing, publication or deployment occurred. Read-only Rust architecture and dependency investigations support implementing one shared recipe next; its candidate selector must remain internal and cannot supply consumer or publication authority. From 5476b7cced4ff3795ae39098203c6910270503cf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 18:08:45 +0530 Subject: [PATCH 034/153] Add confined Rust recipe and executable inspection framework --- scripts/release/build.sh | 10 + scripts/release/inspect-native.py | 243 ++++ scripts/release/preflight.py | 17 + scripts/release/qualification/README.md | 23 + scripts/release/qualification/boundaries.py | 126 +- scripts/release/qualification/qualify.py | 7 +- scripts/release/release-tools.json | 112 ++ scripts/release/rust_recipe.py | 1084 ++++++++++++++++++ scripts/release/tests/test_fastly_runtime.py | 104 ++ scripts/release/tests/test_native.py | 153 +++ scripts/release/tests/test_qualification.py | 24 +- scripts/release/tests/test_recipe.py | 500 ++++++++ scripts/release/validate-fastly.py | 402 +++++++ 13 files changed, 2788 insertions(+), 17 deletions(-) create mode 100755 scripts/release/build.sh create mode 100755 scripts/release/inspect-native.py create mode 100644 scripts/release/rust_recipe.py create mode 100644 scripts/release/tests/test_fastly_runtime.py create mode 100644 scripts/release/tests/test_native.py create mode 100644 scripts/release/tests/test_recipe.py create mode 100755 scripts/release/validate-fastly.py diff --git a/scripts/release/build.sh b/scripts/release/build.sh new file mode 100755 index 0000000000..5d643a4396 --- /dev/null +++ b/scripts/release/build.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# The bootstrap is fail closed until independently authenticated Python exists. +set -eu +exec /usr/bin/python3 -I -B -c ' +import runpy, sys +if sys.version_info < (3, 11): + sys.exit("release qualification pending: authenticated Python 3.11+ required") +sys.argv = sys.argv[1:] +runpy.run_path(sys.argv[0], run_name="__main__") +' "${0%/*}/rust_recipe.py" "$@" diff --git a/scripts/release/inspect-native.py b/scripts/release/inspect-native.py new file mode 100755 index 0000000000..c33988020c --- /dev/null +++ b/scripts/release/inspect-native.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Inspect bounded final CLI bytes without selecting an ambient external tool. + +This structural gate is not startup qualification. Actual clean selected runtime +startup must independently measure these same final bytes outside the checkout. +""" +import argparse +import logging +import re +import struct +import sys +from pathlib import Path + +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from common import deterministic_json_bytes, sha256_bytes +from prepare import _regular_bytes + +MAX_EXECUTABLE = 536870912 +MAX_VERSION_RECORDS = 16384 + +LINUX_LIBRARIES = frozenset({"libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", + "librt.so.1", "libgcc_s.so.1", "ld-linux-x86-64.so.2"}) +MAC_LIBRARIES = frozenset({"/usr/lib/libSystem.B.dylib", "/usr/lib/libobjc.A.dylib", + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", + "/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration"}) + + +def _span(data, offset, size): + if type(offset) is not int or type(size) is not int or offset < 0 or size < 0 or offset + size > len(data): + raise ValueError("truncated native executable") + return data[offset:offset + size] + + +def _unpack(data, offset, format): + return struct.unpack(format, _span(data, offset, struct.calcsize(format))) + + +def _cstring(data, offset): + if offset < 0 or offset >= len(data): + raise ValueError("native string offset outside table") + end = data.find(b"\0", offset) + if end < 0 or end - offset > 4096: + raise ValueError("native string unterminated or too long") + try: + return data[offset:end].decode("utf-8") + except UnicodeError as error: + raise ValueError("native loader string must be UTF-8") from error + + +def _elf(data): + if _span(data, 0, 7) != b"\x7fELF\x02\x01\x01": + raise ValueError("ELF64 little-endian executable required") + kind, machine, version, _, phoff, _, _, ehsize, phsize, phnum, _, _, _ = _unpack(data, 16, " memsz: + raise ValueError("ELF segment file size exceeds memory size") + if tag == 1: + loads.append((offset, address, filesz)) + elif tag == 2: + dynamic.append((offset, filesz)) + elif tag == 3: + interpreters.append(_cstring(_span(data, offset, filesz), 0)) + if len(dynamic) != 1 or interpreters != ["/lib64/ld-linux-x86-64.so.2"]: + raise ValueError("ELF dynamic interpreter selection mismatch") + + def mapped(address, size): + matching = [offset + address - base for offset, base, length in loads if base <= address and address + size <= base + length] + if len(matching) != 1: + raise ValueError("ELF dynamic address is not uniquely file-backed") + return matching[0] + + offset, size = dynamic[0] + if size % 16 or size > 65536: + raise ValueError("ELF dynamic table bound exceeded") + entries = [] + for index in range(size // 16): + entry = _unpack(data, offset + 16 * index, " MAX_VERSION_RECORDS: + raise ValueError("ELF version traversal bound exceeded") + if auxiliary in seen: + raise ValueError("ELF version auxiliary cycle") + seen.add(auxiliary) + _, _, _, name, next_aux = _unpack(data, mapped(auxiliary, 16), " (2, 34, 0): + raise ValueError("GLIBC symbol ceiling exceeds 2.34") + versions.append(match[1]) + if bool(next_aux) != (member < number - 1): + raise ValueError("ELF version auxiliary membership mismatch") + auxiliary += next_aux + if bool(following) != (index < count - 1): + raise ValueError("ELF version need membership mismatch") + address += following + return {"format": "elf", "architecture": "x86_64", "interpreter": interpreters[0], + "libraries": sorted(libraries), "rpaths": paths, + "glibc_versions": sorted(set(versions), key=lambda value: tuple(map(int, value.split("."))))} + + +def _version(value): + return f"{value >> 16}.{value >> 8 & 255}.{value & 255}" + + +def _macho(data, target): + magic, cpu, _, kind, count, size, _, _ = _unpack(data, 0, " 1048576: + raise ValueError("Mach-O load command bound exceeded") + offset, libraries, rpaths, minimum, sdk, interpreters, entrypoints = 0, [], [], [], [], [], [] + for _ in range(count): + command, length = _unpack(commands, offset, "= deadline: + raise ValueError("bounded process timeout") + for key, _ in selector.select(min(0.1, max(0, deadline - time.monotonic()))): + chunk = os.read(key.fileobj.fileno(), 65536) + if not chunk: + selector.unregister(key.fileobj) + continue + if len(outputs[key.data]) + len(chunk) > maximum: + raise ValueError("bounded process output bound exceeded") + outputs[key.data].extend(chunk) + process.wait(timeout=max(0.001, deadline - time.monotonic())) + return subprocess.CompletedProcess(argv, process.returncode, bytes(outputs[0]), bytes(outputs[1])) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + selector.close() + process.stdout.close() + process.stderr.close() + + def _roots(private_root, readonly): root = Path(private_root).resolve(strict=True) reads = [Path(path).resolve(strict=True) for path in readonly] @@ -94,7 +133,7 @@ def __init__(self, private_root, readonly, *, immutable=()): self.profile = macos_profile(private_root, readonly, immutable=immutable) self.sha256 = sha256_bytes(self.profile.encode()) - def command(self, command): + def command(self, command, *, environment=None, cwd=None): """Wrap source-owned argv, closing descriptors in the caller.""" return ["/usr/bin/sandbox-exec", "-p", self.profile, *map(str, command)] @@ -107,8 +146,26 @@ class LinuxBoundary: Preparation must create writable ownership for the fixed unprivileged UID. """ - def __init__(self, private_root, readonly, image): + def __init__(self, private_root, readonly, image, *, immutable=(), python=None, execution_umask=0o022): self.private_root, self.readonly = _roots(private_root, readonly) + if type(execution_umask) is not int or execution_umask not in {0o022, 0o027}: + raise ValueError("source-owned release umask required") + if python is not None and (not Path(python).is_absolute() or ".." in Path(python).parts): + raise ValueError("explicit authenticated container Python required") + self.python, self.execution_umask = python, execution_umask + self.containers = set() + self.docker_config = self.private_root / "docker-config" + self.docker_config.mkdir(mode=0o755) + (self.docker_config / "config.json").write_bytes(b"{}\n") + self.docker_prefix = ["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", + "--config", str(self.docker_config)] + self.immutable = [] + for path in immutable: + path = Path(path) + resolved = path.resolve(strict=True) + if path.is_symlink() or resolved == self.private_root or not resolved.is_relative_to(self.private_root): + raise ValueError("immutable source must be a real member of the private workspace") + self.immutable.append(resolved) if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+@sha256:[0-9a-f]{64}", image): raise ValueError("selected OCI manifest digest required; tags are forbidden") self.image = image @@ -128,26 +185,69 @@ def __init__(self, private_root, readonly, image): if path.is_socket(): raise ValueError("host Unix/daemon sockets must not enter container mounts") - def command(self, command): + def command(self, command, *, environment=None, cwd=None): """Use no host namespace, daemon socket, privileges or writable tools.""" - return self._container_command(command, confined=True) + return self._container_command(command, confined=True, environment=environment, cwd=cwd) + + def map_path(self, path): + """Map internal source-owned workspace paths to the private container.""" + pattern = r"(? 256: + raise ValueError("closed controlled container environment required") + for key, value in sorted(environment.items()): + if (not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) + or not isinstance(value, str) or len(value) > 65536 or "\0" in value or "\n" in value): + raise ValueError("unsupported controlled environment entry") + result.extend(["--env", key + "=" + self.map_path(value)]) if confined: result.extend(["--network", "none"]) for path in self.readonly: result.extend(["--mount", f"type=bind,src={path},dst={path},readonly"]) - return [*result, self.image, *map(str, command)] + for path in self.immutable: + result.extend(["--mount", f"type=bind,src={path},dst={self.map_path(path)},readonly"]) + argv = [self.map_path(value) for value in command] + if self.python is not None: + # Docker does not inherit its client's umask. Apply it to the actual + # container process before exec; no shell evaluation is involved. + driver = f"import os,sys;os.umask({self.execution_umask});os.execve(sys.argv[1],sys.argv[1:],dict(os.environ))" + argv = [str(self.python), "-I", "-B", "-c", driver, *argv] + return [*result, self.image, *argv] def probe_commands(boundary, private_root, python): @@ -189,9 +289,11 @@ def probe_controls(boundary, private_root, python): raise ValueError("explicit prepared Python executable required") observations = {} for phase, argv in probe_commands(boundary, root, python).items(): - result = subprocess.run(argv, env={"HOME": str(root), "TMPDIR": str(root), "LANG": "C", "LC_ALL": "C"}, - stdin=subprocess.DEVNULL, close_fds=True, capture_output=True, - timeout=30, check=False) + try: + result = bounded_process(argv, {"HOME": str(root), "TMPDIR": str(root), "LANG": "C", "LC_ALL": "C"}) + finally: + if isinstance(boundary, LinuxBoundary): + boundary.cleanup(argv) if result.returncode or len(result.stdout) > 65536 or len(result.stderr) > 65536: raise ValueError(f"{phase} probe failed; actual local boundary unavailable") observations[phase] = load_json_bytes(result.stdout, max_bytes=65536) diff --git a/scripts/release/qualification/qualify.py b/scripts/release/qualification/qualify.py index 0739044b02..efe893963a 100644 --- a/scripts/release/qualification/qualify.py +++ b/scripts/release/qualification/qualify.py @@ -45,8 +45,11 @@ "crates/trusted-server-cli/Cargo.toml", "crates/trusted-server-core/Cargo.toml", "crates/trusted-server-js/Cargo.toml", "crates/trusted-server-adapter-fastly/Cargo.toml", ) -RUST_CLOSURE = ("scripts/release/build.sh", "scripts/release/inspect-native.py", - "scripts/release/validate-fastly.py") +RUST_CLOSURE = ("edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs", + "scripts/release/build.sh", "scripts/release/inspect-native.py", + "scripts/release/validate-fastly.py", "scripts/release/rust_recipe.py", + "crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml", + "crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml") def _version(value): diff --git a/scripts/release/release-tools.json b/scripts/release/release-tools.json index c1291fc7ec..e15583a22b 100644 --- a/scripts/release/release-tools.json +++ b/scripts/release/release-tools.json @@ -70,15 +70,43 @@ "qualification": "pending", "script": "scripts/release/build.sh", "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, "aws_lc_target_selector": true, "aws_lc_use_system": false, "cargo_config": ".cargo/config.toml", + "cargo_frozen": true, "cargo_home": "private-prepared", "cargo_locked": true, + "cargo_message_format": "json-render-diagnostics", + "download_control": "actual-cargo-build-script-required", "fresh_target": true, "network_boundary": "qualification-pending", + "normalized_inputs": [ + "compiler-locale-C", + "compiler-timezone-UTC", + "source-commit-epoch", + "remapped-paths" + ], "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, + "release_strip": "none", "rust_toolchain": "1.95.0", "sqlite_features": [ "bundled" @@ -98,15 +126,43 @@ "qualification": "pending", "script": "scripts/release/build.sh", "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, "aws_lc_target_selector": true, "aws_lc_use_system": false, "cargo_config": ".cargo/config.toml", + "cargo_frozen": true, "cargo_home": "private-prepared", "cargo_locked": true, + "cargo_message_format": "json-render-diagnostics", + "download_control": "actual-cargo-build-script-required", "fresh_target": true, "network_boundary": "qualification-pending", + "normalized_inputs": [ + "compiler-locale-C", + "compiler-timezone-UTC", + "source-commit-epoch", + "remapped-paths" + ], "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, + "release_strip": "none", "rust_toolchain": "1.95.0", "sqlite_features": [ "bundled" @@ -126,15 +182,43 @@ "qualification": "pending", "script": "scripts/release/build.sh", "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, "aws_lc_target_selector": true, "aws_lc_use_system": false, "cargo_config": ".cargo/config.toml", + "cargo_frozen": true, "cargo_home": "private-prepared", "cargo_locked": true, + "cargo_message_format": "json-render-diagnostics", + "download_control": "actual-cargo-build-script-required", "fresh_target": true, "network_boundary": "qualification-pending", + "normalized_inputs": [ + "compiler-locale-C", + "compiler-timezone-UTC", + "source-commit-epoch", + "remapped-paths" + ], "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, + "release_strip": "none", "rust_toolchain": "1.95.0", "sqlite_features": [ "bundled" @@ -154,15 +238,43 @@ "qualification": "pending", "script": "scripts/release/build.sh", "settings": { + "ambient_profiles": { + "A": { + "checkout_root": "external-release-A", + "execution_user": "qualification-selected", + "jobs": 4, + "locale": "C", + "timezone": "UTC", + "umask": "022" + }, + "B": { + "checkout_root": "external-release-B", + "execution_user": "qualified-alternate-or-unsupported", + "jobs": 2, + "locale": "qualification-selected-alternate", + "timezone": "qualification-selected-alternate", + "umask": "027" + } + }, "aws_lc_target_selector": true, "aws_lc_use_system": false, "cargo_config": ".cargo/config.toml", + "cargo_frozen": true, "cargo_home": "private-prepared", "cargo_locked": true, + "cargo_message_format": "json-render-diagnostics", + "download_control": "actual-cargo-build-script-required", "fresh_target": true, "network_boundary": "qualification-pending", + "normalized_inputs": [ + "compiler-locale-C", + "compiler-timezone-UTC", + "source-commit-epoch", + "remapped-paths" + ], "npm_lifecycle": "reviewed-preparation-only", "release_debug": 1, + "release_strip": "none", "rust_toolchain": "1.95.0", "sqlite_features": [ "bundled" diff --git a/scripts/release/rust_recipe.py b/scripts/release/rust_recipe.py new file mode 100644 index 0000000000..bdab2ab534 --- /dev/null +++ b/scripts/release/rust_recipe.py @@ -0,0 +1,1084 @@ +"""Closed canonical Rust recipe shared by production and private qualification. + +Selectors remain closed until independently authenticated source-owned candidate +acquisitions exist. A candidate build never grants consumer or publishing trust. +""" +import argparse +import copy +from contextlib import ExitStack +import importlib.util +import hashlib +import logging +import os +import re +import selectors +import shutil +import shlex +import signal +import stat +import subprocess +import sys +import time +import tomllib +from pathlib import Path + +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from common import deterministic_json_bytes, load_json_bytes, read_json_file, sha256_bytes +import preflight +import prepare + +RELEASE = Path(__file__).resolve().parent +ROOT = RELEASE.parents[1] +MAX_CAPTURE = 134217728 +PROFILES = preflight.ambient_profiles() +CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" +CARGO_REGISTRY_CACHE_NAME = "index.crates.io-1949cf8c6b5b557f" +MAX_TOOL_FILE = 536870912 +MAX_TOOL_TREE = 8589934592 +MAX_TOOL_MEMBERS = 250000 +EXECUTING_HELPERS = ("common.py", "preflight.py", "prepare.py", "rust_recipe.py", "inspect-native.py", "validate-fastly.py", + "qualification/boundaries.py", "qualification/qualify.py", "qualification/socket_probe.py") +EXECUTING_HELPER_HASHES = {name: sha256_bytes(prepare._regular_bytes(RELEASE / name, 33554432)) for name in EXECUTING_HELPERS} + + +def ambient_profile(role): + """Return a copy of reviewed role policy; callers cannot choose its values.""" + if role not in PROFILES: + raise ValueError("unknown build role") + return copy.deepcopy(PROFILES[role]) + + +def artifact_selection(name): + """Select only the fixed cohort's default package/bin/target/features.""" + for artifact in preflight.fixed_artifacts(): + if artifact["name"] == name: + return artifact + raise ValueError("unknown fixed release asset") + + +def parse_request(arguments=None): + """Parse the seven closed fields without executable/config/profile inputs.""" + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + parser.add_argument("--asset", required=True) + parser.add_argument("--role", required=True, choices=("A", "B")) + parser.add_argument("--source-commit", required=True) + for name in ("js-dir", "js-inventory", "materials", "output"): + parser.add_argument("--" + name, required=True, type=Path) + args = parser.parse_args(arguments) + artifact_selection(args.asset) + preflight._commit_id(args.source_commit) + for name in ("js_dir", "js_inventory", "materials", "output"): + path = getattr(args, name) + if not path.is_absolute() or ".." in path.parts: + raise ValueError("absolute normalized request paths required") + return args + + +def fresh_root(path): + """Create private generated state only at a fresh real absolute pathname.""" + path = Path(path) + if any(character.isspace() or ord(character) < 32 or character in ",:" for character in str(path)): + raise ValueError("recipe path cannot inject compiler/mount arguments") + if not path.is_absolute() or os.path.lexists(path) or path.parent.resolve(strict=True) != path.parent: + raise ValueError("workspace/output root must be fresh and absolute") + path.mkdir(mode=0o700) + path.chmod(0o700) + return path + + +def tree_digest(root): + """Measure bounded regular immutable material trees, rejecting escaped links.""" + root = Path(root).resolve(strict=True) + if not root.is_dir(): + raise ValueError("material tree must be a directory") + records, total = [], 0 + for path in sorted(root.rglob("*")): + information = path.lstat() + name = str(path.relative_to(root)) + if len(records) >= 100000 or stat.S_ISLNK(information.st_mode): + raise ValueError("material tree links/member bound forbidden") + if stat.S_ISREG(information.st_mode): + data = prepare._regular_bytes(path, prepare.MAX_ARCHIVE) + total += len(data) + if total > 2147483648: + raise ValueError("material tree byte bound exceeded") + records.append({"path": name, "mode": stat.S_IMODE(information.st_mode), "sha256": sha256_bytes(data)}) + elif stat.S_ISDIR(information.st_mode): + records.append({"path": name, "mode": stat.S_IMODE(information.st_mode), "directory": True}) + else: + raise ValueError("unsupported material tree file") + return sha256_bytes(deterministic_json_bytes(records)) + + + +def tool_file_sha256(path): + """Stream an authenticated regular tool file through one held descriptor.""" + path = Path(path) + identity = lambda value: (value.st_dev, value.st_ino, value.st_mode, value.st_nlink, value.st_size, value.st_mtime_ns, value.st_ctime_ns) + with os.fdopen(os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW), "rb") as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_TOOL_FILE or identity(before) != identity(path.lstat()): + raise ValueError("bounded regular authenticated tool file required") + digest, total = hashlib.sha256(), 0 + deadline = time.monotonic() + 120 + while chunk := stream.read(1048576): + total += len(chunk) + if total > MAX_TOOL_FILE or time.monotonic() > deadline: + raise ValueError("tool file hash byte/time bound exceeded") + digest.update(chunk) + if total != before.st_size or identity(os.fstat(stream.fileno())) != identity(before) or identity(path.lstat()) != identity(before): + raise ValueError("tool file changed during held-descriptor measurement") + return digest.hexdigest() + + +def _resolve_tool_link(path): + current, pending, links, components = Path("/"), list(Path(path).parts[1:]), 0, 0 + while pending: + components += 1 + if components > 8192: + raise ValueError("tool link resolution component bound exceeded") + part = pending.pop(0) + if part in {"", "."}: + continue + if part == "..": + current = current.parent + continue + candidate = current / part + if candidate.is_symlink(): + links += 1 + if links > 64: + raise ValueError("tool link cycle/resolution bound exceeded") + raw = os.readlink(candidate) + if len(os.fsencode(raw)) > 4096: + raise ValueError("tool link target bound exceeded") + target = Path(raw) + if target.is_absolute(): + current = Path("/") + parts = list(target.parts[1:]) + else: + parts = list(target.parts) + pending = parts + pending + else: + candidate.lstat() + current = candidate + return current + + +def tool_tree_digest(root, *, approved_roots): + """Measure tool/SDK aliases without weakening source/vendor material policy. + + Preserve every raw symlink and require bounded final resolution within an + explicitly authenticated immutable root. Hash regular files by streaming; + never delete SDK aliases or rewrite vendor distribution contents. + """ + original = Path(root) + roots = [Path(path).resolve(strict=True) for path in approved_roots] + root = original.resolve(strict=True) + if (not roots or len(roots) > 32 or len(roots) != len(set(roots)) or Path("/") in roots + or root not in roots or original.is_symlink() or not root.is_dir()): + raise ValueError("explicit authenticated real tool roots required") + records, total = [], 0 + deadline = time.monotonic() + 1200 + for path in sorted(root.rglob("*")): + if len(records) >= MAX_TOOL_MEMBERS or time.monotonic() > deadline: + raise ValueError("tool tree member/time bound exceeded") + information = path.lstat() + record = {"path": str(path.relative_to(root)), "mode": stat.S_IMODE(information.st_mode)} + if stat.S_ISLNK(information.st_mode): + raw = os.readlink(path) + resolved = _resolve_tool_link(path) + approved = [parent for parent in roots if resolved.is_relative_to(parent)] + if not approved: + raise ValueError("tool link escapes authenticated immutable roots") + if path.lstat() != information or os.readlink(path) != raw: + raise ValueError("tool link changed during measurement") + parent = max(approved, key=lambda value: len(value.parts)) + record.update(link=raw, resolved_root=roots.index(parent), resolved_path=str(resolved.relative_to(parent))) + elif stat.S_ISREG(information.st_mode): + total += information.st_size + if total > MAX_TOOL_TREE: + raise ValueError("tool tree byte bound exceeded") + record["sha256"] = tool_file_sha256(path) + elif stat.S_ISDIR(information.st_mode): + record["directory"] = True + else: + raise ValueError("unsupported authenticated tool tree file") + records.append(record) + return sha256_bytes(deterministic_json_bytes(records)) + + +def check_tree(root, expected): + """Reject mutation against a previously measured or authenticated tree.""" + if not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected) or tree_digest(root) != expected: + raise ValueError("immutable material tree changed") + + +class RecipeProcessError(ValueError): + """Retain bounded process results for expected negative fixture evidence.""" + + def __init__(self, code, stdout, stderr): + super().__init__(f"recipe process failed with exit code {code}") + self.code, self.stdout, self.stderr = code, stdout, stderr + + +def capture(argv, environment, cwd, log, *, timeout=7200, maximum=MAX_CAPTURE, umask=0o022, boundary=None): + """Drain independent bounded stdout/stderr; close/reap and clean OS boundary. + + Fresh log descriptors open before descendants start. Compiler hooks cannot + redirect parent writes with symlinks. Cargo stdout remains unchanged JSON. + """ + result = {"stdout": bytearray(), "stderr": bytearray()} + with ExitStack() as stack: + logs = {name: stack.enter_context(Path(str(log) + "." + name).open("xb")) for name in result} + process = subprocess.Popen(list(map(str, argv)), env=environment, cwd=cwd, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + close_fds=True, start_new_session=True, umask=umask) + deadline = time.monotonic() + timeout + poller = selectors.DefaultSelector() + for name in result: + poller.register(getattr(process, name), selectors.EVENT_READ, name) + try: + while poller.get_map(): + if time.monotonic() >= deadline: + raise ValueError("recipe process timeout") + for key, _ in poller.select(min(0.25, deadline - time.monotonic())): + chunk = os.read(key.fileobj.fileno(), 65536) + if not chunk: + poller.unregister(key.fileobj) + else: + result[key.data].extend(chunk) + if len(result[key.data]) > maximum: + raise ValueError("recipe capture bound exceeded") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ValueError("recipe process timeout") + code = process.wait(timeout=remaining) + finally: + poller.close() + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + for name, value in result.items(): + getattr(process, name).close() + logs[name].write(value) + if boundary is not None and hasattr(boundary, "cleanup"): + boundary.cleanup(list(map(str, argv))) + if code: + raise RecipeProcessError(code, bytes(result["stdout"]), bytes(result["stderr"])) + return bytes(result["stdout"]), bytes(result["stderr"]) + + +def cargo_command(cargo, artifact, target, configuration): + """Construct the sole reviewed compiler invocation from source selection.""" + if artifact != artifact_selection(artifact["name"]): + raise ValueError("unreviewed artifact selection") + return [str(cargo), "build", "--package", artifact["package"], "--bin", artifact["bin"], + "--target", artifact["target"], "--release", "--frozen", + "--message-format=json-render-diagnostics", "--target-dir", str(target), + "--config", str(configuration)] + + +def final_cargo_artifact(stdout, artifact, target_root, *, source_root=None, reader=None): + """Join exactly one final Cargo event to the source-owned expected output.""" + root = Path(target_root).resolve(strict=True) + name = artifact["bin"] + (".wasm" if artifact["kind"] == "wasm-core" else "") + expected = root / artifact["target"] / "release" / name + events = [] + for line in stdout.splitlines(): + event = load_json_bytes(line, max_bytes=1048576) + if not isinstance(event, dict): + raise ValueError("Cargo JSON event must be an object") + if event.get("reason") != "compiler-artifact" or event.get("executable") is None: + continue + target = event.get("target", {}) + package = event.get("package_id", "") + if (target.get("name") == artifact["bin"] and target.get("kind") == ["bin"] + and isinstance(package, str) and re.search(r"(?:/|#)" + re.escape(artifact["package"]) + r"(?:#|@)", package)): + events.append(event) + if len(events) != 1: + raise ValueError("exactly one selected final Cargo executable required") + event = events[0] + profile = event.get("profile", {}) + if (event.get("features") != artifact["features"] or profile.get("test") is not False + or profile.get("opt_level") != "3" or type(profile.get("debuginfo")) is not int or profile.get("debuginfo") != 1): + raise ValueError("final Cargo features/release debug profile mismatch") + actual = Path(event["executable"]) + if actual != expected: + raise ValueError("final Cargo output is escaped, unrelated or absent") + if source_root is not None: + crate = Path(source_root) / "crates" / artifact["package"] + if event.get("manifest_path") != str(crate / "Cargo.toml") or not event["package_id"].startswith("path+file://" + str(crate) + "#"): + raise ValueError("final Cargo output package/source identity mismatch") + if reader is None: + if actual.is_symlink() or not actual.is_file() or actual.resolve(strict=True) != expected: + raise ValueError("final Cargo output is escaped, unrelated or absent") + data = prepare._regular_bytes(actual, 536870912) + else: + data = reader(actual) + if not isinstance(data, bytes) or not data or len(data) > 536870912: + raise ValueError("final Cargo output byte observation missing or oversized") + return actual + + +def _material_path(root, name): + if not isinstance(name, str) or not re.fullmatch(r"[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*", name) or any(part in {".", ".."} for part in name.split("/")): + raise ValueError("unsafe Cargo material index path") + path = Path(root) / name + if path.is_symlink() or not path.resolve(strict=True).is_relative_to(Path(root).resolve(strict=True)): + raise ValueError("Cargo material index path escapes pool") + return path + + +def verify_cargo_materials(lock_bytes, index, pool): + """Authenticate original archives/Git pins joined exactly to source Cargo.lock. + + The index is closed data; it never carries executable commands/configuration. + Git workspace normalization belongs to the selected authenticated Cargo's + vendor command, not hand-edited dependency manifests. + """ + if (not isinstance(index, dict) or set(index) != {"schema_version", "registry", "git"} + or type(index["schema_version"]) is not int or index["schema_version"] != 1 + or not isinstance(index["registry"], list) or not isinstance(index["git"], list)): + raise ValueError("closed Cargo material index required") + lock = tomllib.loads(lock_bytes.decode("utf-8")) + required_registry, required_git = {}, {} + for package in lock.get("package", []): + source = package.get("source", "") + if source.startswith("registry+"): + key = (package["name"], package["version"], source) + if key in required_registry: + raise ValueError("duplicate Cargo lock registry package") + required_registry[key] = package["checksum"] + elif source.startswith("git+"): + if not re.fullmatch(r"git\+https://[^#]+#[a-f0-9]{40}", source): + raise ValueError("unsupported locked Git source") + required_git[source] = source.rsplit("#", 1)[1] + elif source: + raise ValueError("unsupported locked Cargo source") + archives, registry_seen, git_seen = {}, set(), set() + for record in index["registry"]: + if not isinstance(record, dict) or set(record) != {"name", "version", "source", "checksum", "archive"}: + raise ValueError("closed Cargo registry index record required") + key = (record["name"], record["version"], record["source"]) + if key in registry_seen or key not in required_registry or record["checksum"] != required_registry[key]: + raise ValueError("Cargo material index registry mismatch") + registry_seen.add(key) + path = _material_path(pool, record["archive"]) + checked = prepare.verify_archive(path, record["checksum"], algorithm="sha256", expected_root=record["name"] + "-" + record["version"]) + archives[key] = checked + for record in index["git"]: + if not isinstance(record, dict) or set(record) != {"source", "commit", "tree", "object_store"}: + raise ValueError("closed Cargo Git index record required") + source = record["source"] + if source in git_seen or source not in required_git or record["commit"] != required_git[source]: + raise ValueError("Cargo material index Git revision mismatch") + git_seen.add(source) + if not isinstance(record["tree"], str) or not re.fullmatch(r"[a-f0-9]{40}", record["tree"]): + raise ValueError("Cargo Git tree must have authenticated full identity") + prepare.verify_git_revision(_material_path(pool, record["object_store"]), record["commit"], record["tree"]) + if registry_seen != set(required_registry) or git_seen != set(required_git): + raise ValueError("Cargo material index missing/extra locked packages") + return archives + + + +def prepare_cargo_home(seed, home, archives, index, pool): + """Populate fresh private Cargo state from original verified materials. + + Seed only authenticated registry index metadata and pinned bare Git objects. + Never trust restored registry source/extraction/cache trees. Pinned Cargo + expands original archives and normalizes Git workspace inheritance itself. + """ + seed, home = Path(seed), Path(home) + if list(home.iterdir()) or {path.name for path in seed.iterdir()} - {"registry", "git"}: + raise ValueError("fresh Cargo home and closed metadata seed required") + registry = seed / "registry" + if registry.exists(): + if {path.name for path in registry.iterdir()} != {"index"}: + raise ValueError("Cargo seed restored registry sources/cache are forbidden") + namespaces = list((registry / "index").iterdir()) + if len(namespaces) != 1 or namespaces[0].name != CARGO_REGISTRY_CACHE_NAME or not namespaces[0].is_dir(): + raise ValueError("source-owned default Cargo registry namespace required") + shutil.copytree(registry / "index", home / "registry/index") + elif archives: + raise ValueError("authenticated Cargo registry index metadata required") + cache = home / "registry/cache" / CARGO_REGISTRY_CACHE_NAME + if archives: + cache.mkdir(parents=True) + for (name, version, source), checked in archives.items(): + if source != CARGO_REGISTRY_SOURCE or not re.fullmatch(r"[a-zA-Z0-9_-]+", name) or not re.fullmatch(r"[0-9][a-zA-Z0-9.+-]*", version): + raise ValueError("unselected Cargo registry material identity") + data = prepare._authenticated_bytes(checked.path, checked.integrity, "sha256", prepare.MAX_ARCHIVE) + (cache / (name + "-" + version + ".crate")).write_bytes(data) + git = seed / "git" + if git.exists(): + if {path.name for path in git.iterdir()} != {"db"}: + raise ValueError("Cargo seed restored Git checkouts are forbidden") + databases = list((git / "db").iterdir()) + if len(databases) != len(index["git"]): + raise ValueError("Cargo seed Git object-store membership mismatch") + matched = set() + for database in databases: + matches = [] + for record in index["git"]: + try: + prepare.verify_git_revision(database, record["commit"], record["tree"]) + matches.append(record["source"]) + except ValueError: + continue + if len(matches) != 1 or matches[0] in matched: + raise ValueError("Cargo seed Git bytes do not join the authenticated original revision") + matched.add(matches[0]) + shutil.copytree(git / "db", home / "git/db") + elif index["git"]: + raise ValueError("authenticated pinned Cargo Git object metadata required") + + +def validate_pair_observations(a, b, qualification): + """Reject dishonest/missing ambient variation and normalized compiler claims.""" + fields = {"role", "checkout_root", "umask", "jobs", "locale", "timezone", "start_time_ns", + "execution_user", "user_variation", "normalized"} + for observation, role in ((a, "A"), (b, "B")): + if not isinstance(observation, dict) or set(observation) != fields or observation["role"] != role: + raise ValueError("missing role-specific ambient observations") + selected = ambient_profile(role) + for field in ("umask", "jobs"): + if observation[field] != selected[field] or type(observation[field]) is not type(selected[field]): + raise ValueError(f"actual {field} observation differs from selected role") + if type(observation["start_time_ns"]) is not int or observation["start_time_ns"] < 0: + raise ValueError("actual start time observation required") + if not isinstance(observation["checkout_root"], str) or not Path(observation["checkout_root"]).is_absolute(): + raise ValueError("actual external checkout root observation required") + if a["checkout_root"] == b["checkout_root"] or a["start_time_ns"] == b["start_time_ns"]: + raise ValueError("selected root/start-time variation missing") + if a["locale"] != "C" or b["locale"] != qualification["locale"] or a["locale"] == b["locale"]: + raise ValueError("selected locale variation missing") + if a["timezone"] != "UTC" or b["timezone"] != qualification["timezone"] or a["timezone"] == b["timezone"]: + raise ValueError("selected timezone variation missing") + if a["normalized"] != b["normalized"] or a["normalized"].get("locale") != "C" or a["normalized"].get("timezone") != "UTC" or a["normalized"].get("source_root") != "/source": + raise ValueError("normalized values must be identical and cannot be reported varied") + if qualification["user_variation"]: + if a["execution_user"] == b["execution_user"] or a["user_variation"] != "passed" or b["user_variation"] != "passed": + raise ValueError("qualified execution user variation missing") + elif a["user_variation"] != "unsupported" or b["user_variation"] != "unsupported" or a["execution_user"] != b["execution_user"]: + raise ValueError("unsupported user variation must be reported honestly") + + + +# Empty until vendor acquisitions, external roots and execution identities have +# independent review. This source table is the authority for candidate staging; +# downloaded JSON and version output can never authenticate their own tools. +CANDIDATE_ACQUISITIONS = {} + + + +def executing_source_context(commit): + """Bind independent source approval to the actual executing checkout/helpers. + + No acquisition policy embeds its own future Git commit. Compare immutable + requested objects to executing ROOT, and also to helper bytes captured when + this module loaded, so later edits cannot retitle already loaded code. + """ + preflight._commit_id(commit) + if Path(__file__).resolve() != ROOT / "scripts/release/rust_recipe.py" or RELEASE != ROOT / "scripts/release": + raise ValueError("executing helper checkout identity mismatch") + for module in (preflight, prepare, sys.modules["common"]): + if Path(module.__file__).resolve().parent != RELEASE: + raise ValueError("executing helper import comes from an alternate checkout") + try: + snapshot = preflight.source_snapshot(ROOT, commit, {}) + except (OSError, ValueError) as error: + raise ValueError("executing source commit/helper snapshot mismatch") from error + for name, digest in EXECUTING_HELPER_HASHES.items(): + record = snapshot["files"].get("scripts/release/" + name) + if record is None or record["sha256"] != digest: + raise ValueError("executing helper bytes changed after import or are untracked") + return {"source": str(ROOT), "source_commit": commit, "snapshot": snapshot} + + +def candidate_selection(asset, commit): + """Authenticate private candidate selections without requiring future results. + + Source-owned acquisition pins precede Task 8 measurement. Results, recipe + qualification and signing permissions cannot bootstrap these pins. Missing + pins refuse before workspace creation. There is deliberately no CLI bypass. + """ + artifact = artifact_selection(asset) + preflight._commit_id(commit) + environment_id = ("macos15-aarch64" if artifact["target"] == "aarch64-apple-darwin" + else "macos15-x86_64" if artifact["target"] == "x86_64-apple-darwin" + else "gnu-builder-x86_64") + selected = CANDIDATE_ACQUISITIONS.get(environment_id) + if selected is None: + raise ValueError("authenticated candidate acquisition pins pending independent review") + required = {"environment_id", "tools", "readonly", "trees", "tool_trees", + "archive_pool", "cargo_seed", "rustup", "sdk", "variation", "image", "runtime"} + if not isinstance(selected, dict) or set(selected) != required or selected["environment_id"] != environment_id: + raise ValueError("candidate acquisition policy must not select source/context") + selected = copy.deepcopy(selected) + required_tools = {"python", "git", "cargo", "rustc", "cc", "cxx", "linker", "ar", "cmake", "shell"} + if environment_id == "gnu-builder-x86_64": + required_tools.add("docker") + if artifact["kind"] == "wasm-core": + required_tools.update({"wasmtime", "viceroy"}) + if set(selected["tools"]) != required_tools: + raise ValueError("candidate acquisition tool closure incomplete") + for name, tool in selected["tools"].items(): + if not isinstance(tool, dict) or set(tool) != {"path", "sha256", "acquisition_sha256"}: + raise ValueError("candidate acquisition tool binding incomplete") + path = Path(tool["path"]) + if not path.is_absolute() or path.is_symlink() or not isinstance(tool["acquisition_sha256"], str) or not re.fullmatch(r"[a-f0-9]{64}", tool["acquisition_sha256"]): + raise ValueError("candidate acquisition authority required") + if tool_file_sha256(path) != tool["sha256"]: + raise ValueError("candidate acquired executable digest mismatch") + if selected["tools"]["git"]["path"] != "/usr/bin/git": + raise ValueError("source inspection must bind the actual trusted Git executable") + if Path(selected["tools"]["python"]["path"]).resolve() != Path(sys.executable).resolve(): + raise ValueError("candidate interpreter must be the actual independently authenticated bootstrap") + for path, digest in selected["trees"].items(): + check_tree(path, digest) + for key in ("archive_pool", "cargo_seed"): + if selected[key] not in selected["trees"]: + raise ValueError("candidate immutable original material tree missing") + tool_roots = list(selected["tool_trees"]) + for key in ("rustup", "sdk"): + if selected[key] not in selected["tool_trees"]: + raise ValueError("candidate authenticated tool/SDK tree missing") + for path, digest in selected["tool_trees"].items(): + if tool_tree_digest(path, approved_roots=tool_roots) != digest: + raise ValueError("candidate authenticated tool/SDK tree changed") + context = executing_source_context(commit) + selected.update(source=context["source"], source_commit=context["source_commit"]) + return selected + + +def vendor_configuration(lock_bytes, vendor): + """Generate reviewed exact source replacement, discarding Cargo's suggestions.""" + lock = tomllib.loads(lock_bytes.decode()) + configuration = {"source": {"release-vendor": {"directory": str(vendor)}}, "net": {"offline": True}} + git = sorted({package["source"] for package in lock["package"] if package.get("source", "").startswith("git+")}) + registries = {package["source"] for package in lock["package"] if package.get("source", "").startswith("registry+")} + if registries - {CARGO_REGISTRY_SOURCE}: + raise ValueError("unselected Cargo registry source") + if registries: + configuration["source"]["crates-io"] = {"replace-with": "release-vendor"} + for index, source in enumerate(git): + match = re.fullmatch(r"git\+(https://[^?#]+)\?rev=([a-f0-9]{40})#([a-f0-9]{40})", source) + if not match or match[2] != match[3]: + raise ValueError("unselected Cargo Git URL/revision") + configuration["source"][f"release-git-{index}"] = {"git": match[1], "rev": match[2], "replace-with": "release-vendor"} + # JSON strings are also valid quoted TOML strings for these selected paths. + quote = lambda value: deterministic_json_bytes(value).decode().strip() + lines = ["[net]", "offline = true"] + for name, record in sorted(configuration["source"].items()): + lines.extend(["", "[source." + quote(name) + "]"]) + lines.extend(key + " = " + quote(value) for key, value in sorted(record.items())) + data = ("\n".join(lines) + "\n").encode() + if tomllib.loads(data.decode()) != configuration: + raise ValueError("source-owned Cargo configuration encoding mismatch") + return data + + +def compare_final_bytes(a, b, artifact): + """Require full Fastly equality; retain nonblocking native mismatch measures.""" + left = prepare._regular_bytes(a, 536870912) + right = prepare._regular_bytes(b, 536870912) + equal = left == right + blocking = artifact["kind"] == "wasm-core" + if blocking and not equal: + raise ValueError("complete final Fastly A/B bytes differ") + return {"equal": equal, "blocking": blocking, "A_sha256": sha256_bytes(left), + "B_sha256": sha256_bytes(right), "A_size": len(left), "B_size": len(right)} + + + +def snapshot_final(selected, protected_root, name, *, data=None): + """Snapshot exact Cargo bytes into a directory denied to compile descendants. + + The caller creates and protects this directory in the compile boundary + before launching any build. Inspection/runtime gates use this stable copy, + so detached compiler children cannot race a mutable target after evidence. + """ + if not re.fullmatch(r"[a-zA-Z0-9_.-]+", name): + raise ValueError("invalid fixed final executable name") + path = Path(protected_root) / name + if os.path.lexists(path): + raise ValueError("accepted final executable must be fresh") + if data is None: + data = prepare._regular_bytes(selected, 536870912) + if not isinstance(data, bytes) or not data or len(data) > 536870912: + raise ValueError("bounded selected executable snapshot required") + with path.open("xb") as output: + output.write(data) + path.chmod(0o755 if not name.endswith(".wasm") else 0o644) + if sha256_bytes(prepare._regular_bytes(path, 536870912)) != sha256_bytes(data): + raise ValueError("accepted final bytes changed during snapshot") + return path + + +def _load_gate(name): + spec = importlib.util.spec_from_file_location(name.replace("-", "_"), RELEASE / (name + ".py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _compile_environment(selection, workspace, artifact, request, snapshot, jobs): + source, vendor, target = workspace / "source", workspace / "vendor", workspace / "target" + commit, epoch = request.source_commit, snapshot["source_date_epoch"] + js_dir, inventory = workspace / "js", workspace / "js-inventory.json" + tools = {name: record["path"] for name, record in selection["tools"].items()} + paths = {"home": str(workspace / "home"), "cargo_home": str(workspace / "cargo-home"), + "rustup_home": selection["rustup"], "tmpdir": str(workspace / "tmp"), + "tool_path": sorted({str(Path(path).parent) for path in tools.values()}), "sysroot": selection["sdk"]} + environment = preflight.build_environment({}, commit, epoch, artifact["target"], paths) + environment.update({"RUSTC": tools["rustc"], "CC": tools["cc"], "CXX": tools["cxx"], "AR": tools["ar"], + "CMAKE": tools["cmake"], "CARGO_BUILD_JOBS": str(jobs), "CARGO_PROFILE_RELEASE_DEBUG": "1", + "CARGO_PROFILE_RELEASE_STRIP": "none", "TSJS_PREBUILT_DIR": str(js_dir), + "TSJS_PREBUILT_INVENTORY": str(inventory)}) + remaps = ((source, "/source"), (vendor, "/vendor"), (target, "/target"), + (Path(selection["sdk"]), "/sdk"), (Path(selection["rustup"]), "/rust-toolchain"), + (workspace, "/build")) + host = artifact["target"] if artifact["target"].endswith("apple-darwin") else "x86_64-unknown-linux-gnu" + environment["CARGO_TARGET_" + host.replace("-", "_").upper() + "_LINKER"] = tools["linker"] + environment["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join("--remap-path-prefix=" + str(path) + "=" + normalized for path, normalized in remaps) + flags = " ".join("-ffile-prefix-map=" + str(path) + "=" + normalized + " -fdebug-prefix-map=" + str(path) + "=" + normalized for path, normalized in remaps) + environment["CFLAGS"] = flags + environment["CXXFLAGS"] = flags + return environment, tools + + +def native_runtime_identity(output, identity): + """Parse actual selected GNU OS/architecture and positive CLI help output.""" + expected = {"ubi9-x86_64": ("rhel", r"9(?:\.[0-9]+)*"), + "ubuntu22.04-x86_64": ("ubuntu", r"22\.04(?:\.[0-9]+)*"), + "debian12-x86_64": ("debian", r"12(?:\.[0-9]+)*")} + if identity not in expected or len(output) > 65536: + raise ValueError("unknown selected runtime identity") + try: + release, rest = output.decode("utf-8", errors="strict").split("\nTS_RELEASE_ARCH\n") + architecture, help_output = rest.split("\nTS_RELEASE_HELP\n") + except (ValueError, UnicodeError) as error: + raise ValueError("missing actual runtime OS/architecture evidence") from error + fields = {} + for line in release.splitlines(): + if not line or line.startswith("#"): + continue + key, separator, value = line.partition("=") + if not separator or not re.fullmatch(r"[A-Z_]+", key) or key in fields: + raise ValueError("invalid runtime OS identity fields") + values = shlex.split(value) + if len(values) != 1: + raise ValueError("invalid runtime OS identity value") + fields[key] = values[0] + operating_system, version = expected[identity] + if fields.get("ID") != operating_system or not re.fullmatch(version, fields.get("VERSION_ID", "")): + raise ValueError("actual runtime OS/version differs from selected image slot") + if architecture != "x86_64" or "Usage:" not in help_output: + raise ValueError("actual runtime architecture/CLI startup mismatch") + return {"os_id": operating_system, "os_version": fields["VERSION_ID"], "architecture": architecture, + "help_stdout_sha256": sha256_bytes(help_output.encode())} + + +def _native_startup(final, artifact, selection, workspace, environment): + """Run exact CLI outside checkout on selected real macOS or clean OCI images.""" + runtime = workspace / "native-runtime" + runtime.mkdir(mode=0o755) + executable = runtime / "ts" + shutil.copyfile(final, executable) + executable.chmod(0o755) + before = sha256_bytes(prepare._regular_bytes(executable, 536870912)) + observations = [] + if artifact["target"].endswith("apple-darwin"): + import platform + if sys.platform != "darwin" or platform.mac_ver()[0].split(".")[0] != "15": + raise ValueError("actual macOS15 native startup required; newer local macOS is unqualified") + architecture = "aarch64" if platform.machine() == "arm64" else platform.machine() + if architecture != artifact["target"].split("-")[0]: + raise ValueError("actual native startup architecture mismatch") + translated = subprocess.run(["/usr/sbin/sysctl", "-in", "sysctl.proc_translated"], capture_output=True, timeout=10, check=False) + if translated.stdout.strip() == b"1": + raise ValueError("translated native startup forbidden") + output, _ = capture([str(executable), "--help"], {"HOME": str(runtime), "TMPDIR": str(runtime), "LANG": "C", "LC_ALL": "C", "TZ": "UTC"}, runtime, runtime / "startup", timeout=30) + observations.append({"environment_id": selection["environment_id"], "os_version": platform.mac_ver()[0], + "architecture": architecture, "executable_sha256": before, + "stdout_sha256": sha256_bytes(output)}) + else: + expected = {"ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64"} + if not isinstance(selection["runtime"], dict) or set(selection["runtime"]) != expected: + raise ValueError("all three selected clean GNU runtime images required") + for identity, image in sorted(selection["runtime"].items()): + if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+@sha256:[a-f0-9]{64}", image): + raise ValueError("authenticated clean native OCI runtime digest required") + from boundaries import LinuxBoundary + control = workspace / ("native-control-" + identity) + control.mkdir(mode=0o755) + boundary = LinuxBoundary(control, [runtime], image) + runtime_environment = {"HOME": "/work", "TMPDIR": "/work", "LANG": "C", "LC_ALL": "C", "TZ": "UTC"} + driver = 'cat /etc/os-release && printf "\\nTS_RELEASE_ARCH\\n" && uname -m && printf "TS_RELEASE_HELP\\n" && "$1" --help' + argv = boundary.command(["/bin/sh", "-c", driver, "release-runtime", str(executable)], + environment=runtime_environment, cwd=runtime) + output, _ = capture(argv, {"HOME": str(control), "LANG": "C", "LC_ALL": "C"}, + runtime, runtime / identity, timeout=60, maximum=65536, boundary=boundary) + details = native_runtime_identity(output, identity) + observations.append({"environment_id": identity, "image": image, "executable_sha256": before, + "stdout_sha256": sha256_bytes(output), **details}) + if sha256_bytes(prepare._regular_bytes(executable, 536870912)) != before or sha256_bytes(prepare._regular_bytes(final, 536870912)) != before: + raise ValueError("native final bytes changed during clean startup") + if any(not record["stdout_sha256"] or record["stdout_sha256"] == sha256_bytes(b"") for record in observations): + raise ValueError("native startup requires actual help output") + return {"sha256_before": before, "sha256_after": before, "outside_checkout": True, "runtime": observations} + + + +DOWNLOAD_BUILD_SCRIPT = r""" +use std::io::{Read as _, Write as _}; +fn main() { + let address = std::env::var("DOWNLOAD_PROBE_ADDRESS").expect("should receive fixture address"); + let marker = std::env::var("DOWNLOAD_PROBE_MARKER").expect("should receive fixture marker"); + let jobs = std::env::var("NUM_JOBS").expect("should receive actual Cargo concurrency").parse::().expect("should parse actual Cargo concurrency"); + let result = std::net::TcpStream::connect(address).and_then(|mut stream| { + stream.write_all(b"GET /fixture HTTP/1.0\r\nHost: example.com\r\n\r\n")?; + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + if response.ends_with(b"fixture-download-example") { Ok(()) } + else { Err(std::io::Error::other("fixture response mismatch")) } + }); + match result { + Ok(()) => { + let observation = format!("{{\"outcome\":\"allowed\",\"errno\":0,\"jobs\":{jobs}}}"); + std::fs::write(marker, observation).expect("should record fixture success"); + }, + Err(error) => { + let code = error.raw_os_error().unwrap_or(0); + let outcome = if code == 1 || code == 13 { "denied" } else { "failed" }; + let observation = format!("{{\"outcome\":\"{outcome}\",\"errno\":{code},\"jobs\":{jobs}}}"); + std::fs::write(marker, observation).expect("should record actual build-script error"); + panic!("fixture download must fail without network access"); + } + } +} +""" +DOWNLOAD_DRIVER = r""" +import os, subprocess, sys +from pathlib import Path +cargo, fixture, target, marker, phase = sys.argv[1:] +server = None +if phase == 'unconfined': + import http.server, threading + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b'fixture-download-example') + def log_message(self, *args): + pass + server = http.server.HTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() +address = '127.0.0.1:' + str(server.server_port if server else 9) +environment = dict(os.environ, DOWNLOAD_PROBE_ADDRESS=address, DOWNLOAD_PROBE_MARKER=marker) +try: + result = subprocess.run([cargo, 'build', '--release', '--frozen', '--manifest-path', fixture + '/Cargo.toml', '--target-dir', target], + env=environment, stdin=subprocess.DEVNULL, close_fds=True, timeout=600, check=False) +finally: + if server: + server.shutdown() + server.server_close() +sys.stdout.buffer.write(Path(marker).read_bytes()) +sys.exit(result.returncode) +""" + + +def prepare_download_fixture(workspace): + """Prepare an immutable dependency-free Cargo project with a real HTTP hook.""" + fixture = Path(workspace) / "download-fixture" + fixture.mkdir() + (fixture / "src").mkdir() + (fixture / "Cargo.toml").write_text('[package]\nname = "release-download-fixture"\nversion = "0.1.0"\nedition = "2024"\n[workspace]\n') + (fixture / "Cargo.lock").write_text('version = 4\n[[package]]\nname = "release-download-fixture"\nversion = "0.1.0"\n') + (fixture / "src/main.rs").write_text('fn main() {}\n') + (fixture / "build.rs").write_text(DOWNLOAD_BUILD_SCRIPT) + return fixture + + +def verify_download_fixture(boundary, workspace, tools, environment, *, execution_umask=0o022, log_root=None): + """Require actual build-script HTTP success outside and denial inside boundary. + + Each phase compiles its own fresh target. A compiler/bootstrap failure cannot + count as a download denial because the build-script marker must exist and + contain the actual network syscall's permission errno. + """ + sys.path.insert(0, str(RELEASE / "qualification")) + from boundaries import LinuxBoundary + workspace = Path(workspace) + fixture = workspace / "download-fixture" + log_root = Path(log_root) if log_root is not None else workspace + observations = {} + for phase in ("unconfined", "confined"): + target = workspace / ("download-target-" + phase) + marker = workspace / ("download-marker-" + phase + ".json") + if target.exists() or marker.exists(): + raise ValueError("actual Cargo download fixture target/marker must be fresh") + argv = [tools["python"], "-I", "-B", "-c", DOWNLOAD_DRIVER, tools["cargo"], str(fixture), str(target), str(marker), phase] + if phase == "confined": + argv = boundary.command(argv, environment=environment, cwd=fixture) + elif isinstance(boundary, LinuxBoundary): + argv = boundary._container_command(argv, confined=False, environment=environment, cwd=fixture) + failure = None + try: + measured, _ = capture(argv, environment, fixture, log_root / ("download-" + phase), timeout=660, umask=execution_umask, boundary=boundary if isinstance(boundary, LinuxBoundary) else None) + except RecipeProcessError as error: + failure, measured = error, error.stdout + if not measured: + raise ValueError("actual build-script download was not reached; missing syscall evidence") from failure + observed = load_json_bytes(measured, max_bytes=65536) + if not isinstance(observed, dict) or set(observed) != {"outcome", "errno", "jobs"} or type(observed["errno"]) is not int or type(observed["jobs"]) is not int: + raise ValueError("actual build-script download evidence invalid") + if observed["jobs"] != int(environment["CARGO_BUILD_JOBS"]): + raise ValueError("actual Cargo build-script concurrency differs from selected jobs") + if phase == "unconfined" and (failure is not None or observed["outcome"] != "allowed" or observed["errno"] != 0): + raise ValueError("actual unconfined build-script HTTP control must succeed") from failure + if phase == "confined" and (failure is None or observed["outcome"] != "denied" or observed["errno"] not in {1, 13}): + raise ValueError("actual confined build-script HTTP attempt must fail with permission denial") + observations[phase] = observed + return {"actual_build_script": True, "boundary_sha256": boundary.sha256, **observations} + + + +def container_permissions(workspace, writable, immutable): + """Permit fixed container UID access while outer host root remains private. + + These modes only make prepared bytes readable and generated state writable. + Read-only OCI overlays enforce immutability; owner chmod is never its proof. + Container-produced B files remain private to its UID and are read through an + authenticated reader in that same container context, not by host DAC bypass. + """ + workspace = Path(workspace).resolve(strict=True) + if workspace.parent.stat().st_mode & 0o077: + raise ValueError("outer host material root must remain private") + workspace.chmod(0o777) + for root in writable: + root = Path(root) + if not root.resolve(strict=True).is_relative_to(workspace) or root.is_symlink(): + raise ValueError("writable generated state must be inside private workspace") + for path in (root, *root.rglob("*")): + if path.is_symlink(): + raise ValueError("writable generated state must not contain symlinks") + path.chmod(0o777 if path.is_dir() or path.stat().st_mode & 0o111 else 0o666) + for root in immutable: + root = Path(root) + if not root.resolve(strict=True).is_relative_to(workspace) or root.is_symlink(): + raise ValueError("immutable prepared input must be inside private workspace") + members = (root, *root.rglob("*")) if root.is_dir() else (root,) + for path in members: + if path.is_symlink(): + # The only source link was already checked by tracked export; + # preserve it and never chmod through its target. + continue + path.chmod(0o755 if path.is_dir() or path.stat().st_mode & 0o111 else 0o644) + + + +def verified_shared_js_input(request): + """Select an independently verified current-run shared-JS producer binding. + + Inventory hashes/source labels prove consistency only. This private boundary + stays closed until the authenticated Task 7 producer integration or a current + run transfer-receipt verifier supplies its binding. There is no public JSON + authenticated flag, receipt-path option or candidate bypass. Implement the + authenticating integration before measuring the complete Task 8 closure. + """ + preflight._commit_id(request.source_commit) + raise ValueError("independent shared-JS producer binding pending Task 8/transfer integration") + + +def _build_recipe(request, selection): + """Execute preparation, same-boundary controls/compile and final-byte gates. + + The only official/private selectors authenticate source-owned acquisition + bindings before this entry. No JSON commands, fixture bypass, transforms, + publication authority or network-enabled compile path exist here. + """ + sys.path.insert(0, str(RELEASE / "qualification")) + from boundaries import MacBoundary, LinuxBoundary, probe_controls + from qualify import validate_socket_pair + artifact = artifact_selection(request.asset) + role = ambient_profile(request.role) + if request.source_commit != selection["source_commit"]: + raise ValueError("independently approved source selection mismatch") + # Reauthenticate every source-owned acquisition at the entry; no Boolean + # authenticated flag in an observation/descriptor can substitute for this. + authentic = candidate_selection(request.asset, request.source_commit) + if selection != authentic: + raise ValueError("candidate acquisition selection mismatch") + shared_js_binding = verified_shared_js_input(request) + for path in (request.materials, request.output): + if os.path.lexists(path): + raise ValueError("recipe materials/output must be fresh") + if request.materials.is_relative_to(Path(selection["source"]).resolve()) or request.output.is_relative_to(request.materials) or request.materials.is_relative_to(request.output): + raise ValueError("external disjoint recipe roots required") + variation = selection["variation"] + if (not isinstance(variation, dict) or set(variation) != {"locale", "timezone", "user_variation"} + or variation["locale"] in {"C", "qualification-selected-alternate"} + or variation["timezone"] in {"UTC", "qualification-selected-alternate"}): + raise ValueError("reviewed candidate alternate ambient selections required; support remains unqualified") + if variation["user_variation"] is not False: + raise ValueError("execution user variation unsupported by current fixed boundary") + work = fresh_root(request.materials) + workspace = work / role["checkout_root"] + workspace.mkdir(mode=0o700) + source = workspace / "source" + snapshot = prepare.materialize_source(selection["source"], request.source_commit, source) + for name in ("home", "tmp", "cargo-home", "target", "js", "accepted-final"): + (workspace / name).mkdir(mode=0o700) + target, vendor = workspace / "target", workspace / "vendor" + # Preparation operates only on independently authenticated original cached + # materials. Cargo performs its own workspace-inheritance normalization. + lock_path = source / "Cargo.lock" + lock_bytes = prepare._regular_bytes(lock_path, 1048576) + pool = Path(selection["archive_pool"]) + index = read_json_file(pool / "cargo-materials.json", max_bytes=1048576) + originals = verify_cargo_materials(lock_bytes, index, pool) + seed = Path(selection["cargo_seed"]) + for name in ("config", "config.toml"): + if os.path.lexists(seed / name): + raise ValueError("Cargo preparation seed configuration forbidden") + prepare_cargo_home(seed, workspace / "cargo-home", originals, index, pool) + js_inventory = prepare._regular_bytes(request.js_inventory, 1048576) + inventory = load_json_bytes(js_inventory, max_bytes=1048576) + if inventory.get("source_commit") != request.source_commit: + raise ValueError("prepared JS inventory source identity mismatch") + expected_js = sorted("tsjs-" + name + ".js" for name in prepare._expected_modules(source, request.source_commit)) + if sorted(path.name for path in request.js_dir.iterdir()) != expected_js: + raise ValueError("complete prepared JS pair required") + modules = inventory.get("modules", []) + if sorted(record.get("filename", "") for record in modules) != expected_js: + raise ValueError("prepared JS inventory module membership mismatch") + for record in modules: + data = prepare._regular_bytes(request.js_dir / record["filename"], prepare.MAX_FILE) + if record.get("size") != len(data) or record.get("sha256") != sha256_bytes(data): + raise ValueError("prepared JS inventory bytes mismatch") + (workspace / "js" / record["filename"]).write_bytes(data) + private_inventory = workspace / "js-inventory.json" + private_inventory.write_bytes(js_inventory) + environment, tools = _compile_environment(selection, workspace, artifact, request, snapshot, role["jobs"]) + capture([tools["cargo"], "vendor", "--offline", "--locked", "--versioned-dirs", str(vendor)], environment, source, workspace / "preparation", umask=int(role["umask"], 8)) + prepare.source_check(source, request.source_commit, snapshot) + if prepare._regular_bytes(lock_path, 1048576) != lock_bytes: + raise ValueError("Cargo vendor mutated locked source") + for original in originals.values(): + prepare._authenticated_bytes(original.path, original.integrity, "sha256", prepare.MAX_ARCHIVE) + configuration = workspace / "cargo-release.toml" + configuration.write_bytes(vendor_configuration(lock_bytes, vendor)) + download_fixture = prepare_download_fixture(workspace) + evidence_logs = workspace / "accepted-final" / "logs" + evidence_logs.mkdir(mode=0o755) + immutable = [source, vendor, workspace / "js", private_inventory, configuration, download_fixture, workspace / "accepted-final"] + readonly = [Path(path) for path in selection["readonly"]] + if not artifact["target"].endswith("apple-darwin"): + container_permissions(workspace, [workspace / name for name in ("home", "tmp", "cargo-home", "target")], immutable) + vendor_digest = tree_digest(vendor) + boundary = (MacBoundary(workspace, readonly, immutable=immutable) if artifact["target"].endswith("apple-darwin") + else LinuxBoundary(workspace, readonly, selection["image"], immutable=immutable, python=tools["python"], execution_umask=int(role["umask"], 8))) + controls = probe_controls(boundary, workspace, tools["python"]) + validate_socket_pair(controls["unconfined"], controls["confined"]) + (workspace / "boundary-controls.json").write_bytes(deterministic_json_bytes(controls)) + download = verify_download_fixture(boundary, workspace, tools, environment, execution_umask=int(role["umask"], 8), log_root=evidence_logs) + (workspace / "download-controls.json").write_bytes(deterministic_json_bytes(download)) + # Actual ambient observations run inside the exact compile boundary before + # compiler locale/timezone/epoch are normalized. Selected jobs and umask + # remain effective compiler inputs, and are independently observed here. + ambient = dict(environment) + ambient.update({"LANG": "C" if request.role == "A" else variation["locale"], "LC_ALL": "C" if request.role == "A" else variation["locale"], "TZ": "UTC" if request.role == "A" else variation["timezone"]}) + probe = [tools["python"], "-I", "-B", "-c", "import json,locale,os,time;locale.setlocale(locale.LC_ALL,'');time.tzset();mask=os.umask(0);os.umask(mask);print(json.dumps(dict(checkout_root=os.getcwd(),umask=format(mask,'03o'),jobs=int(os.environ['CARGO_BUILD_JOBS']),locale=os.environ['LC_ALL'],timezone=os.environ['TZ'],start_time_ns=time.time_ns(),execution_user=str(os.getuid()))))"] + observed, _ = capture(boundary.command(probe, environment=ambient, cwd=source), ambient, source, evidence_logs / "ambient", umask=int(role["umask"], 8), timeout=30, boundary=boundary) + observations = load_json_bytes(observed, max_bytes=65536) + actual_root = str(source.resolve(strict=True)) + if observations["checkout_root"] != (boundary.map_path(source) if isinstance(boundary, LinuxBoundary) else actual_root): + raise ValueError("observed boundary cwd differs from actual external checkout") + observations["checkout_root"] = actual_root + observations.update({"role": request.role, "user_variation": "unsupported", "normalized": {"locale": "C", "timezone": "UTC", "epoch": snapshot["source_date_epoch"], "source_root": "/source"}}) + if observations["umask"] != role["umask"] or observations["jobs"] != role["jobs"]: + raise ValueError("actual compiler jobs/umask differ from selected role") + argv = [*cargo_command(tools["cargo"], artifact, target, configuration), "--jobs", str(role["jobs"])] + compile_stdout, _ = capture(boundary.command(argv, environment=environment, cwd=source), environment, source, evidence_logs / "cargo", umask=int(role["umask"], 8), boundary=boundary) + prepare.source_check(source, request.source_commit, snapshot) + check_tree(vendor, vendor_digest) + if prepare._regular_bytes(private_inventory, 1048576) != js_inventory: + raise ValueError("prepared JS inventory mutated during compile") + # Container Cargo emits its mapped target path. Convert only the exact + # source-owned mount prefix, then still apply expected final path checks. + if isinstance(boundary, LinuxBoundary): + converted = [] + for line in compile_stdout.splitlines(): + event = load_json_bytes(line, max_bytes=1048576) + if isinstance(event.get("executable"), str) and event["executable"].startswith("/work/"): + event["executable"] = str(workspace) + event["executable"][len("/work"):] + converted.append(deterministic_json_bytes(event).rstrip(b"\n")) + selected_stdout = b"\n".join(converted) + else: + selected_stdout = compile_stdout + captured_final = {} + def verified_reader(path): + code = "import sys;from pathlib import Path;sys.path.insert(0,sys.argv[1]);from prepare import _regular_bytes;p=Path(sys.argv[2]);\nif p.resolve(strict=True)!=p: raise ValueError('final executable path alias');\nsys.stdout.buffer.write(_regular_bytes(p,536870912))" + command = [tools["python"], "-I", "-B", "-c", code, str(source / "scripts/release"), str(path)] + data, _ = capture(boundary.command(command, environment=environment, cwd=source), environment, + source, evidence_logs / "final-read", timeout=60, maximum=536870912, boundary=boundary) + captured_final["bytes"] = data + return data + cargo_final = final_cargo_artifact(selected_stdout, artifact, target, + source_root=Path(boundary.map_path(source)) if isinstance(boundary, LinuxBoundary) else source, + reader=verified_reader if isinstance(boundary, LinuxBoundary) else None) + final = snapshot_final(cargo_final, workspace / "accepted-final", request.asset, data=captured_final.get("bytes")) + final_before = sha256_bytes(prepare._regular_bytes(final, 536870912)) + if artifact["kind"] == "wasm-core": + runtime = workspace / "accepted-final" / "fastly-runtime" + runtime.mkdir() + inspection = _load_gate("validate-fastly").validate_final(final, source, runtime, tools, environment, workspace / "js") + else: + inspection = _load_gate("inspect-native").inspect(final, artifact["target"], forbidden_paths=(str(workspace), str(pool), str(seed), selection["source"])) + inspection["startup"] = _native_startup(final, artifact, selection, workspace, environment) + if sha256_bytes(prepare._regular_bytes(final, 536870912)) != final_before: + raise ValueError("final executable changed after inspection/runtime evidence") + if candidate_selection(request.asset, request.source_commit) != selection: + raise ValueError("authenticated source/tool/material selection changed during recipe") + evidence = {"asset": artifact, "source_commit": request.source_commit, "ambient": observations, + "effective_environment": environment, "cargo_argv": argv, "boundary_sha256": boundary.sha256, + "controls_sha256": sha256_bytes(deterministic_json_bytes(controls)), "vendor_sha256": vendor_digest, + "cargo_executable": str(cargo_final), + "cargo_stdout_sha256": sha256_bytes(compile_stdout), "js_inventory_sha256": sha256_bytes(js_inventory), + "shared_js_binding": shared_js_binding, "download_attempt": download, "inspection": inspection, "qualification": "candidate-only"} + output = fresh_root(request.output) + final_bytes = prepare._regular_bytes(final, 536870912) + if sha256_bytes(final_bytes) != final_before: + raise ValueError("final executable changed before output copy") + (output / request.asset).write_bytes(final_bytes) + (output / request.asset).chmod(0o755 if artifact["kind"] == "native-executable" else 0o644) + (output / "recipe-evidence.json").write_bytes(deterministic_json_bytes(evidence)) + return evidence + + +def qualified_selection(artifact): + """Refuse official execution before reviewed measured qualification exists.""" + artifact_selection(artifact["name"]) + tools = read_json_file(RELEASE / "release-tools.json", max_bytes=1048576) + preflight.validate_tools(tools, require_qualified=True) + raise ValueError("release qualification pending: authenticated executable/bootstrap selection required") + + +def main(): + request = parse_request() + preflight.audit_environment(os.environ) + selection = qualified_selection(artifact_selection(request.asset)) + _build_recipe(request, selection) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError) as error: + logging.error("Canonical release build refuses: %s", error) + sys.exit(1) diff --git a/scripts/release/tests/test_fastly_runtime.py b/scripts/release/tests/test_fastly_runtime.py new file mode 100644 index 0000000000..855c41c212 --- /dev/null +++ b/scripts/release/tests/test_fastly_runtime.py @@ -0,0 +1,104 @@ +"""Fastly final-byte acceptance negatives and response fixture contract.""" +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RELEASE)) +SPEC = importlib.util.spec_from_file_location("validate_fastly", RELEASE / "validate-fastly.py") +fastly = None +if SPEC and SPEC.loader and Path(SPEC.origin).exists(): + fastly = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(fastly) + + +class FastlyTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(fastly, "canonical Fastly validator is missing") + + def test_non_wasm_and_component_rejected(self): + for data in (b"hello", b"\0asm\x0d\0\x01\0", b"\0asm\x01\0\0\0"): + with self.subTest(data=data), self.assertRaisesRegex(ValueError, "Wasm|ABI"): + fastly.core_wasm(data) + + def test_health_only_is_not_positive_publisher_fixture(self): + with self.assertRaisesRegex(ValueError, "publisher"): + fastly.publisher_response(200, b"healthy", {}) + + def test_injection_and_origin_marker_required(self): + with self.assertRaisesRegex(ValueError, "injection"): + fastly.publisher_response(200, b"release-origin-example", {"content-type": "text/html"}) + + def test_wrong_abi_rejected(self): + # A valid empty module has no Fastly imports or WASI request entry. + with self.assertRaisesRegex(ValueError, "ABI"): + fastly.core_wasm(b"\0asm\x01\0\0\0") + +class RuntimeConfigTests(unittest.TestCase): + def test_source_owned_runtime_config_matches_manifest_store(self): + self.assertIsNotNone(fastly, "canonical Fastly validator is missing") + with tempfile.TemporaryDirectory() as directory: + import tomllib + destination = Path(directory) / "viceroy.toml" + fastly.runtime_config(RELEASE.parents[1], destination, 12345, "A") + generated = tomllib.loads(destination.read_text()) + manifest = tomllib.loads((RELEASE.parents[1] / "edgezero.toml").read_text()) + store = manifest["stores"]["config"]["default"] + self.assertIn(store, generated["local_server"]["config_stores"]) + +class AbiFramingTests(unittest.TestCase): + def test_core_framing_accepts_required_abi_before_maintained_validation(self): + # Framing-only bytes deliberately omit instruction/type semantics; the + # maintained validator must still reject these as a complete program. + imports = bytes([2, 15]) + b"fastly_http_req" + bytes([4]) + b"send" + bytes([0, 0]) + imports += bytes([22]) + b"wasi_snapshot_preview1" + bytes([8]) + b"fd_write" + bytes([0, 0]) + exports = bytes([1, 6]) + b"_start" + bytes([0, 0]) + data = b"\0asm\x01\0\0\0" + bytes([2, len(imports)]) + imports + bytes([7, len(exports)]) + exports + self.assertEqual(fastly.core_wasm(data)["abi"], "wasip1-fastly-default") + + def test_positive_full_publisher_fixture_extracts_script(self): + path = b"/static/tsjs=tsjs-unified.min.js?v=" + b"a" * 64 + html = b'release-origin-example' + self.assertEqual(fastly.publisher_response(200, html, {"content-type": "text/html"}), path.decode()) + +class ExactJavascriptTests(unittest.TestCase): + def test_unrelated_javascript_success_is_rejected(self): + expected = b"expected example core;\nexpected example creative" + path = "/static/tsjs=tsjs-unified.min.js?v=" + fastly.sha256_bytes(expected) + with self.assertRaisesRegex(ValueError, "JavaScript.*bytes"): + fastly.javascript_response(200, b"unrelated example", {"content-type": "application/javascript"}, path, expected) + wrong = path[:-1] + ("b" if path[-1] != "b" else "c") + with self.assertRaisesRegex(ValueError, "hash"): + fastly.javascript_response(200, expected, {"content-type": "application/javascript"}, wrong, expected) + self.assertEqual(fastly.javascript_response(200, expected, {"content-type": "application/javascript"}, path, expected), fastly.sha256_bytes(expected)) + + def test_expected_runtime_bundle_uses_exact_prepared_modules_and_separator(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "tsjs-core.js").write_bytes(b"example core\n") + (root / "tsjs-creative.js").write_bytes(b"example creative\n") + self.assertEqual(fastly.expected_javascript(RELEASE.parents[1], root), b"example core\n;\nexample creative\n") + + +class RuntimeLogBoundsTests(unittest.TestCase): + def test_live_runtime_logs_are_bounded_and_process_is_reaped(self): + import subprocess + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + process = subprocess.Popen([sys.executable, "-I", "-c", "import sys,time;sys.stdout.write('x'*100000);sys.stdout.flush();time.sleep(30)"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) + logs = None + try: + logs = fastly.RuntimeLogs(process, root / "viceroy", maximum=1024) + self.assertTrue(logs.failure.wait(3), "live log bound should trigger") + with self.assertRaisesRegex(ValueError, "bound"): + logs.check() + finally: + if logs is not None: + logs.close() + else: + process.kill() + process.wait() + self.assertIsNotNone(process.poll()) + self.assertLessEqual((root / "viceroy.stdout").stat().st_size, 1024) diff --git a/scripts/release/tests/test_native.py b/scripts/release/tests/test_native.py new file mode 100644 index 0000000000..2ed19e45ae --- /dev/null +++ b/scripts/release/tests/test_native.py @@ -0,0 +1,153 @@ +"""Binary format negatives use bounded synthetic fixtures, never qualification.""" +import importlib.util +import struct +import sys +import tempfile +import unittest +from pathlib import Path + +RELEASE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RELEASE)) +SPEC = importlib.util.spec_from_file_location("inspect_native", RELEASE / "inspect-native.py") +native = None +if SPEC and SPEC.loader and Path(SPEC.origin).exists(): + native = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(native) + + +def elf(library="libc.so.6", version="GLIBC_2.34", rpath=None): + strings = b"\0" + library.encode() + b"\0" + version.encode() + b"\0" + version_offset = len(library) + 2 + entries = [(1, 1), (5, 0x400300), (10, len(strings)), (0x6ffffffe, 0x400400), (0x6fffffff, 1)] + if rpath is not None: + entries.insert(0, (29, len(strings))) + strings += rpath.encode() + b"\0" + entries = [(tag, len(strings) if tag == 10 else value) for tag, value in entries] + entries.append((0, 0)) + data = bytearray(0x500) + data[:16] = b"\x7fELF\x02\x01\x01" + b"\0" * 9 + struct.pack_into("Release fixturerelease-origin-example" + + +def _leb(data, offset): + result = 0 + for index in range(5): + if offset >= len(data): + raise ValueError("truncated Wasm integer") + byte = data[offset] + offset += 1 + result |= (byte & 127) << (index * 7) + if byte < 128: + if result > 0xffffffff: + raise ValueError("Wasm integer overflow") + return result, offset + raise ValueError("Wasm integer bound exceeded") + + +def _name(data, offset): + size, offset = _leb(data, offset) + if size > 4096 or offset + size > len(data): + raise ValueError("Wasm name bound exceeded") + try: + return data[offset:offset + size].decode("utf-8"), offset + size + except UnicodeError as error: + raise ValueError("invalid Wasm name") from error + + +def core_wasm(data): + """Require core module framing and default Fastly/WASI function ABI. + + This bounded framing check supplements the selected maintained Wasmtime + validator; it deliberately does not claim to validate instruction semantics. + """ + if data[:8] != b"\0asm\x01\0\0\0": + raise ValueError("final core Wasm format required; components forbidden") + offset, imported, exports, seen = 8, [], [], set() + while offset < len(data): + identifier = data[offset] + size, start = _leb(data, offset + 1) + offset = start + size + if offset > len(data) or identifier > 12 or (identifier and identifier in seen): + raise ValueError("invalid or repeated Wasm section") + seen.add(identifier) + body = data[start:offset] + if identifier not in {2, 7}: + continue + count, cursor = _leb(body, 0) + if count > 10000: + raise ValueError("Wasm ABI member bound exceeded") + for _ in range(count): + module, cursor = _name(body, cursor) + if identifier == 2: + name, cursor = _name(body, cursor) + if cursor >= len(body) or body[cursor] != 0: + raise ValueError("Fastly ABI imports must be functions") + _, cursor = _leb(body, cursor + 1) + if module != "wasi_snapshot_preview1" and not re.fullmatch(r"fastly_[a-z_]+", module): + raise ValueError("unselected Fastly ABI import module") + imported.append((module, name)) + else: + if cursor >= len(body): + raise ValueError("truncated Wasm export ABI") + kind = body[cursor] + _, cursor = _leb(body, cursor + 1) + exports.append((module, kind)) + if cursor != len(body): + raise ValueError("Wasm ABI section trailing bytes") + if (("_start", 0) not in exports or not any(module.startswith("fastly_") for module, _ in imported) + or not any(module == "wasi_snapshot_preview1" for module, _ in imported)): + raise ValueError("default Fastly WASI request ABI required") + return {"format": "core-wasm", "abi": "wasip1-fastly-default", "imports": imported, "exports": exports} + + +def publisher_response(status, body, headers): + """Require origin marker, complete HTML and injected main script path.""" + if status != 200 or ORIGIN_MARKER not in body or "text/html" not in headers.get("content-type", ""): + raise ValueError("positive publisher-origin fixture failed") + if b"" not in body.lower() or b"" not in body.lower(): + raise ValueError("publisher response must retain complete HTML") + tags = re.findall(rb"]*>", body, re.I) + selected = [tag for tag in tags if re.search(rb'\bid=["\']trustedserver-js["\']', tag)] + if len(selected) != 1: + raise ValueError("publisher main script injection missing or repeated") + match = re.search(rb'\bsrc=["\']([^"\']+)["\']', selected[0]) + if match is None: + raise ValueError("publisher script injection src missing") + path = match[1].decode("ascii") + if not re.fullmatch(r"/static/tsjs=tsjs-unified\.min\.js\?v=[a-f0-9]{64}", path): + raise ValueError("publisher script injection path mismatch") + return path + + +def _port(): + with socket.socket() as selected: + selected.bind(("127.0.0.1", 0)) + return selected.getsockname()[1] + + +def _request(port, path, *, deadline=None, logs=None): + """Bound wall time as well as response bytes, including slow headers/body.""" + deadline = min(deadline if deadline is not None else time.monotonic() + MAX_REQUEST_SECONDS, + time.monotonic() + MAX_REQUEST_SECONDS) + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2) + reply, active_socket = [], [] + def read(): + try: + connection.connect() + active_socket.append(connection.sock) + connection.request("GET", path, headers={"Host": "publisher.example.com"}) + response = connection.getresponse() + body = bytearray() + while True: + chunk = response.read1(65536) + if not chunk: + break + body.extend(chunk) + if len(body) > MAX_RESPONSE: + raise ValueError("runtime response bound exceeded") + reply.append((response.status, bytes(body), {key.lower(): value for key, value in response.getheaders()})) + except Exception as error: + reply.append(error) + finally: + connection.close() + worker = threading.Thread(target=read, daemon=True) + worker.start() + try: + while worker.is_alive(): + if logs is not None: + logs.check() + if time.monotonic() >= deadline: + raise ValueError("runtime request wall-time bound exceeded") + worker.join(timeout=min(0.05, deadline - time.monotonic())) + if not reply: + raise ValueError("runtime request did not produce an outcome") + if isinstance(reply[0], Exception): + raise reply[0] + return reply[0] + finally: + for selected in active_socket: + try: + selected.shutdown(socket.SHUT_RDWR) + except OSError: + pass + selected.close() + connection.close() + worker.join(timeout=1) + + +class RuntimeLogs: + """Drain bounded live runtime logs through parent-owned descriptors.""" + + def __init__(self, process, prefix, *, maximum=MAX_RUNTIME_LOG): + self.process, self.maximum = process, maximum + self.failure = threading.Event() + self.files, self.threads = [], [] + try: + for name in ("stdout", "stderr"): + output = Path(str(prefix) + "." + name).open("xb") + self.files.append(output) + thread = threading.Thread(target=self._drain, args=(getattr(process, name), output), daemon=True) + self.threads.append(thread) + thread.start() + except Exception: + self.close() + raise + + def _drain(self, stream, output): + total = 0 + try: + while chunk := os.read(stream.fileno(), 65536): + remaining = self.maximum - total + output.write(chunk[:remaining]) + output.flush() + total += len(chunk) + if total > self.maximum: + self.failure.set() + return + except (OSError, ValueError): + self.failure.set() + + def check(self): + if self.failure.is_set(): + raise ValueError("runtime log bound/drain failed") + + def close(self): + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait(timeout=5) + for thread in self.threads: + thread.join(timeout=5) + if thread.is_alive(): + raise ValueError("runtime log drain completion bound exceeded") + for stream in (self.process.stdout, self.process.stderr): + if stream is not None: + stream.close() + for output in self.files: + output.close() + + +def expected_javascript(source, js_dir): + """Derive exact core/always module bytes for the disabled-integration fixture.""" + registry = _regular_bytes(Path(source) / "crates/trusted-server-core/src/integrations/registry.rs", 33554432).decode() + match = re.search(r'const JS_ALWAYS: &\[&str\] = &\[([^]]*)\];', registry) + if match is None or match[1] != '"creative"': + raise ValueError("source-owned always-JS selection changed; review runtime fixture") + return b";\n".join(_regular_bytes(Path(js_dir) / ("tsjs-" + name + ".js"), MAX_RESPONSE) for name in ("core", "creative")) + + +def javascript_response(status, body, headers, script, expected): + """Require fetched exact prepared JS bytes and its actual injected version.""" + if status != 200 or not body or "javascript" not in headers.get("content-type", "") or body != expected: + raise ValueError("actual injected JavaScript bytes mismatch") + digest = sha256_bytes(expected) + if script != "/static/tsjs=tsjs-unified.min.js?v=" + digest: + raise ValueError("injected JavaScript version hash mismatch") + return digest + + +class _Origin(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.server.requests.append(self.path) + if len(self.server.requests) > 100: + self.server.failure.set() + self.send_error(507) + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(HTML))) + self.end_headers() + self.wfile.write(HTML) + + def log_message(self, format, *args): + return + + +def runtime_config(source, destination, origin_port, variant): + """Create fictional integrity-checked runtime configuration without a build.""" + config_root = Path(source) / "crates/trusted-server-integration-tests/fixtures/configs" + data = tomllib.loads(_regular_bytes(config_root / "trusted-server.integration.toml", 1048576).decode()) + data["publisher"].update(domain="publisher.example.com", cookie_domain="publisher.example.com", origin_url=f"http://127.0.0.1:{origin_port}") + data["response_headers"] = {"x-release-runtime": variant} + for integration in data["integrations"].values(): + integration["enabled"] = False + # This fixed fixture contains only integers, strings, booleans and arrays; + # Python's compact sorted JSON agrees with the selected EdgeZero canonical + # v1 rules for this subset. Floats must not silently gain another spelling. + def no_float(value): + if isinstance(value, float): + raise ValueError("runtime fixture canonical float unsupported") + if isinstance(value, dict): + for child in value.values(): + no_float(child) + elif isinstance(value, list): + for child in value: + no_float(child) + no_float(data) + canonical = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + envelope = json.dumps({"data": data, "sha256": sha256_bytes(canonical), "version": 1, + "generated_at": "2026-01-01T00:00:00Z"}, ensure_ascii=False, separators=(",", ":")) + manifest = tomllib.loads(_regular_bytes(Path(source) / "edgezero.toml", 1048576).decode()) + store = manifest["stores"]["config"]["default"] + if not isinstance(store, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+", store) or "'''" in envelope: + raise ValueError("reviewed runtime store identity changed") + template = _regular_bytes(config_root / "viceroy-template.toml", 1048576).decode() + marker = "# GENERATED_TRUSTED_SERVER_CONFIG_STORES" + if template.count(marker) != 1: + raise ValueError("runtime config marker mismatch") + block = f"[local_server.config_stores.{store}]\nformat = \"inline-toml\"\n[local_server.config_stores.{store}.contents]\n{store} = '''{envelope}'''" + destination.write_text(template.replace(marker, block)) + # Parse the exact generated TOML before invoking the runtime. + tomllib.loads(destination.read_text()) + return sha256_bytes(destination.read_bytes()) + + +def publisher_runtime(wasm, source, workspace, viceroy, environment, expected): + """Exercise publisher/injection/JS exact bytes; this alone is no acceptance. + + A local development-artifact diagnostic may call this with explicitly + unqualified tools/bytes. Official acceptance calls it only after maintained + validation and authenticated source/input/tool selection. + """ + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Origin) + server.requests, server.failure = [], threading.Event() + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + observations = [] + try: + for variant in ("A", "B"): + origin_start = len(server.requests) + configuration = workspace / f"viceroy-{variant}.toml" + config_hash = runtime_config(source, configuration, server.server_port, variant) + port = _port() + process = subprocess.Popen([str(viceroy), str(wasm), "-C", str(configuration), "--addr", f"127.0.0.1:{port}"], + env=environment, cwd=workspace, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + close_fds=True, start_new_session=True) + logs = RuntimeLogs(process, workspace / f"viceroy-{variant}") + try: + deadline = time.monotonic() + 60 + while True: + logs.check() + if process.poll() is not None: + raise ValueError("exact Wasm Viceroy exits before publisher fixture") + try: + status, html, headers = _request(port, "/release-fixture", deadline=deadline, logs=logs) + break + except (OSError, http.client.HTTPException): + if time.monotonic() >= deadline: + raise ValueError("exact Wasm publisher runtime timeout") + time.sleep(0.05) + script = publisher_response(status, html, headers) + if headers.get("x-release-runtime") != variant: + raise ValueError("runtime config cannot change independently of build") + status, javascript, script_headers = _request(port, script, deadline=deadline, logs=logs) + js_hash = javascript_response(status, javascript, script_headers, script, expected) + origin = server.requests[origin_start:] + if "/release-fixture" not in origin or server.failure.is_set(): + raise ValueError("publisher variant did not actually reach bounded origin fixture") + observations.append({"variant": variant, "publisher_status": 200, "script_status": 200, + "html_sha256": sha256_bytes(html), "javascript_sha256": js_hash, + "config_sha256": config_hash, "script_path": script, "origin_requests": origin}) + finally: + logs.close() + logs.check() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + if observations[0]["config_sha256"] == observations[1]["config_sha256"]: + raise ValueError("independent runtime config variants did not change") + return observations + + +def validate_final(wasm, source, workspace, tools, environment, js_dir): + """Accept unchanged final bytes after maintained validation and actual runtime.""" + from rust_recipe import capture + wasm = Path(wasm).resolve(strict=True) + workspace = Path(workspace).resolve(strict=True) + before = sha256_bytes(_regular_bytes(wasm, MAX_EXECUTABLE)) + abi = core_wasm(_regular_bytes(wasm, MAX_EXECUTABLE)) + if Path(source).resolve() == workspace or workspace.is_relative_to(Path(source).resolve()): + raise ValueError("runtime fixture must run outside checkout") + expected = expected_javascript(source, js_dir) + capture([tools["wasmtime"], "compile", str(wasm), "--output", str(workspace / "validated.cwasm")], + environment, workspace, workspace / "wasmtime", timeout=300) + observations = publisher_runtime(wasm, source, workspace, tools["viceroy"], environment, expected) + after = sha256_bytes(_regular_bytes(wasm, MAX_EXECUTABLE)) + if before != after: + raise ValueError("final Wasm changed during runtime acceptance") + result = {"sha256_before": before, "sha256_after": after, "abi": abi, "runtime": observations, + "outside_checkout": True, "exact_final_bytes": True, "maintained_validation": True} + (workspace / "runtime-outcome.json").write_bytes(deterministic_json_bytes(result)) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + parser.add_argument("--wasm", required=True, type=Path) + parser.parse_args() + raise ValueError("release qualification pending: authenticated Wasmtime/Viceroy selection required") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError) as error: + logging.error("Final Fastly acceptance refuses: %s", error) + sys.exit(1) From 9988a566b9f319c7305cb9f677a39c9da9dafc22 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 18:25:33 +0530 Subject: [PATCH 035/153] Correct release runtime configuration and startup gates --- scripts/release/qualification/boundaries.py | 5 +- scripts/release/qualification/qualify.py | 2 +- scripts/release/rust_recipe.py | 97 ++++++++++++++++++-- scripts/release/tests/test_fastly_runtime.py | 20 +++- scripts/release/tests/test_qualification.py | 2 +- scripts/release/tests/test_recipe.py | 82 ++++++++++++++++- scripts/release/validate-fastly.py | 3 + 7 files changed, 193 insertions(+), 18 deletions(-) diff --git a/scripts/release/qualification/boundaries.py b/scripts/release/qualification/boundaries.py index 344deb21f5..acc365f7ca 100644 --- a/scripts/release/qualification/boundaries.py +++ b/scripts/release/qualification/boundaries.py @@ -12,6 +12,7 @@ from pathlib import Path RELEASE = Path(__file__).resolve().parents[1] +DOCKER_EXECUTABLE = "/usr/bin/docker" sys.path.insert(0, str(RELEASE)) from common import deterministic_json_bytes, load_json_bytes, sha256_bytes @@ -157,9 +158,9 @@ def __init__(self, private_root, readonly, image, *, immutable=(), python=None, self.docker_config = self.private_root / "docker-config" self.docker_config.mkdir(mode=0o755) (self.docker_config / "config.json").write_bytes(b"{}\n") - self.docker_prefix = ["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", + self.docker_prefix = [DOCKER_EXECUTABLE, "--host", "unix:///var/run/docker.sock", "--config", str(self.docker_config)] - self.immutable = [] + self.immutable = [self.docker_config] for path in immutable: path = Path(path) resolved = path.resolve(strict=True) diff --git a/scripts/release/qualification/qualify.py b/scripts/release/qualification/qualify.py index efe893963a..33bf633189 100644 --- a/scripts/release/qualification/qualify.py +++ b/scripts/release/qualification/qualify.py @@ -45,7 +45,7 @@ "crates/trusted-server-cli/Cargo.toml", "crates/trusted-server-core/Cargo.toml", "crates/trusted-server-js/Cargo.toml", "crates/trusted-server-adapter-fastly/Cargo.toml", ) -RUST_CLOSURE = ("edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs", +RUST_CLOSURE = ("trusted-server.example.toml", "edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs", "scripts/release/build.sh", "scripts/release/inspect-native.py", "scripts/release/validate-fastly.py", "scripts/release/rust_recipe.py", "crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml", diff --git a/scripts/release/rust_recipe.py b/scripts/release/rust_recipe.py index bdab2ab534..d7a327b643 100644 --- a/scripts/release/rust_recipe.py +++ b/scripts/release/rust_recipe.py @@ -526,6 +526,10 @@ def candidate_selection(asset, commit): required_tools.update({"wasmtime", "viceroy"}) if set(selected["tools"]) != required_tools: raise ValueError("candidate acquisition tool closure incomplete") + if environment_id == "gnu-builder-x86_64": + from boundaries import DOCKER_EXECUTABLE + if selected["tools"]["docker"]["path"] != DOCKER_EXECUTABLE: + raise ValueError("candidate must bind the actual source-owned Docker executable") for name, tool in selected["tools"].items(): if not isinstance(tool, dict) or set(tool) != {"path", "sha256", "acquisition_sha256"}: raise ValueError("candidate acquisition tool binding incomplete") @@ -582,6 +586,13 @@ def vendor_configuration(lock_bytes, vendor): return data +def write_vendor_configuration(lock_bytes, vendor, configuration, boundary): + """Write source-owned Cargo replacement paths in the execution namespace.""" + from boundaries import LinuxBoundary + directory = boundary.map_path(vendor) if isinstance(boundary, LinuxBoundary) else str(vendor) + Path(configuration).write_bytes(vendor_configuration(lock_bytes, directory)) + + def compare_final_bytes(a, b, artifact): """Require full Fastly equality; retain nonblocking native mismatch measures.""" left = prepare._regular_bytes(a, 536870912) @@ -651,6 +662,56 @@ def _compile_environment(selection, workspace, artifact, request, snapshot, jobs return environment, tools +def native_cli_contract(source, commit): + """Bind native CLI version and embedded fictional scaffold to prepared source.""" + preflight._commit_id(commit) + source = Path(source) + workspace = tomllib.loads(prepare._regular_bytes(source / "Cargo.toml", 1048576).decode()) + package = tomllib.loads(prepare._regular_bytes(source / "crates/trusted-server-cli/Cargo.toml", 1048576).decode())["package"] + if package.get("name") != "trusted-server-cli": + raise ValueError("native CLI owning package mismatch") + version = package.get("version") + if version == {"workspace": True}: + version = workspace["workspace"]["package"]["version"] + if not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?", version): + raise ValueError("native CLI source package version invalid") + scaffold = prepare._regular_bytes(source / "trusted-server.example.toml", 1048576) + if not scaffold or not tomllib.loads(scaffold.decode()): + raise ValueError("native CLI source scaffold missing") + return {"package_version": version, "source_commit": commit, "scaffold": scaffold, + "version_stdout": ("ts " + version + " (" + commit + ")\n").encode()} + + +def native_cli_outcomes(contract, help_output, version, initialization, scaffold): + """Require actual help, full source/version binding and exact generated config.""" + if b"Usage: ts" not in help_output: + raise ValueError("native CLI help startup missing") + if version != contract["version_stdout"]: + raise ValueError("native CLI version/source binding mismatch") + if not re.fullmatch(rb"Initialized config at [^\r\n]+/trusted-server\.toml\n", initialization): + raise ValueError("native CLI config initialization outcome missing") + if scaffold != contract["scaffold"]: + raise ValueError("native CLI generated scaffold missing or differs from prepared source") + return {"help": {"exit_code": 0, "stdout_sha256": sha256_bytes(help_output)}, + "version": {"exit_code": 0, "stdout_sha256": sha256_bytes(version), + "package_version": contract["package_version"], "source_commit": contract["source_commit"]}, + "config_init": {"exit_code": 0, "stdout_sha256": sha256_bytes(initialization), + "scaffold_sha256": sha256_bytes(scaffold), "scaffold_size": len(scaffold)}} + + +def native_cli_probe(executable, runtime, contract, environment, log_root): + """Run fixed native commands outside checkout with fresh writable config state.""" + generated = runtime / "output" + generated.mkdir(mode=0o700) + configuration = generated / "trusted-server.toml" + help_output, _ = capture([str(executable), "--help"], environment, generated, log_root / "help", timeout=30, maximum=65536) + version, _ = capture([str(executable), "--version"], environment, generated, log_root / "version", timeout=30, maximum=65536) + initialized, _ = capture([str(executable), "config", "init", "--app-config", str(configuration)], + environment, generated, log_root / "config-init", timeout=30, maximum=65536) + scaffold = prepare._regular_bytes(configuration, 1048576) + return native_cli_outcomes(contract, help_output, version, initialized, scaffold) + + def native_runtime_identity(output, identity): """Parse actual selected GNU OS/architecture and positive CLI help output.""" expected = {"ubi9-x86_64": ("rhel", r"9(?:\.[0-9]+)*"), @@ -692,6 +753,7 @@ def _native_startup(final, artifact, selection, workspace, environment): executable.chmod(0o755) before = sha256_bytes(prepare._regular_bytes(executable, 536870912)) observations = [] + contract = native_cli_contract(workspace / "source", selection["source_commit"]) if artifact["target"].endswith("apple-darwin"): import platform if sys.platform != "darwin" or platform.mac_ver()[0].split(".")[0] != "15": @@ -702,10 +764,11 @@ def _native_startup(final, artifact, selection, workspace, environment): translated = subprocess.run(["/usr/sbin/sysctl", "-in", "sysctl.proc_translated"], capture_output=True, timeout=10, check=False) if translated.stdout.strip() == b"1": raise ValueError("translated native startup forbidden") - output, _ = capture([str(executable), "--help"], {"HOME": str(runtime), "TMPDIR": str(runtime), "LANG": "C", "LC_ALL": "C", "TZ": "UTC"}, runtime, runtime / "startup", timeout=30) + commands = native_cli_probe(executable, runtime, contract, + {"HOME": str(runtime), "TMPDIR": str(runtime), "LANG": "C", "LC_ALL": "C", "TZ": "UTC"}, runtime) observations.append({"environment_id": selection["environment_id"], "os_version": platform.mac_ver()[0], "architecture": architecture, "executable_sha256": before, - "stdout_sha256": sha256_bytes(output)}) + "stdout_sha256": commands["help"]["stdout_sha256"], "commands": commands}) else: expected = {"ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64"} if not isinstance(selection["runtime"], dict) or set(selection["runtime"]) != expected: @@ -716,16 +779,31 @@ def _native_startup(final, artifact, selection, workspace, environment): from boundaries import LinuxBoundary control = workspace / ("native-control-" + identity) control.mkdir(mode=0o755) + generated = control / "output" + generated.mkdir(mode=0o777) + generated.chmod(0o777) boundary = LinuxBoundary(control, [runtime], image) - runtime_environment = {"HOME": "/work", "TMPDIR": "/work", "LANG": "C", "LC_ALL": "C", "TZ": "UTC"} - driver = 'cat /etc/os-release && printf "\\nTS_RELEASE_ARCH\\n" && uname -m && printf "TS_RELEASE_HELP\\n" && "$1" --help' - argv = boundary.command(["/bin/sh", "-c", driver, "release-runtime", str(executable)], - environment=runtime_environment, cwd=runtime) + runtime_environment = {"HOME": "/work/output", "TMPDIR": "/work/output", "LANG": "C", "LC_ALL": "C", "TZ": "UTC"} + driver = ('umask 022; cat /etc/os-release && printf "\\nTS_RELEASE_ARCH\\n" && uname -m ' + '&& printf "TS_RELEASE_HELP\\n" && "$1" --help ' + '&& printf "TS_RELEASE_VERSION\\n" && "$1" --version ' + '&& printf "TS_RELEASE_INIT\\n" && "$1" config init --app-config "$2" ' + '&& printf "TS_RELEASE_SCAFFOLD\\n" && test -f "$2" && test ! -L "$2" && cat "$2"') + argv = boundary.command(["/bin/sh", "-c", driver, "release-runtime", str(executable), "/work/output/trusted-server.toml"], + environment=runtime_environment, cwd=generated) output, _ = capture(argv, {"HOME": str(control), "LANG": "C", "LC_ALL": "C"}, runtime, runtime / identity, timeout=60, maximum=65536, boundary=boundary) - details = native_runtime_identity(output, identity) + try: + help_and_identity, remaining = output.split(b"TS_RELEASE_VERSION\n") + version, remaining = remaining.split(b"TS_RELEASE_INIT\n") + initialized, scaffold = remaining.split(b"TS_RELEASE_SCAFFOLD\n") + help_output = help_and_identity.split(b"\nTS_RELEASE_HELP\n")[1] + except (ValueError, IndexError) as error: + raise ValueError("native runtime missing help/version/config-init evidence") from error + details = native_runtime_identity(help_and_identity, identity) + commands = native_cli_outcomes(contract, help_output, version, initialized, scaffold) observations.append({"environment_id": identity, "image": image, "executable_sha256": before, - "stdout_sha256": sha256_bytes(output), **details}) + "stdout_sha256": sha256_bytes(output), "commands": commands, **details}) if sha256_bytes(prepare._regular_bytes(executable, 536870912)) != before or sha256_bytes(prepare._regular_bytes(final, 536870912)) != before: raise ValueError("native final bytes changed during clean startup") if any(not record["stdout_sha256"] or record["stdout_sha256"] == sha256_bytes(b"") for record in observations): @@ -972,7 +1050,7 @@ def _build_recipe(request, selection): for original in originals.values(): prepare._authenticated_bytes(original.path, original.integrity, "sha256", prepare.MAX_ARCHIVE) configuration = workspace / "cargo-release.toml" - configuration.write_bytes(vendor_configuration(lock_bytes, vendor)) + configuration.touch() download_fixture = prepare_download_fixture(workspace) evidence_logs = workspace / "accepted-final" / "logs" evidence_logs.mkdir(mode=0o755) @@ -983,6 +1061,7 @@ def _build_recipe(request, selection): vendor_digest = tree_digest(vendor) boundary = (MacBoundary(workspace, readonly, immutable=immutable) if artifact["target"].endswith("apple-darwin") else LinuxBoundary(workspace, readonly, selection["image"], immutable=immutable, python=tools["python"], execution_umask=int(role["umask"], 8))) + write_vendor_configuration(lock_bytes, vendor, configuration, boundary) controls = probe_controls(boundary, workspace, tools["python"]) validate_socket_pair(controls["unconfined"], controls["confined"]) (workspace / "boundary-controls.json").write_bytes(deterministic_json_bytes(controls)) diff --git a/scripts/release/tests/test_fastly_runtime.py b/scripts/release/tests/test_fastly_runtime.py index 855c41c212..9ede78ba73 100644 --- a/scripts/release/tests/test_fastly_runtime.py +++ b/scripts/release/tests/test_fastly_runtime.py @@ -60,9 +60,27 @@ def test_core_framing_accepts_required_abi_before_maintained_validation(self): def test_positive_full_publisher_fixture_extracts_script(self): path = b"/static/tsjs=tsjs-unified.min.js?v=" + b"a" * 64 - html = b'release-origin-example' + injection = b'' + html = fastly.HTML.replace(b"", b"" + injection) self.assertEqual(fastly.publisher_response(200, html, {"content-type": "text/html"}), path.decode()) +class CompletePublisherTests(unittest.TestCase): + def test_truncation_of_each_origin_document_region_is_rejected(self): + injection = b'' + complete = fastly.HTML.replace(b"", b"" + injection) + damaged = (injection + b'release-origin-example', + complete.replace(b'', b''), + complete.replace(b'', b''), + complete.replace(b'', b''), + complete.replace(b'Release fixture', b''), + complete.replace(b'', b''), + complete.replace(b'', b''), + complete.replace(b'Release fixture', b'Changed example'), + complete.replace(b'', b'')) + for body in damaged: + with self.subTest(body=body), self.assertRaisesRegex(ValueError, "complete HTML|origin fixture"): + fastly.publisher_response(200, body, {"content-type": "text/html"}) + class ExactJavascriptTests(unittest.TestCase): def test_unrelated_javascript_success_is_rejected(self): expected = b"expected example core;\nexpected example creative" diff --git a/scripts/release/tests/test_qualification.py b/scripts/release/tests/test_qualification.py index 17eb3aaedf..51b07e18ea 100644 --- a/scripts/release/tests/test_qualification.py +++ b/scripts/release/tests/test_qualification.py @@ -80,7 +80,7 @@ def observation(): class RuntimeClosureTests(unittest.TestCase): def test_runtime_gate_source_inputs_change_candidate_fingerprint(self): tools = json.loads((RELEASE / "release-tools.json").read_text()) - for changed in ("edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs"): + for changed in ("trusted-server.example.toml", "edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs"): with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: root = Path(directory) for name in (*qualify.SHARED_CLOSURE, *qualify.RUST_CLOSURE): diff --git a/scripts/release/tests/test_recipe.py b/scripts/release/tests/test_recipe.py index 38db221e6f..fa98125d51 100644 --- a/scripts/release/tests/test_recipe.py +++ b/scripts/release/tests/test_recipe.py @@ -166,6 +166,29 @@ def test_private_candidate_selection_rejects_missing_authentication(self): with self.assertRaisesRegex(ValueError, "candidate acquisition"): recipe.candidate_selection("ts-x86_64-unknown-linux-gnu", "a" * 40) + def test_recipe_vendor_config_matches_actual_boundary_namespace(self): + import tomllib + sys.path.insert(0, str(RELEASE / "qualification")) + from boundaries import LinuxBoundary, MacBoundary + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + vendor = root / "vendor" + vendor.mkdir() + configuration = root / "cargo-release.toml" + configuration.touch() + linux = LinuxBoundary(root, [], "example/builder@sha256:" + "a" * 64, immutable=[vendor, configuration]) + lock = b'version=4\n[[package]]\nname="example"\nversion="1.0"\n' + recipe.write_vendor_configuration(lock, vendor, configuration, linux) + configured = tomllib.loads(configuration.read_text())["source"]["release-vendor"]["directory"] + self.assertEqual(configured, "/work/vendor") + argv = linux.command(["/selected/cargo", "build", "--config", str(configuration)]) + self.assertEqual(argv[-1], "/work/cargo-release.toml") + self.assertIn(f"type=bind,src={vendor},dst={configured},readonly", argv) + if sys.platform == "darwin": + mac = MacBoundary(root, [], immutable=[vendor, configuration]) + recipe.write_vendor_configuration(lock, vendor, configuration, mac) + self.assertEqual(tomllib.loads(configuration.read_text())["source"]["release-vendor"]["directory"], str(vendor)) + def test_generated_config_is_source_owned_not_cargo_stdout(self): lock = b'version=4\n[[package]]\nname="example"\nversion="1.0"\nsource="' + recipe.CARGO_REGISTRY_SOURCE.encode() + b'"\nchecksum="' + b'a' * 64 + b'"\n' data = recipe.vendor_configuration(lock, Path("/private/vendor")) @@ -277,6 +300,44 @@ def test_control_and_cleanup_capture_stop_live_output_overflow(self): bounded_process([sys.executable, "-I", "-c", "import sys,time;sys.stdout.write('x'*100000);sys.stdout.flush();time.sleep(30)"], {}, maximum=1024, timeout=5) +def native_fixture_source(root): + source = root / "source" + manifest = source / "crates/trusted-server-cli/Cargo.toml" + manifest.parent.mkdir(parents=True) + (source / "Cargo.toml").write_text('[workspace.package]\nversion="1.2.3"\n') + manifest.write_text('[package]\nname="trusted-server-cli"\nversion={workspace=true}\n') + scaffold = b'[publisher]\ndomain="publisher.example.com"\n' + (source / "trusted-server.example.toml").write_bytes(scaffold) + return source, scaffold + + +class NativeCommandContractTests(unittest.TestCase): + def test_source_owned_version_and_exact_embedded_scaffold_are_required(self): + with tempfile.TemporaryDirectory() as directory: + source, scaffold = native_fixture_source(Path(directory)) + contract = recipe.native_cli_contract(source, "a" * 40) + version = b"ts 1.2.3 (" + b"a" * 40 + b")\n" + help_output = b"Trusted Server CLI\nUsage: ts \n" + initialized = b"Initialized config at /work/output/trusted-server.toml\n" + result = recipe.native_cli_outcomes(contract, help_output, version, initialized, scaffold) + self.assertEqual(set(result), {"help", "version", "config_init"}) + self.assertTrue(all(command["exit_code"] == 0 for command in result.values())) + damaged = ((help_output, b"", initialized, scaffold), + (help_output, version.replace(b"a" * 40, b"b" * 40), initialized, scaffold), + (help_output, version.replace(b"1.2.3", b"1.2.4"), initialized, scaffold), + (help_output, version, b"", scaffold), + (help_output, version, initialized, b""), + (help_output, version, initialized, scaffold.replace(b"publisher", b"other"))) + for outputs in damaged: + with self.subTest(outputs=outputs), self.assertRaisesRegex(ValueError, "native.*version|native.*scaffold|native.*config"): + recipe.native_cli_outcomes(contract, *outputs) + with self.assertRaisesRegex(ValueError, "source"): + recipe.native_cli_contract(source, "unknown") + (source / "trusted-server.example.toml").unlink() + with self.assertRaises((ValueError, OSError)): + recipe.native_cli_contract(source, "a" * 40) + + class LinuxLifecycleTests(unittest.TestCase): def test_map_path_does_not_rewrite_sibling_prefix(self): sys.path.insert(0, str(RELEASE / "qualification")) @@ -316,6 +377,7 @@ def test_docker_context_is_explicit_and_empty_for_run_and_cleanup(self): self.assertEqual(argv[:5], prefix) self.assertEqual(boundary.cleanup_command(argv)[:5], prefix) self.assertEqual((root / "docker-config/config.json").read_bytes(), b"{}\n") + self.assertIn(f"type=bind,src={root / 'docker-config'},dst=/work/docker-config,readonly", argv) def test_native_container_startup_uses_same_closed_cleanup_lifecycle(self): from unittest import mock @@ -323,16 +385,19 @@ def test_native_container_startup_uses_same_closed_cleanup_lifecycle(self): root = Path(directory).resolve() final = root / "final" final.write_bytes(b"example native fixture") - selection = {"runtime": {name: "example/runtime@sha256:" + "a" * 64 for name in ("ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64")}} - outputs = [(b'ID=debian\nVERSION_ID="12"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\nUsage: ts\n', b""), - (b'ID=rhel\nVERSION_ID="9.7"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\nUsage: ts\n', b""), - (b'ID=ubuntu\nVERSION_ID="22.04"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\nUsage: ts\n', b"")] + _, scaffold = native_fixture_source(root) + selection = {"source_commit": "a" * 40, "runtime": {name: "example/runtime@sha256:" + "a" * 64 for name in ("ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64")}} + commands = b'Usage: ts\nTS_RELEASE_VERSION\nts 1.2.3 (' + b'a' * 40 + b')\nTS_RELEASE_INIT\nInitialized config at /work/output/trusted-server.toml\nTS_RELEASE_SCAFFOLD\n' + scaffold + outputs = [(b'ID=debian\nVERSION_ID="12"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\n' + commands, b""), + (b'ID=rhel\nVERSION_ID="9.7"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\n' + commands, b""), + (b'ID=ubuntu\nVERSION_ID="22.04"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\n' + commands, b"")] with mock.patch.object(recipe, "capture", side_effect=outputs) as captured: observed = recipe._native_startup(final, recipe.artifact_selection("ts-x86_64-unknown-linux-gnu"), selection, root, {}) self.assertEqual(observed["runtime"][0]["os_version"], "12") self.assertEqual(observed["runtime"][0]["architecture"], "x86_64") self.assertEqual(observed["runtime"][0]["executable_sha256"], recipe.sha256_bytes(final.read_bytes())) self.assertEqual(captured.call_count, 3) + self.assertEqual(set(observed["runtime"][0]["commands"]), {"help", "version", "config_init"}) for call in captured.call_args_list: self.assertIn("--name", call.args[0]) self.assertIn("--config", call.args[0]) @@ -414,6 +479,15 @@ def test_wrong_commit_and_self_auth_json_are_rejected(self): with self.assertRaisesRegex(ValueError, "source commit"): recipe.candidate_selection("trusted-server-fastly.wasm", {"approved": True, "commit": "a" * 40}) + def test_acquisition_cannot_attest_a_different_docker_executable(self): + from unittest import mock + names = {"python", "git", "cargo", "rustc", "cc", "cxx", "linker", "ar", "cmake", "shell", "docker"} + policy = {key: None for key in ("environment_id", "tools", "readonly", "trees", "tool_trees", "archive_pool", "cargo_seed", "rustup", "sdk", "variation", "image", "runtime")} + policy.update(environment_id="gnu-builder-x86_64", tools={name: {"path": "/example/selected-tool", "sha256": "a" * 64, "acquisition_sha256": "b" * 64} for name in names}) + with mock.patch.dict(recipe.CANDIDATE_ACQUISITIONS, {"gnu-builder-x86_64": policy}): + with self.assertRaisesRegex(ValueError, "actual.*Docker|Docker.*executable"): + recipe.candidate_selection("ts-x86_64-unknown-linux-gnu", "a" * 40) + def test_acquisition_policy_cannot_select_an_alternate_checkout(self): from unittest import mock with mock.patch.dict(recipe.CANDIDATE_ACQUISITIONS, {"gnu-builder-x86_64": {"source": "/alternate/example", "approved_commit": "a" * 40}}): diff --git a/scripts/release/validate-fastly.py b/scripts/release/validate-fastly.py index 42ad374995..800ace43bf 100755 --- a/scripts/release/validate-fastly.py +++ b/scripts/release/validate-fastly.py @@ -120,6 +120,9 @@ def publisher_response(status, body, headers): path = match[1].decode("ascii") if not re.fullmatch(r"/static/tsjs=tsjs-unified\.min\.js\?v=[a-f0-9]{64}", path): raise ValueError("publisher script injection path mismatch") + injected = re.search(re.escape(selected[0]) + rb"\s*", body, re.I) + if injected is None or body[:injected.start()] + body[injected.end():] != HTML: + raise ValueError("publisher response must preserve complete HTML origin fixture") return path From 2a2db6d3a96f9e1860aabc650bfb886a189c5031 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 18:35:28 +0530 Subject: [PATCH 036/153] Bind release boundary imports to the source-owned module --- scripts/release/rust_recipe.py | 27 ++++++++++++----- scripts/release/tests/test_recipe.py | 45 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/scripts/release/rust_recipe.py b/scripts/release/rust_recipe.py index d7a327b643..da6f7a0246 100644 --- a/scripts/release/rust_recipe.py +++ b/scripts/release/rust_recipe.py @@ -30,6 +30,26 @@ RELEASE = Path(__file__).resolve().parent ROOT = RELEASE.parents[1] +sys.path.insert(0, str(RELEASE / "qualification")) +_BOUNDARY_SOURCE = RELEASE / "qualification/boundaries.py" +_BOUNDARY_MODULE = sys.modules.get("boundaries") +if _BOUNDARY_MODULE is None: + _boundary_spec = importlib.util.spec_from_file_location("boundaries", _BOUNDARY_SOURCE) + if _boundary_spec is None or _boundary_spec.loader is None: + raise ValueError("source-owned qualification boundary module unavailable") + _BOUNDARY_MODULE = importlib.util.module_from_spec(_boundary_spec) + sys.modules["boundaries"] = _BOUNDARY_MODULE + try: + _boundary_spec.loader.exec_module(_BOUNDARY_MODULE) + except BaseException: + sys.modules.pop("boundaries", None) + raise +if Path(getattr(_BOUNDARY_MODULE, "__file__", "")).resolve() != _BOUNDARY_SOURCE: + raise ValueError("source-owned qualification boundary module mismatch") +DOCKER_EXECUTABLE = _BOUNDARY_MODULE.DOCKER_EXECUTABLE +LinuxBoundary = _BOUNDARY_MODULE.LinuxBoundary +MacBoundary = _BOUNDARY_MODULE.MacBoundary +probe_controls = _BOUNDARY_MODULE.probe_controls MAX_CAPTURE = 134217728 PROFILES = preflight.ambient_profiles() CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" @@ -527,7 +547,6 @@ def candidate_selection(asset, commit): if set(selected["tools"]) != required_tools: raise ValueError("candidate acquisition tool closure incomplete") if environment_id == "gnu-builder-x86_64": - from boundaries import DOCKER_EXECUTABLE if selected["tools"]["docker"]["path"] != DOCKER_EXECUTABLE: raise ValueError("candidate must bind the actual source-owned Docker executable") for name, tool in selected["tools"].items(): @@ -588,7 +607,6 @@ def vendor_configuration(lock_bytes, vendor): def write_vendor_configuration(lock_bytes, vendor, configuration, boundary): """Write source-owned Cargo replacement paths in the execution namespace.""" - from boundaries import LinuxBoundary directory = boundary.map_path(vendor) if isinstance(boundary, LinuxBoundary) else str(vendor) Path(configuration).write_bytes(vendor_configuration(lock_bytes, directory)) @@ -776,7 +794,6 @@ def _native_startup(final, artifact, selection, workspace, environment): for identity, image in sorted(selection["runtime"].items()): if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+@sha256:[a-f0-9]{64}", image): raise ValueError("authenticated clean native OCI runtime digest required") - from boundaries import LinuxBoundary control = workspace / ("native-control-" + identity) control.mkdir(mode=0o755) generated = control / "output" @@ -889,8 +906,6 @@ def verify_download_fixture(boundary, workspace, tools, environment, *, executio count as a download denial because the build-script marker must exist and contain the actual network syscall's permission errno. """ - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary workspace = Path(workspace) fixture = workspace / "download-fixture" log_root = Path(log_root) if log_root is not None else workspace @@ -980,8 +995,6 @@ def _build_recipe(request, selection): bindings before this entry. No JSON commands, fixture bypass, transforms, publication authority or network-enabled compile path exist here. """ - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import MacBoundary, LinuxBoundary, probe_controls from qualify import validate_socket_pair artifact = artifact_selection(request.asset) role = ambient_profile(request.role) diff --git a/scripts/release/tests/test_recipe.py b/scripts/release/tests/test_recipe.py index fa98125d51..4a1701b83d 100644 --- a/scripts/release/tests/test_recipe.py +++ b/scripts/release/tests/test_recipe.py @@ -6,6 +6,7 @@ import platform import importlib.util import sys +import subprocess import tempfile import unittest from pathlib import Path @@ -440,6 +441,50 @@ def test_capture_refuses_symlink_log_without_modifying_target(self): recipe.capture([sys.executable, "-I", "-c", "print('changed')"], {}, root, root / "cargo", timeout=10) self.assertEqual(original.read_bytes(), b"original example") +class IsolatedQualificationImportTests(unittest.TestCase): + def test_direct_candidate_selection_uses_source_owned_boundary_in_fresh_python(self): + script = """ +import sys +sys.path.insert(0, sys.argv[1]) +import rust_recipe as recipe +names = {"python", "git", "cargo", "rustc", "cc", "cxx", "linker", "ar", "cmake", "shell", "docker"} +policy = {key: None for key in ("environment_id", "tools", "readonly", "trees", "tool_trees", "archive_pool", "cargo_seed", "rustup", "sdk", "variation", "image", "runtime")} +policy.update(environment_id="gnu-builder-x86_64", tools={name: {"path": "/example/selected-tool", "sha256": "a" * 64, "acquisition_sha256": "b" * 64} for name in names}) +recipe.CANDIDATE_ACQUISITIONS["gnu-builder-x86_64"] = policy +try: + recipe.candidate_selection("ts-x86_64-unknown-linux-gnu", "a" * 40) +except ValueError as error: + assert "actual source-owned Docker executable" in str(error), str(error) +else: + raise AssertionError("wrong Docker executable accepted") +import boundaries +assert recipe.LinuxBoundary is boundaries.LinuxBoundary +print("source-owned import closed") +""" + result = subprocess.run([sys.executable, "-I", "-B", "-c", script, str(RELEASE)], capture_output=True, timeout=30, check=False) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(result.stdout.strip(), b"source-owned import closed") + + def test_preloaded_foreign_boundary_module_is_rejected(self): + script = """ +import sys, types +sys.path.insert(0, sys.argv[1]) +foreign = types.ModuleType("boundaries") +foreign.__file__ = "/example/foreign/boundaries.py" +sys.modules["boundaries"] = foreign +try: + import rust_recipe +except ValueError as error: + assert "source-owned qualification" in str(error), str(error) +else: + raise AssertionError("foreign boundary module accepted") +print("foreign module rejected") +""" + result = subprocess.run([sys.executable, "-I", "-B", "-c", script, str(RELEASE)], capture_output=True, timeout=30, check=False) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(result.stdout.strip(), b"foreign module rejected") + + class SourceContextTests(unittest.TestCase): def test_actual_executing_source_commit_and_helpers_are_bound(self): import subprocess From 7abb8e8f583ef841e6cba86fa108a9efe5e1854f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 18:49:35 +0530 Subject: [PATCH 037/153] Terminate bounded process groups after leader exit --- scripts/release/qualification/boundaries.py | 4 ++- scripts/release/tests/test_recipe.py | 37 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/release/qualification/boundaries.py b/scripts/release/qualification/boundaries.py index acc365f7ca..ffb290ca22 100644 --- a/scripts/release/qualification/boundaries.py +++ b/scripts/release/qualification/boundaries.py @@ -73,8 +73,10 @@ def bounded_process(argv, environment, *, timeout=30, maximum=65536): process.wait(timeout=max(0.001, deadline - time.monotonic())) return subprocess.CompletedProcess(argv, process.returncode, bytes(outputs[0]), bytes(outputs[1])) finally: - if process.poll() is None: + try: os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass process.wait(timeout=5) selector.close() process.stdout.close() diff --git a/scripts/release/tests/test_recipe.py b/scripts/release/tests/test_recipe.py index 4a1701b83d..645c977458 100644 --- a/scripts/release/tests/test_recipe.py +++ b/scripts/release/tests/test_recipe.py @@ -7,6 +7,8 @@ import importlib.util import sys import subprocess +import signal +import time import tempfile import unittest from pathlib import Path @@ -294,6 +296,41 @@ def test_whitespace_cannot_inject_compiler_path_flags(self): recipe.fresh_root(Path(directory).resolve() / "material -Dexample") class BoundedBoundaryProcessTests(unittest.TestCase): + @unittest.skipUnless(os.name == "posix", "process-group cleanup uses POSIX semantics") + def test_timeout_kills_child_holding_pipes_after_leader_exits(self): + from boundaries import bounded_process + with tempfile.TemporaryDirectory() as directory: + identity = Path(directory) / "fixture-pids" + script = "import os,subprocess,sys;from pathlib import Path;child=subprocess.Popen([sys.executable,'-I','-c','import time;time.sleep(30)']);Path(sys.argv[1]).write_text(str(os.getpid())+' '+str(child.pid))" + leader = child = None + def alive(pid): + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + try: + started = time.monotonic() + with self.assertRaisesRegex(ValueError, "timeout"): + bounded_process([sys.executable, "-I", "-B", "-c", script, str(identity)], {}, timeout=0.5) + self.assertLess(time.monotonic() - started, 3, "timeout should remain bounded") + leader, child = map(int, identity.read_text().split()) + self.assertFalse(alive(leader), "direct child should be reaped") + deadline = time.monotonic() + 2 + while alive(child) and time.monotonic() < deadline: + time.sleep(0.01) + self.assertFalse(alive(child), "descendant holding pipes should terminate with its process group") + finally: + if identity.exists() and child is None: + leader, child = map(int, identity.read_text().split()) + if child is not None and alive(child): + self.assertEqual(os.getpgid(child), leader, "cleanup should target only this fixture's process group") + os.killpg(leader, signal.SIGKILL) + deadline = time.monotonic() + 5 + while alive(child) and time.monotonic() < deadline: + time.sleep(0.01) + self.assertFalse(alive(child), "fixture cleanup should leave no descendant running") + def test_control_and_cleanup_capture_stop_live_output_overflow(self): sys.path.insert(0, str(RELEASE / "qualification")) from boundaries import bounded_process From 99d2ec7c4c35f7c0d1ddeb2bab75e6dbe68a1b25 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 18:57:12 +0530 Subject: [PATCH 038/153] Record local Rust recipe verification and integration gates --- .../2026-10-13-attested-release-implementation.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 59dcba54a6..9e5dba87b9 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -160,14 +160,14 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Gate:** This task produces measured pins, not placeholder configuration. It can run only with the separately authorized disposable hosted resources. It does not publish official tags or deploy a real publisher service. -Implementation sequencing: review the local validation framework and candidate boundaries first, then implement the shared Task 9 recipes. Hosted qualification must invoke those same recipes with independently authenticated candidate selections; it cannot require its own future qualification result. Production selection remains closed until the hosted evidence and measured pins are reviewed. Local framework completion does not satisfy the hosted checkboxes above. +Implementation sequencing: review the local validation framework and candidate boundaries first, then implement the shared Task 9 recipes and Task 10 composition captures. Complete the authenticated candidate/production adapters and shared-JS producer binding before measuring the executable closure. Hosted qualification must invoke those same recipes with independently authenticated candidate selections; it cannot require its own future qualification result. Production selection remains closed until the hosted evidence and measured pins are reviewed. Local framework completion does not satisfy the hosted checkboxes above. ### Task 9: Implement fixed fresh Rust recipes and native inspection **Create:** `scripts/release/build.sh`, `inspect-native.py`, `tests/test_recipe.py`, `tests/test_native.py`, `scripts/release/validate-fastly.py`, `tests/test_fastly_runtime.py`. -- [ ] Test unknown asset/variant, reused target output, source/material mutation, unselected native library, GLIBC symbol ceiling violations, runner-only paths and Mach-O deployment-target mismatch. Add role-specific variation fixtures: missing selected B variation, normalized values misreported as varied, unsupported user variation claimed as passed and incorrect observed concurrency/umask. -- [ ] Run both new unittest modules; expect missing recipe/inspection behavior. +- [x] Test unknown asset/variant, reused target output, source/material mutation, unselected native library, GLIBC symbol ceiling violations, runner-only paths and Mach-O deployment-target mismatch. Add role-specific variation fixtures: missing selected B variation, normalized values misreported as varied, unsupported user variation claimed as passed and incorrect observed concurrency/umask. +- [x] Run both new unittest modules; expect missing recipe/inspection behavior. - [ ] Implement a closed interface: `build.sh --asset --role A|B --source-commit --js-dir --js-inventory --materials --output `. Select package/target/features from reviewed source; JSON never supplies executable commands. - [ ] Implement checked A/B ambient profiles in the recipe and record them in `release-tools.json`: different external checkout roots, A umask 022/B umask 027, UTC versus a qualified alternate timezone, distinct qualified locale settings, different observed start times and A jobs 4/B jobs 2 within the qualified insensitive envelope. Vary execution user only where Task 8 proves support; otherwise record it unsupported without claiming it changed. Capture both ambient inputs and effective compiler values after sanitation/remapping; fixed internal locale/path/epoch values are normalized, not varied. No unreviewed caller overrides can choose profiles. Tests assert genuinely observed supported differences and reject dishonest/omitted records. - [ ] Apply Task 8's qualified OS/container denial profile in the actual production `build.sh`, not just the qualification prototype. Every production invocation must record a successful unconfined socket control and denied confined socket probe, then execute Cargo, compiler, proc-macro, build-script and native-tool children inside that same denied boundary. Preparation alone may fetch materials. A build-script/lifecycle download-attempt fixture must fail, and a missing or ineffective confinement proof rejects the output; `--frozen` is not the boundary. @@ -460,3 +460,11 @@ Task 8 has a reviewed local framework in `481f8c7fa`, with corrections in `7c8f2 The local framework validates closed observations without authenticating their claims or conferring qualification. It distinguishes the GNU builder from the three compatibility runtimes and records actual hosted macOS image versions rather than inventing VM byte hashes. Domain-separated fingerprints bind the reviewed executable helper closure, settings and acquisition selections while excluding derived result pins; the raw tools-file hash remains a separate input. Actual TCP/UDP controls cover IPv4/IPv6, loopback/documentation destinations and three process generations, with successful private Unix communication. Unavailable routing is distinct from permission denial. Review corrections reject contradictory successful phases, normalize numeric compatibility versions, and prevent protected source/Git ancestors from being renamed or removed while preserving private installation/output writes. The ancestor bypass was reproduced before correction and independently rejected afterward. All tool, SDK, OCI and recipe qualification pins remain pending. The inert manual hosted-workflow proposal is outside `.github/workflows` and deliberately stops before incomplete shared recipes or unauthenticated staging can run. Actual GNU execution, native macOS 15 ARM/Intel compilation and startup, complete Fastly publisher-response validation, authenticated bootstrap/acquisition evidence, separately authorized hosted resources and reviewed measured pins remain required. No workflow registration, hosted dispatch, signing, publication or deployment occurred. Read-only Rust architecture and dependency investigations support implementing one shared recipe next; its candidate selector must remain internal and cannot supply consumer or publication authority. + +Task 9 has a reviewed local framework in `5476b7cce`, with corrections in `9988a566b`, `2a2db6d3a` and `7abb8e8f5`; Task 9 itself remains incomplete. Independent spec and quality reviews approve the local slice only. The controller freshly ran all 227 release-helper Python tests in 31.672 seconds, including actual local Cargo build-script HTTP success/permission-denial, all three macOS boundary controls and a compiled tiny Mach-O fixture. The separately selected full JS pipeline is the sole skipped test and retains its Task 7 evidence. These local fixtures do not qualify GNU or native macOS 15 ARM/Intel release artifacts. + +The implementation provides fixed asset/role parsing, fresh original-material preparation and Cargo vendor normalization, source-owned role profiles, bounded separate Cargo logs, source/tool/material checks, immutable accepted bytes, ELF/Mach-O inspection and exact-final-Wasm runtime gates. Review corrections compose generated Cargo configuration with the actual Linux `/work` namespace, preserve the complete origin HTML fixture, exercise native help/version/config initialization with source/package/scaffold checks, bind Docker authentication to its actual executor, protect its private client configuration and load one exact source-owned boundary module before candidate selection. A reproduced timeout leak is corrected by terminating the spawned process group even after its leader exits; the regression verifies descendant termination and leader reaping. + +The controller independently exercised the stricter Viceroy publisher fixture against an existing development Wasm. Both runtime-config variants reached the fictional origin, preserved complete HTML, injected and fetched exact prepared core/creative JS with its matching version hash, and left the Wasm unchanged. This diagnostic explicitly has no maintained-validator or canonical-build proof. An existing native development CLI generated the exact source scaffold outside checkout but correctly failed the approved-source version gate because it reported a development branch label. + +The central Rust recipe has not executed preparation, product compilation, final acceptance and output copy together. Its public selectors remain closed: candidate acquisitions are unpopulated, acquisition-evidence digests do not yet authenticate their evidence, Python bootstrap and reviewed qualification receipt consumption are not integrated, and checked JS inventory bytes alone cannot establish producer authority. The next focused Task 8 integration must add source-owned authenticated selection/dispatch and a private completion binding from the actual shared Task 7 producer, and correct hosted JS environment/cwd/interpreter mapping. Task 10 must capture metadata/tree and native materials through that same boundary before hosted closure measurement. Actual selected-platform compilation/startup, maintained Wasmtime validation, canonical Fastly A/B equality and reviewed measured pins remain mandatory. No hosted resources, signing, publication or provider operations were enabled. From 6d0efda7210983021ab3c9efe8a0a3190e9eb81b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 23:11:08 +0530 Subject: [PATCH 039/153] Replace Python release tooling and address attestation review Add private host-only Rust document utilities with bounded strict JSON, offline pinned schema evaluation and atomic output handling. Remove the introduced Python producer and validator dependencies while keeping the release bootstrap closed pending approved source and acquisition checks. Restore development prebuilt compatibility, preserve checked inventory copying, align opaque number handling, and expose actual owning build facts. Replace extracted-source tests with real helpers, scripts and CLI binaries. Add native CI coverage and full release JS test selection. Reconcile the specification and plans with the review, clarify trust inputs and pending qualification proofs, and remove execution journals and private paths. Verify all adapter gates, native CLI and prebuilt tests, release utilities, JS tests/build/lint, docs build/lint/format, workflows and staged domain lint. Hosted release qualification, signing, publication and deployment remain pending; no official release is enabled by this checkpoint. --- .github/workflows/format.yml | 16 +- .github/workflows/test.yml | 103 +- Cargo.lock | 236 +++- Cargo.toml | 1 + crates/trusted-server-adapter-fastly/build.rs | 15 +- .../trusted-server-adapter-fastly/src/main.rs | 67 +- crates/trusted-server-cli/build.rs | 7 +- .../src/commands/dev/lint/domains.rs | 229 ++++ .../trusted-server-cli/src/prebid_bundle.rs | 98 +- crates/trusted-server-cli/src/run.rs | 15 +- .../tests/release_build_facts.rs | 512 ++----- crates/trusted-server-core/build.rs | 44 +- .../build_support/git_version.rs | 26 + crates/trusted-server-core/src/build_info.rs | 23 +- crates/trusted-server-core/src/constants.rs | 9 +- .../tests/git_version_resolve.rs | 51 + crates/trusted-server-js/build.rs | 69 +- crates/trusted-server-js/build/bundle_set.rs | 54 +- crates/trusted-server-js/build/inventory.rs | 56 +- crates/trusted-server-js/build/prebuilt.rs | 35 +- crates/trusted-server-js/lib/.gitignore | 1 + crates/trusted-server-js/lib/.prettierignore | 1 + crates/trusted-server-js/lib/build-all.mjs | 2 +- crates/trusted-server-js/lib/eslint.config.js | 2 +- crates/trusted-server-js/lib/package.json | 4 +- .../lib/release-composition.mjs | 8 +- .../lib/test/release-composition.test.mjs | 610 ++++++--- crates/trusted-server-js/lib/vitest.config.ts | 7 + .../tests/release_prebuilt.rs | 302 ++++- crates/trusted-server-release/Cargo.toml | 21 + .../schemas/SPDX-LICENSE | 92 ++ .../schemas/spdx-2.3.schema.json | 740 +++++++++++ .../trusted-server-release/src/documents.rs | 559 ++++++++ .../src/documents/tests.rs | 767 +++++++++++ crates/trusted-server-release/src/error.rs | 12 + crates/trusted-server-release/src/main.rs | 11 + .../trusted-server-release/tests/commands.rs | 68 + .../tests/fixtures/README.md | 11 + docs/guide/edgezero.md | 7 +- docs/guide/error-reference.md | 33 +- docs/guide/getting-started.md | 6 + ...6-10-13-attested-release-implementation.md | 464 ++++--- ...3-cli-packaging-attestation-integration.md | 28 +- ...13-fastly-prebuilt-edgezero-integration.md | 10 +- .../specs/2026-01-15-attestation-design.md | 8 +- .../2026-09-25-git-version-header-design.md | 2 + ...6-10-09-1235-release-attestation-design.md | 335 +++-- ...12-1235-release-attestation-feasibility.md | 95 +- ...26-10-14-release-tooling-rust-migration.md | 55 + scripts/release/bootstrap.sh | 6 + scripts/release/build-js.sh | 10 +- scripts/release/build.sh | 10 +- scripts/release/collect-js.mjs | 203 +-- scripts/release/common.py | 219 --- scripts/release/inspect-native.py | 243 ---- scripts/release/js-composition.md | 152 ++- scripts/release/preflight.py | 830 ------------ scripts/release/prepare.py | 808 ----------- scripts/release/qualification/README.md | 255 ++-- scripts/release/qualification/approvals.json | 1 + scripts/release/qualification/boundaries.py | 303 ----- .../hosted-workflow.proposal.yml | 14 +- scripts/release/qualification/qualify.py | 308 ----- scripts/release/qualification/socket_probe.py | 135 -- scripts/release/release-tools.json | 10 +- scripts/release/requirements.txt | 153 --- scripts/release/rust_recipe.py | 1176 ----------------- scripts/release/tests/fixtures/README.md | 112 +- scripts/release/tests/test_common.py | 387 ------ scripts/release/tests/test_fastly_runtime.py | 122 -- scripts/release/tests/test_js_release.py | 478 ------- scripts/release/tests/test_native.py | 153 --- scripts/release/tests/test_preflight.py | 651 --------- scripts/release/tests/test_prepare.py | 335 ----- scripts/release/tests/test_qualification.py | 609 --------- scripts/release/tests/test_recipe.py | 656 --------- scripts/release/validate-fastly.py | 405 ------ 77 files changed, 5192 insertions(+), 9479 deletions(-) create mode 100644 crates/trusted-server-release/Cargo.toml create mode 100644 crates/trusted-server-release/schemas/SPDX-LICENSE create mode 100644 crates/trusted-server-release/schemas/spdx-2.3.schema.json create mode 100644 crates/trusted-server-release/src/documents.rs create mode 100644 crates/trusted-server-release/src/documents/tests.rs create mode 100644 crates/trusted-server-release/src/error.rs create mode 100644 crates/trusted-server-release/src/main.rs create mode 100644 crates/trusted-server-release/tests/commands.rs create mode 100644 crates/trusted-server-release/tests/fixtures/README.md create mode 100644 docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md create mode 100644 scripts/release/bootstrap.sh delete mode 100644 scripts/release/common.py delete mode 100755 scripts/release/inspect-native.py delete mode 100644 scripts/release/preflight.py delete mode 100644 scripts/release/prepare.py create mode 100644 scripts/release/qualification/approvals.json delete mode 100644 scripts/release/qualification/boundaries.py delete mode 100644 scripts/release/qualification/qualify.py delete mode 100644 scripts/release/qualification/socket_probe.py delete mode 100644 scripts/release/requirements.txt delete mode 100644 scripts/release/rust_recipe.py delete mode 100644 scripts/release/tests/test_common.py delete mode 100644 scripts/release/tests/test_fastly_runtime.py delete mode 100644 scripts/release/tests/test_js_release.py delete mode 100644 scripts/release/tests/test_native.py delete mode 100644 scripts/release/tests/test_preflight.py delete mode 100644 scripts/release/tests/test_prepare.py delete mode 100644 scripts/release/tests/test_qualification.py delete mode 100644 scripts/release/tests/test_recipe.py delete mode 100755 scripts/release/validate-fastly.py diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index ef36c83d6c..f193659b73 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -1,4 +1,4 @@ -name: "Run Format" +name: 'Run Format' on: push: @@ -47,7 +47,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up rust toolchain @@ -55,7 +55,7 @@ jobs: with: toolchain: ${{ steps.rust-version.outputs.rust-version }} target: wasm32-wasip1,wasm32-unknown-unknown - components: "clippy, rustfmt" + components: 'clippy, rustfmt' cache-shared-key: cargo-${{ runner.os }} - name: Run cargo fmt @@ -107,14 +107,14 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.node-version }} - cache: "npm" + cache: 'npm' cache-dependency-path: crates/trusted-server-js/lib/package.json - name: Install dependencies @@ -138,14 +138,14 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.node-version }} - cache: "npm" + cache: 'npm' cache-dependency-path: docs/package.json - name: Install dependencies @@ -161,7 +161,7 @@ jobs: working-directory: . run: >- docs/node_modules/.bin/prettier --config docs/.prettierrc --check - "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" + "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "scripts/**/*.mjs" "tinybird/**/*.md" - name: Build with VitePress (fails on dead links) run: npm run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95654ecddf..20ed4235ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: "Run Tests" +name: 'Run Tests' permissions: contents: read @@ -18,19 +18,19 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Retrieve Viceroy version id: viceroy-version # `.tool-versions` is the single source of truth so this workflow and # `.github/actions/setup-integration-test-env/action.yml` can't drift. - run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "viceroy-version=$(grep '^viceroy ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Retrieve Node.js version id: node-version - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain @@ -79,7 +79,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain @@ -133,7 +133,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain (native + wasm32-unknown-unknown) @@ -160,7 +160,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain (native + wasm32-wasip1) @@ -185,7 +185,7 @@ jobs: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:8080 TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32 - TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" + TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: 'false' run: cargo build --package trusted-server-adapter-spin --target wasm32-wasip1 --features spin --release - name: Run Spin adapter tests (native host) @@ -199,7 +199,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain @@ -230,14 +230,14 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} - components: "clippy, rustfmt" + components: 'clippy, rustfmt' cache-shared-key: cargo-cli-${{ runner.os }} - name: Read pinned Node.js version for CLI fixtures @@ -292,6 +292,75 @@ jobs: cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$TARGET" \ --test proxy_trust_linux -- --ignored + # Ordinary source-built behavior checks; this matrix is not release qualification. + test-release-native: + name: cargo test (release tooling and JS prebuilt, native) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24) + with: + persist-credentials: false + + - name: Read pinned tool versions + id: tools + shell: bash + run: | + set -euo pipefail + echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + - name: Use pinned Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24) + with: + node-version: ${{ steps.tools.outputs.node }} + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + + - name: Install JS dependencies + working-directory: crates/trusted-server-js/lib + run: npm ci + + # The runner's installed rustup bootstraps ordinary CI, not the release producer. + - name: Install pinned native Rust toolchain + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal --component clippy + rustup override set "$RUST_VERSION" + HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" + test -n "$HOST_TARGET" + echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV" + + - name: Guard and run all native release-tool tests + shell: bash + run: | + set -euo pipefail + TEST_LIST="$(mktemp)" + trap 'rm -f "$TEST_LIST"' EXIT + cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features -- --list > "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count == 0) { print "No native release-tool tests selected"; exit 1 } print "Selected " count " native release-tool tests" }' "$TEST_LIST" + cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features + cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings + cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features + + - name: Guard and run native JS prebuilt tests + shell: bash + run: | + set -euo pipefail + TEST_LIST="$(mktemp)" + trap 'rm -f "$TEST_LIST"' EXIT + cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt -- --list > "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count == 0) { print "No native JS prebuilt tests selected"; exit 1 } print "Selected " count " native JS prebuilt tests" }' "$TEST_LIST" + cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt + cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features -- -D warnings + test-typescript: name: vitest runs-on: ubuntu-latest @@ -299,19 +368,21 @@ jobs: run: working-directory: crates/trusted-server-js/lib steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24) + with: + persist-credentials: false - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24) with: node-version: ${{ steps.node-version.outputs.node-version }} - cache: "npm" + cache: 'npm' cache-dependency-path: crates/trusted-server-js/lib/package.json - name: Install dependencies @@ -321,4 +392,6 @@ jobs: run: npm run build - name: Run unit tests + env: + TRUSTED_SERVER__RELEASE_JS_TESTS: '1' run: npm test -- --run diff --git a/Cargo.lock b/Cargo.lock index 07c865b64e..73b71fcced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -393,6 +395,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -512,6 +529,12 @@ dependencies = [ "serde_with", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.4" @@ -565,6 +588,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -1721,6 +1750,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1834,6 +1872,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "faster-hex" version = "0.10.0" @@ -1978,6 +2027,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1999,6 +2059,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3394,7 +3464,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "strum_macros", + "strum_macros 0.27.2", "syn 2.0.118", "thiserror 2.0.18", "walkdir", @@ -3798,6 +3868,59 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "kstring" version = "2.0.2" @@ -4013,6 +4136,12 @@ dependencies = [ "libc", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -4114,9 +4243,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -4138,6 +4267,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -4284,6 +4419,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -5046,6 +5187,23 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.4" @@ -5060,9 +5218,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5952,7 +6110,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5967,6 +6134,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6740,6 +6919,21 @@ dependencies = [ "prost-build", ] +[[package]] +name = "trusted-server-release" +version = "0.1.0" +dependencies = [ + "derive_more", + "error-stack", + "hex", + "jsonschema", + "libc", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6797,6 +6991,12 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6933,6 +7133,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "validator" version = "0.20.0" @@ -6975,6 +7185,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -7588,7 +7804,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", - "strum", + "strum 0.27.2", "tokio", "url", "wasm-bindgen", @@ -7608,7 +7824,7 @@ dependencies = [ "async-trait", "proc-macro2", "quote", - "strum", + "strum 0.27.2", "syn 2.0.118", "wasm-bindgen", "wasm-bindgen-macro-support", @@ -7795,6 +8011,6 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f5c81ecf4b..adae1beae6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ # Manual protobuf codegen tool — a member so it shares workspace settings, # but no default member or alias builds it. Run it explicitly with `-p`. "crates/trusted-server-openrtb-codegen", + "crates/trusted-server-release", ] # Viceroy (cargo test-fastly runner) calls `cargo run --bin trusted-server-adapter-fastly` diff --git a/crates/trusted-server-adapter-fastly/build.rs b/crates/trusted-server-adapter-fastly/build.rs index ea6efe16fa..0889fe217b 100644 --- a/crates/trusted-server-adapter-fastly/build.rs +++ b/crates/trusted-server-adapter-fastly/build.rs @@ -19,17 +19,8 @@ fn main() { println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__GIT_VERSION"); let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION"); - if let Some(source) = git_version::release_source(control.as_deref(), source.as_deref()) - .expect("should provide valid release build inputs") - { - println!("cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION={source}"); - } + git_version::release_source(control.as_deref(), source.as_deref()) + .expect("should provide valid release build inputs"); let target = std::env::var("TARGET").expect("should set the owning Cargo target"); - println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_TARGET={target}"); - let variant = if std::env::var_os("CARGO_FEATURE_REUSABLE_SANDBOX").is_some() { - "reusable-sandbox" - } else { - "default" - }; - println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_VARIANT={variant}"); + println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}"); } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index c728c9e546..82b059a315 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -62,7 +62,7 @@ use crate::sandbox::RetirementCounters; /// Immutable facts compiled into the owning Fastly product. pub const BUILD_INFO: BuildInfo = BuildInfo::new( env!("CARGO_PKG_VERSION"), - env!("TRUSTED_SERVER_BUILD_TARGET"), + env!("TRUSTED_SERVER__BUILD_TARGET"), if cfg!(feature = "reusable-sandbox") { BuildVariant::ReusableSandbox } else { @@ -111,10 +111,7 @@ fn main() { // Held outside the sandbox: `serve_custom` owns the `Sandbox` and exposes // no slot for application state that is not the retained payload. - let mut startup = StartupDiagnostics::default(); - for message in diagnostics { - startup.push(message); - } + let mut startup = startup_diagnostics(diagnostics); match mode { ServeMode::Single => { @@ -130,6 +127,21 @@ fn main() { } } +fn startup_diagnostics(diagnostics: Vec) -> StartupDiagnostics { + let mut startup = StartupDiagnostics::default(); + startup.push(format!( + "Build facts: package={} source={} target={} variant={}", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target(), + BUILD_INFO.variant() + )); + for message in diagnostics { + startup.push(message); + } + startup +} + /// Serves requests from one sandbox under explicit bounds. /// /// Only compiled with the `reusable-sandbox` feature; [`serve_mode`] can never @@ -927,6 +939,51 @@ mod tests { ); } + #[test] + fn fastly_build_info_uses_actual_owning_package_target_source_and_feature() { + assert_eq!( + BUILD_INFO.package_version(), + env!("CARGO_PKG_VERSION"), + "should own Fastly package version" + ); + assert_eq!( + BUILD_INFO.target(), + "wasm32-wasip1", + "should own Fastly Cargo target" + ); + assert_eq!( + BUILD_INFO.source(), + trusted_server_core::constants::TS_GIT_VERSION, + "should read source from core" + ); + let expected = if cfg!(feature = "reusable-sandbox") { + BuildVariant::ReusableSandbox + } else { + BuildVariant::Default + }; + assert_eq!( + BUILD_INFO.variant(), + expected, + "should derive actual Fastly feature variant" + ); + } + + #[test] + fn startup_queues_one_build_fact_message_for_existing_log_flush() { + let startup = startup_diagnostics(Vec::new()); + assert_eq!( + startup.pending(), + 1, + "should queue exactly one build fact message" + ); + let startup = startup_diagnostics(vec!["example diagnostic".to_owned()]); + assert_eq!( + startup.pending(), + 2, + "should preserve existing startup diagnostics" + ); + } + #[test] fn health_response_reports_git_version_only() { let req = FastlyRequest::get("https://example.com/health"); diff --git a/crates/trusted-server-cli/build.rs b/crates/trusted-server-cli/build.rs index 4a69768233..1c40ab678b 100644 --- a/crates/trusted-server-cli/build.rs +++ b/crates/trusted-server-cli/build.rs @@ -20,13 +20,12 @@ fn main() { println!("cargo:rustc-check-cfg=cfg(trusted_server_release_build)"); let control = std::env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); let source = std::env::var_os("TRUSTED_SERVER__GIT_VERSION"); - if let Some(source) = git_version::release_source(control.as_deref(), source.as_deref()) + if git_version::release_source(control.as_deref(), source.as_deref()) .expect("should provide valid release build inputs") + .is_some() { println!("cargo:rustc-cfg=trusted_server_release_build"); - println!("cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION={source}"); } let target = std::env::var("TARGET").expect("should set the owning Cargo target"); - println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_TARGET={target}"); - println!("cargo:rustc-env=TRUSTED_SERVER_BUILD_VARIANT=default"); + println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}"); } diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs index 3fe578b3b0..7ca6449c84 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -500,6 +500,36 @@ fn canonical_host(authority: &str) -> String { } } +// These are exact standard identifiers, rather than general host permissions. +const STANDARD_IDENTIFIERS: &[&str] = &[ + "http://json-schema.org/draft-07/schema#", + "https://json-schema.org/draft/2020-12/schema", + "http://spdx.org/rdf/terms/2.3", + "http://spdx.org/rdf/terms#none", + "http://spdx.org/rdf/terms#noassertion", +]; + +fn is_standard_identifier(line: &str, start: usize) -> bool { + let Some(prefix) = line.get(..start) else { + return false; + }; + let Some(suffix) = line.get(start..) else { + return false; + }; + if prefix.chars().next_back().is_some_and(|character| { + !character.is_whitespace() && !matches!(character, '"' | '\'' | '`' | '(' | '[' | '<' | '{') + }) { + return false; + } + STANDARD_IDENTIFIERS.iter().any(|identifier| { + suffix.strip_prefix(identifier).is_some_and(|rest| { + rest.chars().next().is_none_or(|character| { + character.is_whitespace() || matches!(character, '"' | '\'' | '`') + }) + }) + }) +} + /// Extract and normalise every host from absolute URLs on `line`. /// /// The scheme itself marks these as URLs, so an authority that merely @@ -510,6 +540,11 @@ fn extract_absolute_hosts(line: &str) -> Vec { let line = unescape_solidus(line); absolute_url_regex() .captures_iter(&line) + .filter(|capture| { + !capture + .get(0) + .is_some_and(|matched| is_standard_identifier(&line, matched.start())) + }) .filter_map(|c| c.get(1).map(|m| canonical_host(m.as_str()))) .filter(|host| host.chars().any(char::is_alphanumeric)) .collect() @@ -1372,6 +1407,200 @@ mod scan_line_tests { } } + fn standard_identifiers() -> [&'static str; 5] { + [ + "http://json-schema.org/draft-07/schema#", + "https://json-schema.org/draft/2020-12/schema", + "http://spdx.org/rdf/terms/2.3", + "http://spdx.org/rdf/terms#none", + "http://spdx.org/rdf/terms#noassertion", + ] + } + + fn standard_host(identifier: &str) -> String { + Url::parse(identifier) + .expect("should parse exact standard identifier") + .host_str() + .expect("should include standard host") + .to_owned() + } + + #[test] + fn normative_identifiers_accept_exact_json_and_quoted_prose() { + for identifier in standard_identifiers() { + for line in [ + format!(r#"{{"$schema":"{identifier}"}}"#), + format!("The identifier is `{identifier}` in this fixture."), + identifier.to_owned(), + format!("'{}'", identifier.replace('/', "\\/")), + ] { + assert!( + hosts(&line).is_empty(), + "should accept exact standard identifier: {line}" + ); + } + } + } + + #[test] + fn normative_identifiers_reject_prefixed_text_and_every_uri_continuation() { + for identifier in standard_identifiers() { + let expected = standard_host(identifier); + for suffix in [ + "/", + "/extra", + "?query", + "#fragment", + "%20", + "@reserved.invalid", + ":80", + ";extra", + ")", + ",", + "*", + "extra", + ] { + let line = format!("\"{identifier}{suffix}\""); + assert_eq!( + hosts(&line), + vec![expected.clone()], + "should reject continued standard URI: {line}" + ); + } + assert_eq!( + hosts(&format!("\"prefixed{identifier}\"")), + vec![expected], + "should reject identifier embedded in prefixed text" + ); + } + } + + #[test] + fn normative_identifiers_require_source_opening_prefix_boundaries() { + for identifier in standard_identifiers() { + let host = standard_host(identifier); + for prefix in [ + ":", + "=", + "/", + ";", + ")", + "]", + "}", + "@", + "%", + "?", + "#", + "urn:reference:", + "query=", + ] { + assert_eq!( + hosts(&format!("{prefix}{identifier}\"")), + vec![host.clone()], + "should reject nested or continued URI prefixes" + ); + } + for prefix in [" ", "\t", "\"", "'", "`", "(", "[", "<", "{"] { + assert!( + hosts(&format!("{prefix}{identifier}\"")).is_empty(), + "should accept source-opening prefix boundary" + ); + } + } + } + + #[test] + fn normative_identifiers_reject_scheme_authority_and_host_variants() { + for identifier in standard_identifiers() { + let parsed = Url::parse(identifier).expect("should parse standard URI"); + let scheme = parsed.scheme(); + let host = standard_host(identifier); + let rest = identifier + .strip_prefix(&format!("{scheme}://{host}")) + .expect("should provide canonical authority"); + for authority in [ + format!("user@{host}"), + format!("{host}:80"), + format!("subdomain.{host}"), + format!("lookalike-{host}"), + format!("{host}.unreviewed"), + host.replacen('o', "%6f", 1), + host.to_uppercase(), + ] { + let line = format!("\"{scheme}://{authority}{rest}\""); + assert!( + !hosts(&line).is_empty(), + "should reject noncanonical standard authority: {line}" + ); + } + for line in [ + format!("\"//{host}{rest}\""), + format!("\"{}://{host}{rest}\"", scheme.to_uppercase()), + format!( + "\"{}://{host}{rest}\"", + if scheme == "http" { "https" } else { "http" } + ), + ] { + assert_eq!( + hosts(&line), + vec![host.clone()], + "should reject noncanonical scheme or protocol-relative URI: {line}" + ); + } + } + } + + #[test] + fn normative_identifiers_do_not_hide_same_host_urls_in_either_order() { + for identifier in standard_identifiers() { + let host = standard_host(identifier); + let noncanonical = format!("{identifier}/unregistered"); + for line in [ + format!("\"{identifier}\" \"{noncanonical}\""), + format!("\"{noncanonical}\" \"{identifier}\""), + ] { + assert_eq!( + hosts(&line), + vec![host.clone()], + "should retain the noncanonical occurrence before host deduplication" + ); + } + } + } + + #[test] + fn normative_identifiers_preserve_other_host_violations() { + for identifier in standard_identifiers() { + let other = if standard_host(identifier) == "spdx.org" { + "https://json-schema.org/unregistered" + } else { + "http://spdx.org/unregistered" + }; + assert_eq!( + hosts(&format!("\"{identifier}\" \"{other}\"")), + vec![standard_host(other)], + "should retain other disallowed URL beside exact identifier" + ); + } + } + + #[test] + fn normative_identifiers_leave_host_suppressions_unused() { + for identifier in standard_identifiers() { + let host = standard_host(identifier); + let outcome = scan_line(&format!("\"{identifier}\" // allow-domain: {host}")); + assert!( + outcome.violations.is_empty(), + "should accept exact identifier without suppression" + ); + assert_eq!( + outcome.unused_suppressions, + vec![host], + "should report unused host suppression" + ); + } + } + #[test] fn release_policy_standard_identifiers_pass_without_suppression() { let policy = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/release-policy.json")); diff --git a/crates/trusted-server-cli/src/prebid_bundle.rs b/crates/trusted-server-cli/src/prebid_bundle.rs index 7f92992722..1ad5d605b1 100644 --- a/crates/trusted-server-cli/src/prebid_bundle.rs +++ b/crates/trusted-server-cli/src/prebid_bundle.rs @@ -620,6 +620,18 @@ fn ensure_file_exists(path: &Path, description: &str) -> CliResult<()> { } fn find_js_lib_dir(start: &Path) -> CliResult { + #[cfg(not(trusted_server_release_build))] + let fallback = Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("crates/trusted-server-js/lib"), + ); + #[cfg(trusted_server_release_build)] + let fallback: Option = None; + find_js_lib_dir_with_fallback(start, fallback.as_deref()) +} + +fn find_js_lib_dir_with_fallback(start: &Path, fallback: Option<&Path>) -> CliResult { for ancestor in start.ancestors() { let candidate = ancestor.join("crates/trusted-server-js/lib"); if is_js_lib_dir(&candidate) { @@ -627,20 +639,15 @@ fn find_js_lib_dir(start: &Path) -> CliResult { } } - #[cfg(not(trusted_server_release_build))] + if let Some(candidate) = fallback + && is_js_lib_dir(candidate) { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let candidate = manifest_dir - .join("../..") - .join("crates/trusted-server-js/lib"); - if is_js_lib_dir(&candidate) { - return candidate.canonicalize().map_err(|error| { - report_error(format!( - "failed to resolve JS library directory {}: {error}", - candidate.display() - )) - }); - } + return candidate.canonicalize().map_err(|error| { + report_error(format!( + "failed to resolve JS library directory {}: {error}", + candidate.display() + )) + }); } cli_error( @@ -838,6 +845,71 @@ fn write_atomic(path: &Path, contents: &str) -> CliResult<()> { mod tests { use super::*; + #[test] + fn resource_discovery_without_fallback_requires_runtime_checkout() { + let directory = tempfile::tempdir().expect("should create outside checkout fixture"); + let error = find_js_lib_dir_with_fallback(directory.path(), None) + .expect_err("should require runtime checkout when fallback is absent"); + assert!( + error + .to_string() + .contains("run `ts prebid client` from the Trusted Server repository"), + "should explain the missing source prerequisites" + ); + let resources = directory.path().join("crates/trusted-server-js/lib"); + fs::create_dir_all(&resources).expect("should create runtime resources"); + fs::write(resources.join("package.json"), "{}").expect("should create package fixture"); + fs::write(resources.join("build-prebid-external.mjs"), "") + .expect("should create builder fixture"); + let nested = directory.path().join("nested"); + fs::create_dir(&nested).expect("should create nested current directory"); + assert_eq!( + find_js_lib_dir_with_fallback(&nested, None) + .expect("should discover actual runtime checkout"), + resources, + "should discover runtime ancestor resources" + ); + } + + #[test] + fn resource_discovery_uses_explicit_development_fallback() { + let directory = tempfile::tempdir().expect("should create fixture"); + let resources = directory.path().join("development-lib"); + fs::create_dir(&resources).expect("should create development fallback"); + fs::write(resources.join("package.json"), "{}").expect("should create package fixture"); + fs::write(resources.join("build-prebid-external.mjs"), "") + .expect("should create builder fixture"); + assert_eq!( + find_js_lib_dir_with_fallback(directory.path(), Some(&resources)) + .expect("should use explicit development fallback"), + resources + .canonicalize() + .expect("should canonicalize fallback"), + "should preserve development fallback behavior" + ); + } + + #[test] + fn actual_resource_discovery_uses_owning_release_cfg() { + let directory = tempfile::tempdir().expect("should create outside checkout fixture"); + let result = find_js_lib_dir(directory.path()); + if cfg!(trusted_server_release_build) { + let error = + result.expect_err("should exclude the compiled developer checkout in release mode"); + assert!( + error + .to_string() + .contains("run `ts prebid client` from the Trusted Server repository"), + "should identify release source prerequisites" + ); + } else { + assert!( + result.is_ok(), + "should retain the compiled checkout fallback in development" + ); + } + } + fn managed_config(managed: &str, user_id: &str) -> String { format!( r#" diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index dd2f6a19e2..35026323fa 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -18,15 +18,17 @@ use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundl /// Immutable facts compiled into the owning CLI product. pub const BUILD_INFO: BuildInfo = BuildInfo::new( env!("CARGO_PKG_VERSION"), - env!("TRUSTED_SERVER_BUILD_TARGET"), + env!("TRUSTED_SERVER__BUILD_TARGET"), BuildVariant::Default, ); static VERSION_TEXT: LazyLock = LazyLock::new(|| { format!( - "{} ({})", + "package={} source={} target={} variant={}", BUILD_INFO.package_version(), - BUILD_INFO.source().unwrap_or("unknown") + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target(), + BUILD_INFO.variant() ) }); @@ -225,11 +227,12 @@ mod tests { assert_eq!( err.to_string(), format!( - "ts {} ({})\n", + "ts package={} source={} target={} variant=default\n", env!("CARGO_PKG_VERSION"), - trusted_server_core::constants::TS_GIT_VERSION.unwrap_or("unknown") + trusted_server_core::constants::TS_GIT_VERSION.unwrap_or("unknown"), + env!("TRUSTED_SERVER__BUILD_TARGET") ), - "should print the owning package version and compiled source identity" + "should print all four labeled owning build facts" ); } diff --git a/crates/trusted-server-cli/tests/release_build_facts.rs b/crates/trusted-server-cli/tests/release_build_facts.rs index 19f8722c89..d9aac5c94b 100644 --- a/crates/trusted-server-cli/tests/release_build_facts.rs +++ b/crates/trusted-server-cli/tests/release_build_facts.rs @@ -1,4 +1,4 @@ -//! Host-only checks of the actual owning build scripts and compiled facts. +//! Host checks of actual owning build scripts and the compiled CLI binary. #![cfg(not(target_arch = "wasm32"))] @@ -6,12 +6,12 @@ use std::ffi::OsStr; use std::fs; #[cfg(unix)] use std::os::unix::ffi::OsStrExt as _; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; +use trusted_server_cli::BUILD_INFO; +use trusted_server_core::build_info::BuildVariant; const SOURCE: &str = "abcdef0123456789abcdef0123456789abcdef01"; @@ -25,72 +25,24 @@ fn workspace() -> &'static Path { fn compile_build_script(owner: &str, directory: &TempDir) -> PathBuf { let output = directory.path().join(format!("{owner}-build")); - let mut compiler = Command::new("rustc"); - compiler + let compiled = Command::new("rustc") .args(["--edition=2024"]) .arg(workspace().join("crates").join(owner).join("build.rs")) .arg("-o") - .arg(&output); - if owner == "trusted-server-core" { - // Exercise the actual core emission control flow without linking its - // unrelated manifest-loader and template-digest dependencies. - let source = fs::read_to_string(workspace().join("crates/trusted-server-core/build.rs")) - .expect("should read core build script"); - let git_start = source.find("fn git(").expect("should locate Git lookup"); - let git_end = source.find("fn main()").expect("should locate core main"); - let emit_start = source - .find("fn emit_git_version()") - .expect("should locate core source emission"); - let emit_end = source[emit_start..] - .find("/// Hash the core implementation") - .expect("should locate following digest function") - + emit_start; - let fixture = directory.path().join("core-build.rs"); - fs::write( - &fixture, - format!( - r#" -use std::env; -use std::path::Path; -use std::process::Command; -#[path = "{}"] mod git_version; -use git_version::{{Candidates, is_usable, release_source, resolve_git_version}}; -const OVERRIDE_ENV: &str = "TRUSTED_SERVER__GIT_VERSION"; -{} -{} -fn main() {{ emit_git_version(); }} -"#, - workspace() - .join("crates/trusted-server-core/build_support/git_version.rs") - .display(), - &source[git_start..git_end], - &source[emit_start..emit_end] - ), - ) - .expect("should write actual core emission fixture"); - compiler = Command::new("rustc"); - compiler - .args(["--edition=2024"]) - .arg(fixture) - .arg("-o") - .arg(&output); - } - let compiled = compiler.output().expect("should run rustc"); + .arg(&output) + .output() + .expect("should run rustc"); assert!( compiled.status.success(), - "should compile the actual owner script: {}", + "should compile actual owner script: {}", String::from_utf8_lossy(&compiled.stderr) ); output } -fn owner_command(script: &Path, owner: &str, directory: &TempDir) -> Command { - let manifest = workspace().join("crates").join(owner); +fn owner_command(script: &Path) -> Command { let mut command = Command::new(script); command - .current_dir(&manifest) - .env("CARGO_MANIFEST_DIR", manifest) - .env("OUT_DIR", directory.path()) .env("TARGET", "example-target") .env_remove("TRUSTED_SERVER__RELEASE_BUILD") .env_remove("TRUSTED_SERVER__GIT_VERSION") @@ -98,29 +50,11 @@ fn owner_command(script: &Path, owner: &str, directory: &TempDir) -> Command { command } -#[cfg(unix)] -fn record_git(directory: &TempDir) -> PathBuf { - let script = directory.path().join("git"); - fs::write( - &script, - "#!/bin/sh\nprintf invoked >> \"$GIT_RECORD\"\nexit 1\n", - ) - .expect("should write fake git"); - fs::set_permissions(&script, fs::Permissions::from_mode(0o700)) - .expect("should make fake git executable"); - directory.path().join("git-record") -} - #[cfg(unix)] #[test] -fn release_scripts_reject_invalid_inputs_without_git_fallback() { +fn actual_owner_scripts_reject_invalid_release_controls_and_sources() { let directory = tempfile::tempdir().expect("should create fixture"); - let record = record_git(&directory); - for owner in [ - "trusted-server-cli", - "trusted-server-adapter-fastly", - "trusted-server-core", - ] { + for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { let script = compile_build_script(owner, &directory); for control in [ OsStr::new(""), @@ -129,18 +63,20 @@ fn release_scripts_reject_invalid_inputs_without_git_fallback() { OsStr::new("1\n"), OsStr::from_bytes(b"\xff"), ] { - let output = owner_command(&script, owner, &directory) - .env("PATH", directory.path()) - .env("GIT_RECORD", &record) + let output = owner_command(&script) .env("TRUSTED_SERVER__RELEASE_BUILD", control) .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) .output() - .expect("should run actual script"); + .expect("should run actual owner script"); assert!( !output.status.success(), - "should reject invalid release control for {owner}: {control:?}" + "should reject control for {owner}: {control:?}" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1"), + "should identify malformed release control" ); - assert!(!record.exists(), "should reject control before Git lookup"); } for source in [ None, @@ -152,235 +88,161 @@ fn release_scripts_reject_invalid_inputs_without_git_fallback() { Some(OsStr::new("abcdef0123456789abcdef0123456789abcdef01\n")), Some(OsStr::from_bytes(b"\xff")), ] { - let mut command = owner_command(&script, owner, &directory); - command - .env("PATH", directory.path()) - .env("GIT_RECORD", &record) - .env("TRUSTED_SERVER__RELEASE_BUILD", "1"); + let mut command = owner_command(&script); + command.env("TRUSTED_SERVER__RELEASE_BUILD", "1"); if let Some(source) = source { command.env("TRUSTED_SERVER__GIT_VERSION", source); } - let output = command.output().expect("should run actual script"); + let output = command.output().expect("should run actual owner script"); assert!( !output.status.success(), - "should reject malformed release source for {owner}: {source:?}" - ); - assert!(!record.exists(), "should reject source before Git lookup"); - } - let output = owner_command(&script, owner, &directory) - .env("PATH", directory.path()) - .env("GIT_RECORD", &record) - .env("TRUSTED_SERVER__RELEASE_BUILD", "1") - .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) - .output() - .expect("should run valid release script"); - assert!( - output.status.success(), - "should accept valid release source for {owner}: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - String::from_utf8_lossy(&output.stdout).contains(&format!( - "cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION={SOURCE}" - )), - "should emit the exact complete source" - ); - assert!( - !record.exists(), - "should emit release source without Git lookup" - ); - if owner == "trusted-server-core" { - let output = owner_command(&script, owner, &directory) - .env("PATH", directory.path()) - .env("GIT_RECORD", &record) - .output() - .expect("should run development source discovery"); - assert!( - output.status.success(), - "should retain development Git fallback" + "should reject source for {owner}: {source:?}" ); + let diagnostic = if source.and_then(OsStr::to_str).is_none() { + "release builds require TRUSTED_SERVER__GIT_VERSION" + } else { + "release source must be exactly 40 lowercase ASCII hexadecimal bytes" + }; assert!( - record.exists(), - "should prove the Git recorder detects development lookup" + String::from_utf8_lossy(&output.stderr).contains(diagnostic), + "should identify malformed release source: {diagnostic}" ); } } } #[test] -fn fastly_owns_the_reusable_sandbox_variant() { - let directory = tempfile::tempdir().expect("should create fixture"); - let owner = "trusted-server-adapter-fastly"; - let script = compile_build_script(owner, &directory); - for (feature, variant) in [(false, "default"), (true, "reusable-sandbox")] { - let mut command = owner_command(&script, owner, &directory); - command - .env("TRUSTED_SERVER__RELEASE_BUILD", "1") - .env("TRUSTED_SERVER__GIT_VERSION", SOURCE); - if feature { - command.env("CARGO_FEATURE_REUSABLE_SANDBOX", "1"); - } - let output = command.output().expect("should run actual Fastly script"); - assert!( - String::from_utf8_lossy(&output.stdout).contains(&format!( - "cargo:rustc-env=TRUSTED_SERVER_BUILD_VARIANT={variant}" - )), - "should derive the variant in the owning Fastly script" - ); - let expected = if feature { - "ReusableSandbox" - } else { - "Default" - }; - assert_eq!( - compile_fact_payload(owner, &directory, &String::from_utf8_lossy(&output.stdout)), - format!("9.8.7|{SOURCE}|example-target|{expected}\n"), - "should compile the owning feature variant into the facts" - ); - } -} - -#[test] -fn owners_emit_the_cargo_target() { - let directory = tempfile::tempdir().expect("should create fixture"); - for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { - let script = compile_build_script(owner, &directory); - let output = Command::new(script) - .env_remove("TRUSTED_SERVER__RELEASE_BUILD") - .env_remove("TRUSTED_SERVER__GIT_VERSION") - .env("TARGET", "example-target") - .output() - .expect("should run actual owner script"); - assert!(output.status.success(), "should build development facts"); - assert!( - String::from_utf8_lossy(&output.stdout) - .contains("cargo:rustc-env=TRUSTED_SERVER_BUILD_TARGET=example-target"), - "should derive the owning target from Cargo TARGET" - ); - } -} - -fn compile_fact_payload(owner: &str, directory: &TempDir, directives: &str) -> String { - let owner_file = if owner == "trusted-server-cli" { - "run.rs" - } else { - "main.rs" - }; - let source = fs::read_to_string( - workspace() - .join("crates") - .join(owner) - .join("src") - .join(owner_file), - ) - .expect("should read owning source"); - let start = source - .find("pub const BUILD_INFO:") - .expect("should expose owning immutable facts"); - let end = source[start..] - .find(';') - .expect("should end owning constant") - + start - + 1; - let record = &source[start..end]; - let fixture = directory.path().join("facts.rs"); - fs::write(&fixture, format!(r#" -extern crate self as trusted_server_core; -#[path = "{}"] pub mod build_info; -pub mod constants {{ pub const TS_GIT_VERSION: Option<&str> = option_env!("TRUSTED_SERVER__GIT_VERSION"); }} -use build_info::{{BuildInfo, BuildVariant}}; -{record} -fn main() {{ println!("{{}}|{{}}|{{}}|{{:?}}", BUILD_INFO.package_version(), BUILD_INFO.source().expect("should have source"), BUILD_INFO.target(), BUILD_INFO.variant()); }} -"#, workspace().join("crates/trusted-server-core/src/build_info.rs").display())).expect("should write fact fixture"); - let output = directory.path().join("facts"); - let mut compiler = Command::new("rustc"); - compiler - .args(["--edition=2024"]) - .arg(&fixture) - .arg("-o") - .arg(&output) - .env("CARGO_PKG_VERSION", "9.8.7"); - for line in directives.lines() { - if let Some(fact) = line.strip_prefix("cargo:rustc-env=") { - let (key, value) = fact.split_once('=').expect("should emit environment fact"); - compiler.env(key, value); - if key == "TRUSTED_SERVER_BUILD_VARIANT" && value == "reusable-sandbox" { - compiler.args(["--cfg", "feature=\"reusable-sandbox\""]); - } - } - } - let compiled = compiler.output().expect("should compile actual facts"); - assert!( - compiled.status.success(), - "should compile actual owning facts: {}", - String::from_utf8_lossy(&compiled.stderr) - ); - let output = Command::new(output) - .output() - .expect("should run compiled facts"); - assert!(output.status.success(), "should read immutable facts"); - String::from_utf8(output.stdout).expect("should emit UTF-8 facts") -} - -#[test] -fn external_tags_do_not_change_compiled_facts() { +fn actual_owner_scripts_emit_only_own_target_and_cli_release_cfg() { let directory = tempfile::tempdir().expect("should create fixture"); for owner in ["trusted-server-cli", "trusted-server-adapter-fastly"] { let script = compile_build_script(owner, &directory); - let mut facts = Vec::new(); + let mut outputs = Vec::new(); for tag in ["v1.2.3", "v1.2.4"] { - let output = owner_command(&script, owner, &directory) + let output = owner_command(&script) .env("TRUSTED_SERVER__RELEASE_BUILD", "1") .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) .env("GITHUB_REF_NAME", tag) .env("GITHUB_RUN_ID", tag) .output() .expect("should run release owner script"); - assert!(output.status.success(), "should emit valid release facts"); - facts.push(compile_fact_payload( - owner, - &directory, - &String::from_utf8_lossy(&output.stdout), - )); + assert!( + output.status.success(), + "should accept valid release source" + ); + let directives = + String::from_utf8(output.stdout).expect("should emit UTF-8 directives"); + assert!( + directives.contains("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET=example-target"), + "should emit the target owned by Cargo TARGET" + ); + assert!( + !directives.contains("cargo:rustc-env=TRUSTED_SERVER__GIT_VERSION="), + "should consume the source fact owned by core" + ); + assert!( + !directives.contains("BUILD_VARIANT"), + "should derive variant from owning feature cfg" + ); + if owner == "trusted-server-cli" { + assert!( + directives.contains("cargo:rustc-cfg=trusted_server_release_build"), + "should compile out the developer checkout fallback in release mode" + ); + } + outputs.push(directives); } assert_eq!( - facts[0], facts[1], - "should keep external tags and runs out of compiled facts" + outputs[0], outputs[1], + "should exclude external tags and run IDs from compiled facts" ); - assert_eq!( - facts[0], - format!("9.8.7|{SOURCE}|example-target|Default\n"), - "should use owning package version, exact source, Cargo target and variant" + let output = owner_command(&script) + .output() + .expect("should run development script"); + assert!( + output.status.success(), + "should preserve development builds" + ); + assert!( + !String::from_utf8_lossy(&output.stdout) + .contains("cargo:rustc-cfg=trusted_server_release_build"), + "should retain development resource discovery" ); } } #[test] -fn version_help_and_config_init_work_outside_the_checkout() { - let directory = tempfile::tempdir().expect("should create outside checkout fixture"); - let binary = env!("CARGO_BIN_EXE_ts"); - for argument in ["--help", "--version"] { - let output = Command::new(binary) +fn actual_cli_version_reads_all_owning_facts_and_ignores_external_tags() { + assert_eq!( + BUILD_INFO.package_version(), + env!("CARGO_PKG_VERSION"), + "should own package version" + ); + assert_eq!( + BUILD_INFO.source(), + trusted_server_core::constants::TS_GIT_VERSION, + "should consume the source owned by core" + ); + assert_eq!( + BUILD_INFO.target(), + env!("TRUSTED_SERVER__BUILD_TARGET"), + "should own Cargo target" + ); + assert_eq!( + BUILD_INFO.variant(), + BuildVariant::Default, + "should use CLI default variant" + ); + if cfg!(trusted_server_release_build) { + let source = BUILD_INFO + .source() + .expect("should compile release source into the actual CLI"); + assert_eq!(source.len(), 40, "should preserve complete release source"); + assert!( + source + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "should use lowercase source SHA in release facts" + ); + } + let directory = tempfile::tempdir().expect("should create outside-checkout fixture"); + for tag in ["v1.2.3", "v1.2.4"] { + let output = Command::new(env!("CARGO_BIN_EXE_ts")) .current_dir(directory.path()) - .arg(argument) + .arg("--version") + .env("GITHUB_REF_NAME", tag) + .env("GITHUB_RUN_ID", tag) .output() - .expect("should run portable CLI"); + .expect("should run actual CLI binary"); assert!( output.status.success(), - "should print {argument} outside checkout" + "should print version outside checkout" + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!( + "ts package={} source={} target={} variant=default\n", + BUILD_INFO.package_version(), + BUILD_INFO.source().unwrap_or("unknown"), + BUILD_INFO.target() + ), + "should label all four actual immutable facts" ); - if argument == "--version" { - assert_eq!( - String::from_utf8_lossy(&output.stdout), - format!( - "ts {} ({})\n", - env!("CARGO_PKG_VERSION"), - trusted_server_core::constants::TS_GIT_VERSION.unwrap_or("unknown") - ), - "should print package version and source" - ); - } } +} + +#[test] +fn help_and_config_init_work_outside_the_checkout() { + let directory = tempfile::tempdir().expect("should create outside-checkout fixture"); + let binary = env!("CARGO_BIN_EXE_ts"); + let output = Command::new(binary) + .current_dir(directory.path()) + .arg("--help") + .output() + .expect("should run portable CLI"); + assert!( + output.status.success(), + "should print help outside checkout" + ); let output = Command::new(binary) .current_dir(directory.path()) .args(["config", "init"]) @@ -394,104 +256,6 @@ fn version_help_and_config_init_work_outside_the_checkout() { fs::read_to_string(directory.path().join("trusted-server.toml")) .expect("should read config scaffold"), include_str!("../../../trusted-server.example.toml"), - "should use the tracked fictional scaffold" - ); -} - -#[test] -fn release_resource_discovery_excludes_the_compiled_developer_checkout() { - let directory = tempfile::tempdir().expect("should create fixture"); - let source = - fs::read_to_string(workspace().join("crates/trusted-server-cli/src/prebid_bundle.rs")) - .expect("should read resource discovery"); - let start = source - .find("fn find_js_lib_dir(") - .expect("should find discovery function"); - let end = source[start..] - .find("fn resolve_output_dir(") - .expect("should find following function") - + start; - let fixture = directory.path().join("discovery.rs"); - fs::write(&fixture, format!(r#" -use std::path::{{Path, PathBuf}}; -type CliResult = Result; -fn cli_error(message: impl Into) -> CliResult {{ Err(message.into()) }} -fn report_error(message: impl Into) -> String {{ message.into() }} -{} -fn main() {{ match find_js_lib_dir(Path::new(&std::env::args().nth(1).expect("should pass directory"))) {{ Ok(path) => println!("{{}}", path.display()), Err(error) => {{ eprintln!("{{error}}"); std::process::exit(1); }} }} }} -"#, &source[start..end])).expect("should write actual discovery fixture"); - let binary = directory.path().join("discovery"); - let owner = "trusted-server-cli"; - let script = compile_build_script(owner, &directory); - let directives = owner_command(&script, owner, &directory) - .env("TRUSTED_SERVER__RELEASE_BUILD", "1") - .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) - .output() - .expect("should run release CLI build script"); - assert!( - directives.status.success(), - "should emit release CLI compile directives" - ); - let directives = String::from_utf8(directives.stdout).expect("should emit UTF-8 directives"); - assert!( - directives.contains("cargo:rustc-check-cfg=cfg(trusted_server_release_build)"), - "should register the checked release cfg" - ); - let mut compiler = Command::new("rustc"); - compiler - .args(["--edition=2024"]) - .arg(&fixture) - .arg("-o") - .arg(&binary) - .env( - "CARGO_MANIFEST_DIR", - workspace().join("crates/trusted-server-cli"), - ); - for directive in directives - .lines() - .filter_map(|line| line.strip_prefix("cargo:rustc-cfg=")) - { - compiler.arg("--cfg").arg(directive); - } - let compiled = compiler.output().expect("should compile release discovery"); - assert!( - compiled.status.success(), - "should compile discovery fixture: {}", - String::from_utf8_lossy(&compiled.stderr) - ); - let output = Command::new(&binary) - .arg(directory.path()) - .output() - .expect("should discover release resources"); - assert!( - !output.status.success(), - "should reject the existing compiled developer checkout fallback" - ); - assert!( - String::from_utf8_lossy(&output.stderr) - .contains("run `ts prebid client` from the Trusted Server repository"), - "should explain source prerequisites" - ); - let bytes = fs::read(&binary).expect("should read compiled release fixture"); - let checkout = workspace().join("crates/trusted-server-cli"); - assert!( - !bytes - .windows(checkout.as_os_str().as_encoded_bytes().len()) - .any(|part| part == checkout.as_os_str().as_encoded_bytes()), - "should compile the developer checkout literal out of release discovery" - ); - let resources = directory.path().join("crates/trusted-server-js/lib"); - fs::create_dir_all(&resources).expect("should create current-checkout resources"); - fs::write(resources.join("package.json"), "{}").expect("should write package fixture"); - fs::write(resources.join("build-prebid-external.mjs"), "").expect("should write build fixture"); - let nested = directory.path().join("nested"); - fs::create_dir(&nested).expect("should create nested working directory"); - let output = Command::new(binary) - .arg(&nested) - .output() - .expect("should discover ancestor resources"); - assert!( - output.status.success(), - "should preserve explicit current-checkout ancestor discovery" + "should use tracked fictional scaffold" ); } diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 161227301e..6359efe62d 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use edgezero_core::manifest::ManifestLoader; -use git_version::{Candidates, is_usable, release_source, resolve_git_version}; +use git_version::{Candidates, is_usable, resolve_build_source, resolve_git_version}; use sha2::{Digest as _, Sha256}; /// Crate-relative build inputs that are both watched and hashed besides `src/`. @@ -161,36 +161,26 @@ fn emit_git_version() { println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); let control = env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); let source = env::var_os(OVERRIDE_ENV); - match release_source(control.as_deref(), source.as_deref()) { - Ok(Some(commit)) => { - println!("cargo:rustc-env={OVERRIDE_ENV}={commit}"); - return; - } - Ok(None) => {} + if control.is_none() + && let Some(value) = source.as_deref().and_then(std::ffi::OsStr::to_str) + && !is_usable(value) + && !value.trim().is_empty() + { + println!( + "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ + falling back to local git for x-ts-version" + ); + } + let resolved = match resolve_build_source( + control.as_deref(), + source.as_deref(), + resolve_from_local_git, + ) { + Ok(source) => source, Err(message) => { println!("cargo::error={message}"); std::process::exit(1); } - } - - let override_value = std::env::var(OVERRIDE_ENV).ok(); - let resolved = match override_value.as_deref() { - Some(value) if is_usable(value) => resolve_git_version(&Candidates { - override_value: Some(value), - exact_tag: None, - branch: None, - commit: None, - }), - Some(value) => { - if !value.trim().is_empty() { - println!( - "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ - falling back to local git for x-ts-version" - ); - } - resolve_from_local_git() - } - None => resolve_from_local_git(), }; // Always emitted, even when empty, so the raw pipeline value never reaches diff --git a/crates/trusted-server-core/build_support/git_version.rs b/crates/trusted-server-core/build_support/git_version.rs index d01a82341a..36f426372a 100644 --- a/crates/trusted-server-core/build_support/git_version.rs +++ b/crates/trusted-server-core/build_support/git_version.rs @@ -33,6 +33,32 @@ pub fn release_source( Ok(Some(source.to_owned())) } +/// Resolve the actual build source, calling local Git only in development. +/// +/// # Errors +/// +/// Returns release input errors before invoking the fallback. +pub fn resolve_build_source( + control: Option<&OsStr>, + source: Option<&OsStr>, + local_git: impl FnOnce() -> Option, +) -> Result, &'static str> { + if let Some(commit) = release_source(control, source)? { + return Ok(Some(commit)); + } + let override_value = source.and_then(OsStr::to_str); + if override_value.is_some_and(is_usable) { + Ok(resolve_git_version(&Candidates { + override_value, + exact_tag: None, + branch: None, + commit: None, + })) + } else { + Ok(local_git()) + } +} + /// Raw candidates for the deployed git version, in priority order. pub struct Candidates<'a> { /// `TRUSTED_SERVER__GIT_VERSION`, supplied by the deploy pipeline. diff --git a/crates/trusted-server-core/src/build_info.rs b/crates/trusted-server-core/src/build_info.rs index c04455bcaf..2809ded936 100644 --- a/crates/trusted-server-core/src/build_info.rs +++ b/crates/trusted-server-core/src/build_info.rs @@ -7,11 +7,13 @@ use crate::constants::TS_GIT_VERSION; /// The owning product's compile-time feature variant. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, derive_more::Display)] pub enum BuildVariant { /// The product's default feature set. + #[display("default")] Default, /// Fastly's opt-in reusable sandbox feature. + #[display("reusable-sandbox")] ReusableSandbox, } @@ -78,3 +80,22 @@ impl BuildInfo { self.variant } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variants_display_canonical_product_labels() { + assert_eq!( + BuildVariant::Default.to_string(), + "default", + "should label the default product variant" + ); + assert_eq!( + BuildVariant::ReusableSandbox.to_string(), + "reusable-sandbox", + "should label the reusable sandbox product variant" + ); + } +} diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 1fd3c2382c..f2a5e89a33 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -32,14 +32,17 @@ pub const HEADER_X_COMPRESS_HINT: HeaderName = HeaderName::from_static("x-compre pub const HEADER_X_DEBUG_FASTLY_POP: HeaderName = HeaderName::from_static("x-debug-fastly-pop"); // Staging / version identification headers -/// Deployed git version: tag, else branch, else 6-char commit (see [`TS_GIT_VERSION`]). +/// Release source commit or development identity (see [`TS_GIT_VERSION`]). pub const HEADER_X_TS_VERSION: HeaderName = HeaderName::from_static("x-ts-version"); /// Fastly service version (`FASTLY_SERVICE_VERSION`), formerly sent as `x-ts-version`. pub const HEADER_X_TS_FASTLY_VERSION: HeaderName = HeaderName::from_static("x-ts-fastly-version"); pub const HEADER_X_TS_ENV: HeaderName = HeaderName::from_static("x-ts-env"); -/// Deployed git version compiled in by `build.rs`, from the deploy pipeline's -/// `TRUSTED_SERVER__GIT_VERSION` or local git. `None` when unknown. +/// Source identity compiled in by `build.rs`. +/// +/// Release builds use the full source commit SHA. Development builds use the +/// pipeline override, exact Git tag, branch, or six-character commit fallback. +/// `None` when unknown. pub const TS_GIT_VERSION: Option<&str> = match option_env!("TRUSTED_SERVER__GIT_VERSION") { Some(version) if !version.is_empty() => Some(version), _ => None, diff --git a/crates/trusted-server-core/tests/git_version_resolve.rs b/crates/trusted-server-core/tests/git_version_resolve.rs index 770ef0b3a5..c1dbbd58f7 100644 --- a/crates/trusted-server-core/tests/git_version_resolve.rs +++ b/crates/trusted-server-core/tests/git_version_resolve.rs @@ -201,3 +201,54 @@ fn non_unicode_release_inputs_fail_closed() { "should reject non-Unicode source" ); } + +#[test] +fn actual_build_source_resolution_never_calls_git_in_release_mode() { + let source = + git_version::resolve_build_source(Some(OsStr::new("1")), Some(OsStr::new(COMMIT)), || { + panic!("should not call Git in release mode") + }) + .expect("should resolve valid release source"); + assert_eq!( + source.as_deref(), + Some(COMMIT), + "should retain the complete release commit" + ); + let result = + git_version::resolve_build_source(Some(OsStr::new("1")), Some(OsStr::new("main")), || { + panic!("should not fall back after invalid release source") + }); + assert!( + result.is_err(), + "should reject malformed release source without Git fallback" + ); +} + +#[test] +fn actual_build_source_resolution_preserves_development_override_and_fallback() { + for identity in ["main", " v1.2.3 ", COMMIT] { + let source = git_version::resolve_build_source(None, Some(OsStr::new(identity)), || { + panic!("should not call Git with a usable development override") + }) + .expect("should resolve development override"); + assert_eq!( + source.as_deref(), + Some(identity.trim()), + "should preserve development override" + ); + } + for identity in [None, Some(OsStr::new("")), Some(OsStr::new("bad identity"))] { + let mut called = false; + let source = git_version::resolve_build_source(None, identity, || { + called = true; + Some("local-branch".to_owned()) + }) + .expect("should resolve development fallback"); + assert!(called, "should call actual fallback for unusable override"); + assert_eq!( + source.as_deref(), + Some("local-branch"), + "should use fallback result" + ); + } +} diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 3a81942f38..7921537dcb 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -28,7 +28,7 @@ use sha2::{Digest as _, Sha256}; use self::bundle_set::{bundle_file_name, check_bundle_set, expected_module_ids, scan_bundle_dir}; const PREBUILT_DIR_VAR: &str = "TSJS_PREBUILT_DIR"; -const INVENTORY_VAR: &str = "TSJS_PREBUILT_INVENTORY"; +const INVENTORY_VAR: &str = "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY"; const RELEASE_VAR: &str = "TRUSTED_SERVER__RELEASE_BUILD"; const SOURCE_VAR: &str = "TRUSTED_SERVER__GIT_VERSION"; const SKIP_BUILD_VAR: &str = "TSJS_SKIP_BUILD"; @@ -119,38 +119,41 @@ pub(crate) fn run_with_hook(hook: impl FnMut(&Path, &Path)) { "tsjs: Using prebuilt bundles from {}", prebuilt_dir.display() ); - #[cfg(any(target_os = "linux", target_os = "macos"))] - { - let inventory = inventory_path.as_ref().map(|path| { - let expected_source = source.as_ref().map(|source| { - source - .to_str() - .expect("should provide a UTF-8 source when checking inventory") + if let Some(path) = inventory_path { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + prebuilt::inventory_outside(&prebuilt_dir, &path).unwrap_or_else(|error| { + panic!("tsjs: invalid inventory path {}: {error}", path.display()) + }); + let inventory = inventory::read_inventory(&path, &expected, release.as_deref()) + .unwrap_or_else(|error| { + panic!("tsjs: invalid inventory {}: {error}", path.display()) + }); + prebuilt::copy_checked( + &expected, + &prebuilt_dir, + &bundle_dir, + &inventory.inventory, + hook, + ) + .unwrap_or_else(|error| { + panic!( + "tsjs: invalid prebuilt inventory inputs in {}: {error}", + prebuilt_dir.display() + ) }); - prebuilt::inventory_outside(&prebuilt_dir, path) - .unwrap_or_else(|error| panic!("tsjs: invalid inventory path: {error}")); - inventory::read_inventory(path, &expected, expected_source) - .unwrap_or_else(|error| panic!("tsjs: invalid inventory: {error}")) - }); - prebuilt::copy_checked( - &expected, - &prebuilt_dir, - &bundle_dir, - inventory.as_ref().map(|checked| &checked.inventory), - hook, - ) - .unwrap_or_else(|error| panic!("tsjs: invalid prebuilt inventory inputs: {error}")); - if let Some(inventory) = inventory { // Cargo JSON build-script messages expose the prepared input // identity to the later release evidence collector. println!( - "cargo:rustc-env=TSJS_INPUT_INVENTORY_SHA256={}", + "cargo:rustc-env=TRUSTED_SERVER__TSJS_INVENTORY_SHA256={}", inventory.raw_sha256 ); } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + panic!("tsjs: checked prebuilt copying requires a supported Linux or macOS build host"); + } else { + copy_prebuilt(&expected, &prebuilt_dir, &bundle_dir); } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - panic!("tsjs: checked prebuilt copying requires a supported Linux or macOS build host"); } else { build_bundles(&ts_dir, &bundle_dir); } @@ -165,6 +168,22 @@ pub(crate) fn run_with_hook(hook: impl FnMut(&Path, &Path)) { write_module_table(&expected, &bundle_dir, &out_dir); } +fn copy_prebuilt(expected: &[String], source: &Path, destination: &Path) { + let found = + bundle_set::scan_legacy_bundle_dir(source).unwrap_or_else(|error| panic!("tsjs: {error}")); + check_bundle_set(expected, &found) + .unwrap_or_else(|error| panic!("tsjs: invalid prebuilt bundle set: {error}")); + if destination.exists() { + fs::remove_dir_all(destination).expect("should clear private prebuilt destination"); + } + fs::create_dir_all(destination).expect("should create private prebuilt destination"); + for id in expected { + let filename = bundle_file_name(id); + fs::copy(source.join(&filename), destination.join(&filename)) + .unwrap_or_else(|error| panic!("tsjs: failed to copy {filename}: {error}")); + } +} + fn build_bundles(ts_dir: &Path, bundle_dir: &Path) { let how_to_prebuild = format!( "To embed prebuilt bundles instead, run `npm run build` in {} and set \ diff --git a/crates/trusted-server-js/build/bundle_set.rs b/crates/trusted-server-js/build/bundle_set.rs index ec80d606e0..acb6edd805 100644 --- a/crates/trusted-server-js/build/bundle_set.rs +++ b/crates/trusted-server-js/build/bundle_set.rs @@ -59,8 +59,8 @@ pub(crate) fn expected_module_ids(lib_src: &Path) -> Result, String> let mut ids = Vec::with_capacity(integrations.len() + 1); ids.push(CORE_MODULE_ID.to_owned()); ids.extend(integrations); - if ids.iter().any(|id| !safe_module_id(id)) { - return Err("module IDs must be safe ASCII identifiers".to_owned()); + if let Some(id) = ids.iter().find(|id| !safe_module_id(id)) { + return Err(format!("module ID {id} must be a safe ASCII identifier")); } if ids .iter() @@ -79,6 +79,19 @@ pub(crate) fn expected_module_ids(lib_src: &Path) -> Result, String> /// /// Returns a message when the directory or a bundle's metadata cannot be read. pub(crate) fn scan_bundle_dir(dir: &Path) -> Result, String> { + scan(dir, true) +} + +/// Scan development prebuilt bundles, ignoring legacy nonbundle output. +/// +/// # Errors +/// +/// Returns a message when the directory or a bundle's metadata cannot be read. +pub(crate) fn scan_legacy_bundle_dir(dir: &Path) -> Result, String> { + scan(dir, false) +} + +fn scan(dir: &Path, checked: bool) -> Result, String> { let entries = fs::read_dir(dir) .map_err(|err| format!("failed to read bundle directory {}: {err}", dir.display()))?; let mut found = Vec::new(); @@ -90,10 +103,17 @@ pub(crate) fn scan_bundle_dir(dir: &Path) -> Result, String> .strip_prefix("tsjs-") .and_then(|stem| stem.strip_suffix(".js")) else { - return Err(format!("unexpected file in bundle directory: {file_name}")); + if checked { + return Err(format!("unexpected file in bundle directory: {file_name}")); + } + continue; }; - let metadata = fs::symlink_metadata(entry.path()) - .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))?; + let metadata = if checked { + fs::symlink_metadata(entry.path()) + } else { + fs::metadata(entry.path()) + } + .map_err(|err| format!("failed to read metadata for {file_name}: {err}"))?; if !safe_module_id(id) || !metadata.is_file() { return Err(format!("unsafe or nonregular bundle: {file_name}")); } @@ -163,6 +183,30 @@ mod tests { .collect() } + #[test] + fn module_identifiers_accept_only_safe_ascii() { + for id in ["core", "example_1", "example-2", "UPPER"] { + assert!(safe_module_id(id), "should accept safe ID {id}"); + } + for id in ["", "../core", "has space", "quote\"", "é", "a/b", "a\\b"] { + assert!(!safe_module_id(id), "should reject unsafe ID {id:?}"); + } + } + + #[test] + fn rejects_duplicate_found_ids_including_core() { + for id in ["core", "gpt"] { + let mut entries = found(&[("core", 10), ("gpt", 20), ("prebid", 30)]); + entries.push((id.to_owned(), 10)); + let error = check_bundle_set(&expected(), &entries) + .expect_err("should reject duplicate found ID"); + assert!( + error.contains("duplicate bundle IDs"), + "should explain duplicate: {error}" + ); + } + } + fn found(entries: &[(&str, u64)]) -> Vec<(String, u64)> { entries .iter() diff --git a/crates/trusted-server-js/build/inventory.rs b/crates/trusted-server-js/build/inventory.rs index d0c1d13273..0b5b5ad587 100644 --- a/crates/trusted-server-js/build/inventory.rs +++ b/crates/trusted-server-js/build/inventory.rs @@ -60,13 +60,15 @@ pub(crate) fn read_inventory( expected: &[String], source: Option<&str>, ) -> io::Result { + let leaf_path = path.components().collect::(); #[cfg(unix)] let file = OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) - .open(path)?; + .open(&leaf_path) + .map_err(|error| invalid(format!("cannot open inventory {}: {error}", path.display())))?; #[cfg(not(unix))] - let file = File::open(path)?; + let file = File::open(&leaf_path)?; let metadata = file.metadata()?; if !metadata.is_file() { return Err(invalid("inventory must be a regular file")); @@ -178,12 +180,18 @@ fn validate_numbers(text: &str) -> io::Result<()> { index += 1; } let number = &text[start..index]; - if number.contains(['.', 'e', 'E']) { - if !number.parse::().is_ok_and(f64::is_finite) { - return Err(invalid("inventory number overflows")); - } - } else if (number.starts_with('-') && number.parse::().is_err()) - || (!number.starts_with('-') && number.parse::().is_err()) + let value = number + .parse::() + .map_err(|_| invalid("invalid inventory number"))?; + if !value.is_finite() { + return Err(invalid("inventory number overflows")); + } + if value.fract() == 0.0 && value.abs() > 9_007_199_254_740_991.0 { + return Err(invalid("inventory contains an unsafe integer")); + } + if !number.contains(['.', 'e', 'E']) + && ((number.starts_with('-') && number.parse::().is_err()) + || (!number.starts_with('-') && number.parse::().is_err())) { return Err(invalid("inventory integer overflows")); } @@ -294,4 +302,36 @@ mod tests { "should reject links at the actual inventory read" ); } + + #[test] + fn declared_bundle_cap_is_enforced_by_inventory_parser() { + let root = tempdir().expect("should create inventory fixture"); + let path = root.path().join("inventory.json"); + const CONTRACT_LIMIT: u64 = 32 * 1024 * 1024; + for (size, accepted) in [(CONTRACT_LIMIT, true), (CONTRACT_LIMIT + 1, false)] { + fs::write( + &path, + serde_json::to_vec(&json!({ + "schema_version": 1, "source_commit": "a".repeat(40), + "modules": [{"id": "core", "filename": "tsjs-core.js", "size": size, + "sha256": "a".repeat(64), "contributions": []}], + "tools": [], "materials": [] + })) + .expect("should serialize inventory"), + ) + .expect("should write inventory"); + let result = read_inventory(&path, &["core".to_owned()], None); + if accepted { + assert!(result.is_ok(), "should accept exact declared bundle cap"); + } else { + let error = result + .err() + .expect("should reject oversized declared bundle"); + assert!( + error.to_string().contains("size"), + "should explain inventory size rejection: {error}" + ); + } + } + } } diff --git a/crates/trusted-server-js/build/prebuilt.rs b/crates/trusted-server-js/build/prebuilt.rs index 8224d9b8ba..80cac5c6fd 100644 --- a/crates/trusted-server-js/build/prebuilt.rs +++ b/crates/trusted-server-js/build/prebuilt.rs @@ -42,10 +42,13 @@ fn identity(file: &File) -> io::Result { } fn open_directory(path: &Path) -> io::Result { + // Components remove trailing separators and '.' without following links. + let leaf_path = path.components().collect::(); OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(path) + .open(&leaf_path) + .map_err(|error| invalid(format!("cannot open directory {}: {error}", path.display()))) } fn open_file(directory: &File, name: &str) -> io::Result { @@ -60,15 +63,18 @@ fn open_file(directory: &File, name: &str) -> io::Result { ) }; if descriptor < 0 { - return Err(io::Error::last_os_error()); + return Err(invalid(format!( + "cannot open bundle {name:?}: {}", + io::Error::last_os_error() + ))); } // SAFETY: openat returned a fresh descriptor owned solely by this File. let file = unsafe { File::from_raw_fd(descriptor) }; let metadata = file.metadata()?; if !metadata.is_file() || metadata.nlink() != 1 { - return Err(invalid( - "bundle must be a regular file with exactly one hard link", - )); + return Err(invalid(format!( + "bundle {name:?} must be a regular file with exactly one hard link" + ))); } Ok(file) } @@ -128,7 +134,9 @@ fn names(directory: &File, maximum: usize) -> io::Result> { .map_err(|_| invalid("bundle filename must be UTF-8"))?; if name != "." && name != ".." { if result.len() >= maximum { - return Err(invalid("unexpected extra bundle directory entry")); + return Err(invalid(format!( + "unexpected extra bundle directory entry: {name}" + ))); } result.push(name.to_owned()); } @@ -157,7 +165,7 @@ fn snapshot(directory: &File, expected: &[String]) -> io::Result, + inventory: &Inventory, mut hook: impl FnMut(&Path, &Path), ) -> io::Result<()> { let source = open_directory(source_path)?; @@ -229,14 +237,17 @@ pub(crate) fn copy_checked( return Err(invalid("source bundle identity changed before copy")); } let module = inventory - .and_then(|inventory| inventory.modules.iter().find(|module| module.id == *id)); - let size = module.map_or(file.metadata()?.len(), |module| module.size); + .modules + .iter() + .find(|module| module.id == *id) + .ok_or_else(|| invalid(format!("inventory missing bundle {name}")))?; + let size = module.size; if size > MAX_BUNDLE { return Err(invalid("bundle exceeds 32 MiB")); } let digest = copy_one(&mut file, &destination.join(&name), size)?; - if module.is_some_and(|module| module.sha256 != digest) { - return Err(invalid("inventory bundle digest mismatch")); + if module.sha256 != digest { + return Err(invalid(format!("inventory bundle digest mismatch: {name}"))); } if before.get(&name) != Some(&identity(&file)?) { return Err(invalid("source bundle changed during copy")); diff --git a/crates/trusted-server-js/lib/.gitignore b/crates/trusted-server-js/lib/.gitignore index 56c5a23dd6..faf0d13fbd 100644 --- a/crates/trusted-server-js/lib/.gitignore +++ b/crates/trusted-server-js/lib/.gitignore @@ -4,3 +4,4 @@ src/integrations/prebid/.external-generated-* # Serializes `npm ci` across concurrent cargo build scripts .tsjs-npm-ci.lock +.release-test-*/ diff --git a/crates/trusted-server-js/lib/.prettierignore b/crates/trusted-server-js/lib/.prettierignore index 72274829b1..27cbce6205 100644 --- a/crates/trusted-server-js/lib/.prettierignore +++ b/crates/trusted-server-js/lib/.prettierignore @@ -1,4 +1,5 @@ node_modules dist coverage +.release-test-*/ diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs index e5500581d1..e6f341a810 100644 --- a/crates/trusted-server-js/lib/build-all.mjs +++ b/crates/trusted-server-js/lib/build-all.mjs @@ -116,6 +116,6 @@ export async function buildAll({ return records.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); } -if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { +if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { await buildAll({ outDir: resolveOutDir(process.argv.slice(2)) }); } diff --git a/crates/trusted-server-js/lib/eslint.config.js b/crates/trusted-server-js/lib/eslint.config.js index 2720ba3a04..725a5b6816 100644 --- a/crates/trusted-server-js/lib/eslint.config.js +++ b/crates/trusted-server-js/lib/eslint.config.js @@ -9,7 +9,7 @@ import unicorn from 'eslint-plugin-unicorn'; export default [ // Files/folders to ignore { - ignores: ['node_modules', 'dist', 'coverage'], + ignores: ['node_modules', 'dist', 'coverage', '.release-test-*/'], }, // Base JS recommended js.configs.recommended, diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json index 427fef1e1f..c34b9f80d6 100644 --- a/crates/trusted-server-js/lib/package.json +++ b/crates/trusted-server-js/lib/package.json @@ -12,8 +12,8 @@ "test:watch": "vitest", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint --fix . --max-warnings=0", - "format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"", - "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\"" + "format": "prettier --check \"**/*.{ts,tsx,js,mjs,json,css,md}\"", + "format:write": "prettier --write \"**/*.{ts,tsx,js,mjs,json,css,md}\"" }, "dependencies": { "prebid.js": "^10.26.0" diff --git a/crates/trusted-server-js/lib/release-composition.mjs b/crates/trusted-server-js/lib/release-composition.mjs index 61993b56aa..7af052b226 100644 --- a/crates/trusted-server-js/lib/release-composition.mjs +++ b/crates/trusted-server-js/lib/release-composition.mjs @@ -87,8 +87,8 @@ export function strictJson(bytes) { /** Derive release settings from the existing build control only. */ export function releaseBuild(env = process.env) { - if (Object.hasOwn(env, 'TSJS_SKIP_BUILD')) throw new Error('TSJS_SKIP_BUILD is forbidden'); if (!Object.hasOwn(env, 'TRUSTED_SERVER__RELEASE_BUILD')) return false; + if (Object.hasOwn(env, 'TSJS_SKIP_BUILD')) throw new Error('TSJS_SKIP_BUILD is forbidden'); if (env.TRUSTED_SERVER__RELEASE_BUILD !== '1') throw new Error('Release build control must be exactly 1'); if (!/^[a-f0-9]{40}$/.test(env.TRUSTED_SERVER__GIT_VERSION ?? '')) @@ -195,7 +195,7 @@ export function installedOrigins({ repoRoot, lockFile, installRoot = path.dirnam } if ( fs.existsSync(path.join(directory, 'package.json')) && - relative(installRoot, directory).includes('node_modules/') + /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/]+$/.test(lockPath) ) throw new Error(`Unmapped installed ownership: ${id}`); if (directory === installRoot) break; @@ -209,8 +209,8 @@ export function installedOrigins({ repoRoot, lockFile, installRoot = path.dirnam } /** Observe exactly one IIFE's rendered modules without altering emitted output. */ -export function compositionPlugin({ name, ...settings }) { - const owners = installedOrigins(settings); +export function compositionPlugin({ name, owners: checkedOwners, ...settings }) { + const owners = checkedOwners ?? installedOrigins(settings); return { name: 'trusted-server-release-composition', generateBundle(options, bundle) { diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs index 1b1390dc97..c9acc63013 100644 --- a/crates/trusted-server-js/lib/test/release-composition.test.mjs +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { build } from 'vite'; @@ -22,6 +23,7 @@ function fixture() { return root; } afterEach(() => { + vi.unstubAllEnvs(); for (const root of temporary.splice(0)) { if (fs.lstatSync(root, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(root); else fs.rmSync(root, { recursive: true, force: true }); @@ -39,6 +41,55 @@ function recorder(onContributions, overrides = {}) { } describe('release Rollup composition', () => { + it('reuses checked installed ownership for recorded module builds', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const owners = installedOrigins({ repoRoot, lockFile }); + const root = fixture(); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(entry, 'globalThis.exampleResult = 7;'); + let rows; + const recorded = await bundle( + root, + entry, + recorder( + (value) => { + rows = value; + }, + { + owners, + lockFile: path.join(root, 'unused-lock.json'), + } + ) + ); + expect(recorded).toEqual(await bundle(root, entry)); + expect(rows).toHaveLength(1); + }); + it('omits a rendered zero-length namespace module without changing bundle bytes', async () => { + const root = fixture(); + const empty = path.join(root, 'empty.ts'); + const entry = path.join(root, 'index.ts'); + fs.writeFileSync(empty, 'export {};'); + fs.writeFileSync(entry, "import * as empty from './empty'; globalThis.exampleResult = empty;"); + let rows; + let renderedLength; + const observe = { + name: 'example-rendered-module-observer', + generateBundle(_options, output) { + renderedLength = Object.values(output)[0].modules[empty].renderedLength; + }, + }; + const recorded = await bundle( + root, + entry, + recorder((value) => { + rows = value; + }), + [observe] + ); + expect(renderedLength).toBe(0); + expect(rows.some((row) => row.path?.endsWith('/empty.ts'))).toBe(false); + expect(recorded).toEqual(await bundle(root, entry)); + }); it('records retained source and transitive npm helpers without changing final bytes', async () => { const root = fixture(); const entry = path.join(root, 'index.ts'); @@ -188,6 +239,73 @@ describe('release Rollup composition', () => { }); describe('release collector boundaries', () => { + it('accepts only absent or exact 1 for the optional full collector test control', async () => { + for (const value of ['', '0', 'true']) { + vi.stubEnv('TRUSTED_SERVER__RELEASE_JS_TESTS', value); + vi.resetModules(); + await expect(import('../vitest.config.ts')).rejects.toThrow(/RELEASE_JS_TESTS.*exactly 1/); + } + for (const value of [undefined, '1']) { + vi.stubEnv('TRUSTED_SERVER__RELEASE_JS_TESTS', value); + vi.resetModules(); + await expect(import('../vitest.config.ts')).resolves.toHaveProperty('default.test'); + } + }); + it('runs symlinked build and collector CLIs while imported entry points stay inert', async () => { + const root = fixture(); + const direct = path.join(root, 'direct'); + const linked = path.join(root, 'linked'); + const buildScript = path.join(libDir, 'build-all.mjs'); + const buildLink = path.join(root, 'build-link.mjs'); + fs.symlinkSync(buildScript, buildLink); + const env = { ...process.env, TSJS_SKIP_BUILD: '1' }; + delete env.TRUSTED_SERVER__RELEASE_BUILD; + const run = (script, output) => + spawnSync(process.execPath, [script, '--out-dir', output], { env, encoding: 'utf8' }); + expect(run(buildScript, direct).status).toBe(0); + expect(run(buildLink, linked).status).toBe(0); + const files = fs.readdirSync(direct).sort(); + expect(files).toContain('tsjs-core.js'); + expect(files.length).toBeGreaterThan(1); + expect(fs.readdirSync(linked).sort()).toEqual(files); + for (const file of files) + expect(fs.readFileSync(path.join(linked, file))).toEqual( + fs.readFileSync(path.join(direct, file)) + ); + const collector = path.join(repoRoot, 'scripts/release/collect-js.mjs'); + const collectorLink = path.join(root, 'collector-link.mjs'); + fs.symlinkSync(collector, collectorLink); + const invalid = spawnSync(process.execPath, [collectorLink], { env, encoding: 'utf8' }); + expect(invalid.status).not.toBe(0); + expect(invalid.stderr).toMatch(/Usage: collect-js/); + const imported = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import ${JSON.stringify(pathToFileURL(buildScript).href)}; import ${JSON.stringify(pathToFileURL(collector).href)};`, + ], + { env, encoding: 'utf8' } + ); + expect(imported.status).toBe(0); + expect(imported.stdout).toBe(''); + }, 30_000); + it('reaches bootstrap refusal from absolute, relative and own-directory shell invocation', () => { + const directory = path.join(repoRoot, 'scripts/release'); + for (const file of ['build.sh', 'build-js.sh']) { + for (const [cwd, script] of [ + [repoRoot, path.join(directory, file)], + [repoRoot, `scripts/release/${file}`], + [directory, file], + ]) { + const result = spawnSync('/bin/sh', [script], { cwd, encoding: 'utf8' }); + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe( + 'Release bootstrap is pending the reviewed Rust tooling replacement' + ); + } + } + }); it.each([ '{"a":1,"a":2}', '{"a":{"b":1,"b":2}}', @@ -206,8 +324,16 @@ describe('release collector boundaries', () => { const { releaseBuild } = await import('../release-composition.mjs'); for (const control of ['', '0', 'true']) expect(() => releaseBuild({ TRUSTED_SERVER__RELEASE_BUILD: control })).toThrow(); - for (const skip of ['', '0', '1']) - expect(() => releaseBuild({ TSJS_SKIP_BUILD: skip })).toThrow(); + for (const skip of ['', '0', '1']) { + expect(releaseBuild({ TSJS_SKIP_BUILD: skip })).toBe(false); + expect(() => + releaseBuild({ + TSJS_SKIP_BUILD: skip, + TRUSTED_SERVER__RELEASE_BUILD: '1', + TRUSTED_SERVER__GIT_VERSION: 'a'.repeat(40), + }) + ).toThrow(/skip/i); + } expect(() => releaseBuild({ TRUSTED_SERVER__RELEASE_BUILD: '1' })).toThrow(); expect( releaseBuild({ @@ -220,10 +346,9 @@ describe('release collector boundaries', () => { const root = fixture(); const entry = path.join(root, 'index.ts'); fs.writeFileSync(entry, 'globalThis.exampleResult = import.meta.env.VITE_EXAMPLE;'); - const oldRelease = process.env.TRUSTED_SERVER__RELEASE_BUILD; - const oldSource = process.env.TRUSTED_SERVER__GIT_VERSION; - process.env.TRUSTED_SERVER__RELEASE_BUILD = '1'; - process.env.TRUSTED_SERVER__GIT_VERSION = 'a'.repeat(40); + vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', '1'); + vi.stubEnv('TRUSTED_SERVER__GIT_VERSION', 'a'.repeat(40)); + vi.stubEnv('TSJS_SKIP_BUILD', undefined); try { const options = moduleBuildOptions({ name: 'fixture', @@ -239,6 +364,7 @@ describe('release collector boundaries', () => { fs.writeFileSync(path.join(root, file), 'VITE_EXAMPLE=fictional-environment-value'); await build(options); expect(fs.readFileSync(path.join(root, 'out/tsjs-fixture.js'))).toEqual(expected); + vi.resetModules(); const config = (await import('../vitest.config.ts')).default; expect(config.envDir).toBe(false); expect(config.mode).toBe('production'); @@ -248,7 +374,7 @@ describe('release collector boundaries', () => { ); expect(nativeImports.resolveId('node:fs')).toEqual({ id: 'node:fs', external: true }); expect(nativeImports.resolveId('dlv')).toBeUndefined(); - delete process.env.TRUSTED_SERVER__RELEASE_BUILD; + vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', undefined); vi.resetModules(); const ordinary = (await import('../vitest.config.ts')).default; expect(ordinary.server).toBeUndefined(); @@ -256,18 +382,14 @@ describe('release collector boundaries', () => { expect(ordinary.envDir).toBeUndefined(); expect(ordinary.mode).toBeUndefined(); } finally { - if (oldRelease === undefined) delete process.env.TRUSTED_SERVER__RELEASE_BUILD; - else process.env.TRUSTED_SERVER__RELEASE_BUILD = oldRelease; - if (oldSource === undefined) delete process.env.TRUSTED_SERVER__GIT_VERSION; - else process.env.TRUSTED_SERVER__GIT_VERSION = oldSource; + vi.unstubAllEnvs(); } }); it('rejects source symlink escape and unknown transform queries', async () => { const { installedOrigins } = await import('../release-composition.mjs'); const root = fixture(); - const external = fs.mkdtempSync( - path.join(process.env.TMPDIR ?? '/private/tmp', 'tsjs-outside-') - ); + vi.stubEnv('TMPDIR', undefined); + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'tsjs-outside-')); temporary.push(external); fs.writeFileSync(path.join(external, 'outside.ts'), 'export const example = 7;'); const link = path.join(root, 'alias.ts'); @@ -278,7 +400,6 @@ describe('release collector boundaries', () => { }); it('requires checked preparation records rather than inventing lifecycle observations', async () => { const collector = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); - expect(typeof collector.collectJs).toBe('function'); await expect( collector.collectJs({ repoRoot, @@ -292,7 +413,6 @@ describe('release collector boundaries', () => { describe('prepared evidence and installed trees', () => { it('hashes normalized executable modes and excludes separately owned nested packages', async () => { const { installedTreeHash } = await import('../release-composition.mjs'); - expect(typeof installedTreeHash).toBe('function'); const root = fixture(); fs.writeFileSync(path.join(root, 'example.js'), 'example bytes'); const nested = path.join(root, 'node_modules/example-dependency'); @@ -307,7 +427,6 @@ describe('prepared evidence and installed trees', () => { }); it('checks preparation distribution file and lifecycle output hashes', async () => { const { validatePreparation } = await import('../release-composition.mjs'); - expect(typeof validatePreparation).toBe('function'); const root = fixture(); const file = path.join(root, 'tool'); fs.writeFileSync(file, 'fictional distribution bytes'); @@ -381,7 +500,6 @@ describe('prepared evidence and installed trees', () => { describe('complete inventory producer', () => { it('binds measured bundler native files separately from npm archive integrity', async () => { const { actualToolFiles } = await import('../release-composition.mjs'); - expect(typeof actualToolFiles).toBe('function'); const tools = actualToolFiles({ installRoot: libDir }); expect(tools.map((tool) => tool.name)).toEqual( expect.arrayContaining([ @@ -495,131 +613,145 @@ describe('complete inventory producer', () => { ).toMatchObject({ lock_path: key, package_name: 'example-child', path: 'index.js' }); }); - it('writes deterministic closed inventory records after final Vite/esbuild output', async () => { - const { actualToolFiles, expectedLifecycleEvents, installedOrigins, installedMaterials } = - await import('../release-composition.mjs'); - const { collectJs } = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); - expect(typeof actualToolFiles).toBe('function'); - const root = fixture(); - const packages = installedMaterials(installedOrigins({ repoRoot, lockFile })); - const measured = actualToolFiles({ installRoot: libDir }); - const distributions = measured.map(({ name, version, sha256: hash, path: file }) => ({ - material: { kind: 'tool-distribution', id: `tool:${name}`, name, version, sha256: hash }, - path: file, - })); - const tools = measured.map(({ name, version, role }) => ({ - name, - version, - role, - material_id: `tool:${name}`, - })); - const preparationPath = path.join(root, 'preparation.json'); - fs.writeFileSync( - preparationPath, - JSON.stringify({ - schema_version: 1, - packages, - distributions, - tools, - // Synthetic observations exercise joins; no preparation actually runs in this fixture. - lifecycle_events: expectedLifecycleEvents({ installRoot: libDir, packages }), - lifecycle_outputs: [], - }) - ); - const oldRelease = process.env.TRUSTED_SERVER__RELEASE_BUILD; - const oldSource = process.env.TRUSTED_SERVER__GIT_VERSION; - process.env.TRUSTED_SERVER__RELEASE_BUILD = '1'; - // This is the fixture's source identity, not release approval. - process.env.TRUSTED_SERVER__GIT_VERSION = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: repoRoot, - encoding: 'utf8', - }).trim(); - try { - const inventoryPath = path.join(root, 'inventory.json'); - const outDir = path.join(root, 'bundles'); - const inventory = await collectJs({ repoRoot, outDir, inventoryPath, preparationPath }); - expect(Object.keys(inventory).sort()).toEqual([ - 'materials', - 'modules', - 'schema_version', - 'source_commit', - 'tools', - ]); - expect(inventory.modules.map((row) => row.id)).toEqual( - [...inventory.modules.map((row) => row.id)].sort() + it.runIf(process.env.TRUSTED_SERVER__RELEASE_JS_TESTS === '1')( + 'writes deterministic closed inventory records after final Vite/esbuild output', + async () => { + const { actualToolFiles, expectedLifecycleEvents, installedOrigins, installedMaterials } = + await import('../release-composition.mjs'); + const { collectJs } = await import(path.join(repoRoot, 'scripts/release/collect-js.mjs')); + const root = fixture(); + const packages = installedMaterials(installedOrigins({ repoRoot, lockFile })); + const measured = actualToolFiles({ installRoot: libDir }); + const distributions = measured.map(({ name, version, sha256: hash, path: file }) => ({ + material: { kind: 'tool-distribution', id: `tool:${name}`, name, version, sha256: hash }, + path: file, + })); + const tools = measured.map(({ name, version, role }) => ({ + name, + version, + role, + material_id: `tool:${name}`, + })); + const preparationPath = path.join(root, 'preparation.json'); + fs.writeFileSync( + preparationPath, + JSON.stringify({ + schema_version: 1, + packages, + distributions, + tools, + // Synthetic observations exercise joins; no preparation actually runs in this fixture. + lifecycle_events: expectedLifecycleEvents({ installRoot: libDir, packages }), + lifecycle_outputs: [], + }) ); - const plainDirectory = path.join(root, 'plain-bundles'); - await buildAll({ outDir: plainDirectory }); - for (const module of inventory.modules) { - const bytes = fs.readFileSync(path.join(outDir, module.filename)); - expect(bytes).toEqual(fs.readFileSync(path.join(plainDirectory, module.filename))); - expect(module.size).toBe(bytes.length); - expect(module.sha256).toBe(sha256(bytes)); - expect(Object.keys(module).sort()).toEqual([ - 'contributions', - 'filename', - 'id', - 'sha256', - 'size', + // This is the fixture's source identity, not release approval. + const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', '1'); + vi.stubEnv('TRUSTED_SERVER__GIT_VERSION', sourceCommit); + vi.stubEnv('TSJS_SKIP_BUILD', undefined); + try { + const inventoryPath = path.join(root, 'inventory.json'); + const outDir = path.join(root, 'bundles'); + const inventory = await collectJs({ repoRoot, outDir, inventoryPath, preparationPath }); + expect(Object.keys(inventory).sort()).toEqual([ + 'materials', + 'modules', + 'schema_version', + 'source_commit', + 'tools', ]); + expect(inventory.modules.map((row) => row.id)).toEqual( + [...inventory.modules.map((row) => row.id)].sort() + ); + const plainDirectory = path.join(root, 'plain-bundles'); + await buildAll({ outDir: plainDirectory }); + for (const module of inventory.modules) { + const bytes = fs.readFileSync(path.join(outDir, module.filename)); + expect(bytes).toEqual(fs.readFileSync(path.join(plainDirectory, module.filename))); + expect(module.size).toBe(bytes.length); + expect(module.sha256).toBe(sha256(bytes)); + expect(Object.keys(module).sort()).toEqual([ + 'contributions', + 'filename', + 'id', + 'sha256', + 'size', + ]); + } + expect(inventory.materials.some((row) => row.kind === 'lifecycle-output')).toBe(false); + expect(inventory.materials.some((row) => row.kind === 'lifecycle-event')).toBe(true); + for (const source of [ + 'crates/trusted-server-js/lib/tsconfig.json', + 'scripts/release/bootstrap.sh', + ]) { + expect(inventory.materials.find((row) => row.id === `source:${source}`)).toMatchObject({ + kind: 'source', + path: source, + sha256: sha256(fs.readFileSync(path.join(repoRoot, source))), + }); + } + expect(fs.readFileSync(inventoryPath, 'utf8')).toBe(`${JSON.stringify(inventory)}\n`); + const alias = path.join(root, 'bundle-alias'); + fs.symlinkSync(outDir, alias); + await expect( + collectJs({ + repoRoot, + outDir: alias, + inventoryPath: path.join(outDir, 'inside.json'), + preparationPath, + }) + ).rejects.toThrow(/outside.*bundle/i); + await expect( + collectJs({ + repoRoot: fixture(), + outDir, + inventoryPath: path.join(root, 'wrong-checkout.json'), + preparationPath, + }) + ).rejects.toThrow(/collector.*checkout/i); + const originalPreparation = fs.readFileSync(preparationPath); + const colliding = JSON.parse(originalPreparation); + const collision = 'source:crates/trusted-server-js/lib/package.json'; + colliding.distributions.find((row) => row.material.name === 'node').material.id = collision; + colliding.tools.find((row) => row.name === 'node').material_id = collision; + fs.writeFileSync(preparationPath, JSON.stringify(colliding)); + await expect( + collectJs({ + repoRoot, + outDir, + inventoryPath: path.join(root, 'collision.json'), + preparationPath, + }) + ).rejects.toThrow(/duplicate.*material|material.*identity/i); + expect(fs.existsSync(path.join(root, 'collision.json'))).toBe(false); + fs.writeFileSync(preparationPath, originalPreparation); + const bad = JSON.parse(fs.readFileSync(preparationPath, 'utf8')); + bad.distributions.find((row) => row.material.name === 'vite').path = path.join( + root, + 'fictional-tool' + ); + const actualVite = measured.find((row) => row.name === 'vite'); + fs.copyFileSync(actualVite.path, path.join(root, 'fictional-tool')); + expect(sha256(fs.readFileSync(path.join(root, 'fictional-tool')))).toBe(actualVite.sha256); + fs.writeFileSync(preparationPath, JSON.stringify(bad)); + await expect( + collectJs({ + repoRoot, + outDir, + inventoryPath: path.join(root, 'bad.json'), + preparationPath, + }) + ).rejects.toThrow(/actual.*tool.*identity/i); + } finally { + vi.unstubAllEnvs(); } - expect(inventory.materials.some((row) => row.kind === 'lifecycle-output')).toBe(false); - expect(inventory.materials.some((row) => row.kind === 'lifecycle-event')).toBe(true); - expect(fs.readFileSync(inventoryPath, 'utf8')).toBe(`${JSON.stringify(inventory)}\n`); - const alias = path.join(root, 'bundle-alias'); - fs.symlinkSync(outDir, alias); - await expect( - collectJs({ - repoRoot, - outDir: alias, - inventoryPath: path.join(outDir, 'inside.json'), - preparationPath, - }) - ).rejects.toThrow(/outside.*bundle/i); - await expect( - collectJs({ - repoRoot: fixture(), - outDir, - inventoryPath: path.join(root, 'wrong-checkout.json'), - preparationPath, - }) - ).rejects.toThrow(/collector.*checkout/i); - const originalPreparation = fs.readFileSync(preparationPath); - const colliding = JSON.parse(originalPreparation); - const collision = 'source:crates/trusted-server-js/lib/package.json'; - colliding.distributions.find((row) => row.material.name === 'node').material.id = collision; - colliding.tools.find((row) => row.name === 'node').material_id = collision; - fs.writeFileSync(preparationPath, JSON.stringify(colliding)); - await expect( - collectJs({ - repoRoot, - outDir, - inventoryPath: path.join(root, 'collision.json'), - preparationPath, - }) - ).rejects.toThrow(/duplicate.*material|material.*identity/i); - expect(fs.existsSync(path.join(root, 'collision.json'))).toBe(false); - fs.writeFileSync(preparationPath, originalPreparation); - const bad = JSON.parse(fs.readFileSync(preparationPath, 'utf8')); - bad.distributions.find((row) => row.material.name === 'vite').path = path.join( - root, - 'fictional-tool' - ); - fs.writeFileSync(path.join(root, 'fictional-tool'), 'unrelated Vite distribution'); - bad.distributions.find((row) => row.material.name === 'vite').material.sha256 = sha256( - 'unrelated Vite distribution' - ); - fs.writeFileSync(preparationPath, JSON.stringify(bad)); - await expect( - collectJs({ repoRoot, outDir, inventoryPath: path.join(root, 'bad.json'), preparationPath }) - ).rejects.toThrow(/actual.*tool|tool.*digest/i); - } finally { - if (oldRelease === undefined) delete process.env.TRUSTED_SERVER__RELEASE_BUILD; - else process.env.TRUSTED_SERVER__RELEASE_BUILD = oldRelease; - if (oldSource === undefined) delete process.env.TRUSTED_SERVER__GIT_VERSION; - else process.env.TRUSTED_SERVER__GIT_VERSION = oldSource; - } - }, 120_000); + }, + 120_000 + ); }); describe('strict parser parity', () => { @@ -701,6 +833,36 @@ describe('observer output boundary', () => { }); describe('complete preparation lifecycle coverage', () => { + it('rejects an omitted installed package even when it has no lifecycle hooks', async () => { + const { validatePreparation } = await import('../release-composition.mjs'); + const root = fixture(); + const packageRoot = path.join(root, 'node_modules/example'); + fs.mkdirSync(packageRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }) + ); + const npm = { + kind: 'npm-package', + id: 'npm:node_modules/example', + lock_path: 'node_modules/example', + name: 'example', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + installed_tree_sha256: 'a'.repeat(64), + }; + const prepared = { + schema_version: 1, + tools: [], + distributions: [], + packages: [], + lifecycle_events: [], + lifecycle_outputs: [], + }; + expect(() => validatePreparation(prepared, { installRoot: root, packages: [npm] })).toThrow( + 'Incomplete prepared package set' + ); + }); it('rejects a missing observation for an installed registry postinstall hook', async () => { const { validatePreparation } = await import('../release-composition.mjs'); const root = fixture(); @@ -734,6 +896,66 @@ describe('complete preparation lifecycle coverage', () => { }); describe('nested package contribution fixture', () => { + it('rejects another node_modules tree inside the repository but outside the install root', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const outside = path.join(root, 'node_modules/example'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync( + path.join(outside, 'package.json'), + JSON.stringify({ name: 'example', version: '1.0.0' }) + ); + fs.writeFileSync(path.join(outside, 'index.js'), 'export default 7;'); + const installRoot = path.join(root, 'install'); + fs.mkdirSync(installRoot); + const fixtureLock = path.join(installRoot, 'package-lock.json'); + fs.writeFileSync(fixtureLock, JSON.stringify({ lockfileVersion: 3, packages: {} })); + const origins = installedOrigins({ repoRoot, lockFile: fixtureLock }); + expect(() => origins.origin(path.join(outside, 'index.js'))).toThrow( + /unmapped installed ownership/i + ); + }); + it('assigns a nested type-only format manifest to its archive package', async () => { + const { installedOrigins } = await import('../release-composition.mjs'); + const root = fixture(); + const key = 'node_modules/@example/parent'; + const directory = path.join(root, key); + const format = path.join(directory, 'dist/esm'); + fs.mkdirSync(format, { recursive: true }); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ name: '@example/parent', version: '1.0.0' }) + ); + fs.writeFileSync(path.join(format, 'package.json'), JSON.stringify({ type: 'module' })); + fs.writeFileSync(path.join(format, 'index.js'), 'export default 7;'); + const fixtureLock = path.join(root, 'package-lock.json'); + fs.writeFileSync( + fixtureLock, + JSON.stringify({ + lockfileVersion: 3, + packages: { + [key]: { + version: '1.0.0', + resolved: archiveSource, + integrity: `sha512-${Buffer.alloc(64).toString('base64')}`, + }, + }, + }) + ); + const origins = installedOrigins({ repoRoot, lockFile: fixtureLock }); + expect(origins.origin(path.join(format, 'index.js'))).toMatchObject({ + lock_path: key, + path: 'dist/esm/index.js', + }); + const hostile = path.join(directory, 'node_modules/example-unmapped'); + fs.mkdirSync(hostile, { recursive: true }); + fs.writeFileSync( + path.join(hostile, 'package.json'), + JSON.stringify({ name: 'example-unmapped', version: '1.0.0' }) + ); + fs.writeFileSync(path.join(hostile, 'index.js'), 'export default 42;'); + expect(() => origins.origin(path.join(hostile, 'index.js'))).toThrow(/unmapped/i); + }); it('preserves final bytes while recording the indirect nested installation identity', async () => { const root = fixture(); const parentKey = 'node_modules/example-parent'; @@ -810,58 +1032,66 @@ describe('nested package contribution fixture', () => { }); describe('approved source module discovery', () => { - it('uses the selected commit despite index removal or an invented staged integration', async () => { - const { expectedModuleIds } = await import( - path.join(repoRoot, 'scripts/release/collect-js.mjs') - ); - expect(typeof expectedModuleIds).toBe('function'); - const root = fixture(); - const git = (args) => - execFileSync( - 'git', - [ - '-c', - 'commit.gpgsign=false', - '-c', - 'user.name=Example', - '-c', - 'user.email=example@example.com', - ...args, - ], - { - cwd: root, - encoding: 'utf8', - env: { - PATH: process.env.PATH, - TMPDIR: process.env.TMPDIR ?? '/private/tmp', - GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_GLOBAL: '/dev/null', - GIT_NO_REPLACE_OBJECTS: '1', - }, - } + it.runIf(process.env.TRUSTED_SERVER__RELEASE_JS_TESTS === '1')( + 'uses the selected commit despite index removal or an invented staged integration', + async () => { + const { expectedModuleIds } = await import( + path.join(repoRoot, 'scripts/release/collect-js.mjs') ); - git(['init', '-q']); - for (const file of [ - 'crates/trusted-server-js/lib/src/core/index.ts', - 'crates/trusted-server-js/lib/src/integrations/example/index.ts', - ]) { - fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); - fs.writeFileSync(path.join(root, file), 'export const example = 7;'); + const root = fixture(); + vi.stubEnv('TMPDIR', undefined); + const git = (args) => + execFileSync( + 'git', + [ + '-c', + 'commit.gpgsign=false', + '-c', + 'user.name=Example', + '-c', + 'user.email=example@example.com', + ...args, + ], + { + cwd: root, + encoding: 'utf8', + env: { + PATH: process.env.PATH, + TMPDIR: os.tmpdir(), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_NO_REPLACE_OBJECTS: '1', + }, + } + ); + git(['init', '-q']); + for (const file of [ + 'crates/trusted-server-js/lib/src/core/index.ts', + 'crates/trusted-server-js/lib/src/integrations/example/index.ts', + ]) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), 'export const example = 7;'); + } + git(['add', '.']); + git(['commit', '-qm', 'Record example source']); + const source = git(['rev-parse', 'HEAD']).trim(); + git([ + 'rm', + '--cached', + '-q', + 'crates/trusted-server-js/lib/src/integrations/example/index.ts', + ]); + const invented = path.join( + root, + 'crates/trusted-server-js/lib/src/integrations/invented/index.ts' + ); + fs.mkdirSync(path.dirname(invented), { recursive: true }); + fs.writeFileSync(invented, 'export const example = 42;'); + git(['add', invented]); + expect(expectedModuleIds(root, source)).toEqual(['core', 'example']); + expect(() => expectedModuleIds(root, 'a'.repeat(40))).toThrow(); } - git(['add', '.']); - git(['commit', '-qm', 'Record example source']); - const source = git(['rev-parse', 'HEAD']).trim(); - git(['rm', '--cached', '-q', 'crates/trusted-server-js/lib/src/integrations/example/index.ts']); - const invented = path.join( - root, - 'crates/trusted-server-js/lib/src/integrations/invented/index.ts' - ); - fs.mkdirSync(path.dirname(invented), { recursive: true }); - fs.writeFileSync(invented, 'export const example = 42;'); - git(['add', invented]); - expect(expectedModuleIds(root, source)).toEqual(['core', 'example']); - expect(() => expectedModuleIds(root, 'a'.repeat(40))).toThrow(); - }); + ); }); describe('locked archive source policy', () => { diff --git a/crates/trusted-server-js/lib/vitest.config.ts b/crates/trusted-server-js/lib/vitest.config.ts index 545426ba64..becaf5204d 100644 --- a/crates/trusted-server-js/lib/vitest.config.ts +++ b/crates/trusted-server-js/lib/vitest.config.ts @@ -4,6 +4,13 @@ import { defineConfig } from 'vitest/config'; import { releaseBuild } from './release-composition.mjs'; +if ( + Object.hasOwn(process.env, 'TRUSTED_SERVER__RELEASE_JS_TESTS') && + process.env.TRUSTED_SERVER__RELEASE_JS_TESTS !== '1' +) { + throw new Error('TRUSTED_SERVER__RELEASE_JS_TESTS must be exactly 1 when present'); +} + export default defineConfig({ ...(releaseBuild() ? { diff --git a/crates/trusted-server-js/tests/release_prebuilt.rs b/crates/trusted-server-js/tests/release_prebuilt.rs index 925d54a4f2..7a1b7959c5 100644 --- a/crates/trusted-server-js/tests/release_prebuilt.rs +++ b/crates/trusted-server-js/tests/release_prebuilt.rs @@ -66,10 +66,14 @@ impl Fixture { .env("CARGO_MANIFEST_DIR", self.root.path()) .env("OUT_DIR", self.path("out")) .env("PATH", self.path("bin")) + .env("TRUSTED_SERVER__TSJS_TEST_SUBPROCESS", "1") .env("TRUSTED_SERVER__RELEASE_BUILD", "1") .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) .env("TSJS_PREBUILT_DIR", self.path("bundles")) - .env("TSJS_PREBUILT_INVENTORY", self.path("inventory.json")); + .env( + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + self.path("inventory.json"), + ); command } @@ -82,11 +86,7 @@ impl Fixture { } fn rejected(&self, output: &Output, diagnostic: &str) { - let output_text = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + let output_text = String::from_utf8_lossy(&output.stderr); assert!( !output.status.success(), "should reject fixture: {output_text}" @@ -104,13 +104,92 @@ impl Fixture { "should not generate a rejected module table" ); } + + fn add_integration(&mut self, id: &str) { + fs::create_dir(self.path(&format!("lib/src/integrations/{id}"))) + .expect("should create integration"); + fs::write( + self.path(&format!("lib/src/integrations/{id}/index.ts")), + b"export {};", + ) + .expect("should write integration entry"); + fs::write(self.path(&format!("bundles/tsjs-{id}.js")), BYTES) + .expect("should write integration bundle"); + let mut module = self.inventory["modules"][0].clone(); + module["id"] = json!(id); + module["filename"] = json!(format!("tsjs-{id}.js")); + self.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .push(module); + self.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str())); + } +} + +#[test] +fn rejects_reversed_complete_two_module_inventory() { + let mut fixture = Fixture::new(); + fixture.add_integration("aaa"); + fixture.inventory["modules"] + .as_array_mut() + .expect("should access modules") + .reverse(); + fixture.write_inventory(); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "inventory module identity, order, size or digest is invalid", + ); +} + +#[test] +fn rejects_unsafe_and_duplicate_core_source_integrations() { + for id in ["unsafe\"name", "core"] { + let mut fixture = Fixture::new(); + fixture.add_integration(id); + fixture.write_inventory(); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + if id == "core" { + "duplicate core module ID" + } else { + "module ID unsafe\"name must be a safe ASCII identifier" + }, + ); + } +} + +#[test] +fn accepts_safe_opaque_floats_and_negative_integers() { + let mut fixture = Fixture::new(); + fixture.inventory["tools"] = json!([ + 0.5, + 1.5e-10, + -1, + -9_007_199_254_740_991_i64, + 9_007_199_254_740_991_u64 + ]); + fixture.inventory["materials"] = json!([{"nested": [1.0, -0.0]}]); + fixture.write_inventory(); + let output = fixture.command().output().expect("should execute fixture"); + assert!( + output.status.success(), + "should accept safe opaque numeric metadata: {output:?}" + ); } #[test] #[ignore = "entry point invoked only by fixture subprocesses"] fn fixture_entry() { - build_script::run_with_hook( - |source, copied| match env::var("FIXTURE_MUTATION").as_deref() { + if env::var_os("TRUSTED_SERVER__TSJS_TEST_SUBPROCESS").as_deref() + != Some(std::ffi::OsStr::new("1")) + { + return; + } + build_script::run_with_hook(|source, copied| { + match env::var("TRUSTED_SERVER__TSJS_TEST_MUTATION").as_deref() { Ok("source") => fs::write(source.join("tsjs-core.js"), b"changed bytes") .expect("should mutate source"), Ok("swap") => { @@ -143,8 +222,8 @@ fn fixture_entry() { symlink(root.join("other"), parent).expect("should redirect parent link"); } _ => {} - }, - ); + } + }); } #[test] @@ -167,6 +246,25 @@ fn release_accepts_checked_bundles_without_npm() { ); } +#[test] +fn ignored_entry_without_explicit_subprocess_marker_does_not_write() { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__TSJS_TEST_SUBPROCESS") + .output() + .expect("should invoke ignored entry directly"); + assert!( + output.status.success(), + "should leave the direct ignored entry inert: {output:?}" + ); + assert!( + !fixture.path("out/tsjs_modules.rs").exists(), + "should never write without explicit marker" + ); +} + #[test] fn rejects_any_skip_presence_before_prebuilt_selection() { for value in [ @@ -188,7 +286,10 @@ fn rejects_any_skip_presence_before_prebuilt_selection() { #[test] fn release_requires_both_prebuilt_inputs() { - for missing in ["TSJS_PREBUILT_DIR", "TSJS_PREBUILT_INVENTORY"] { + for missing in [ + "TSJS_PREBUILT_DIR", + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + ] { let fixture = Fixture::new(); fixture.write_inventory(); let output = fixture @@ -212,7 +313,6 @@ fn rejects_inventory_identity_and_module_tampering() { "missing", "empty", "extra", - "order", "unknown", "version", "tools", @@ -240,7 +340,7 @@ fn rejects_inventory_identity_and_module_tampering() { "id" => fixture.inventory["modules"][0]["id"] = json!("../core"), "missing" => fixture.inventory["modules"] = json!([]), "empty" => fixture.inventory["modules"][0]["size"] = json!(0), - "extra" | "order" => { + "extra" => { let mut module = fixture.inventory["modules"][0].clone(); module["id"] = json!("aaa"); module["filename"] = json!("tsjs-aaa.js"); @@ -270,7 +370,19 @@ fn rejects_inventory_identity_and_module_tampering() { fixture.write_inventory(); fixture.rejected( &fixture.command().output().expect("should execute fixture"), - "inventory", + match case { + "source" => "inventory schema version or source does not match", + "digest" => "inventory bundle digest mismatch", + "size" => "inventory bundle size mismatch", + "missing" => "missing bundles", + "unknown" | "module_unknown" => "unknown field", + "missing_array" => "missing field", + "version" | "tools" | "bool_size" | "float_size" | "contributions" => { + "invalid type" + } + "negative_size" => "invalid value", + _ => "inventory module identity, order, size or digest is invalid", + }, ); } } @@ -325,7 +437,14 @@ fn rejects_inexact_source_directories() { } fixture.rejected( &fixture.command().output().expect("should execute fixture"), - "tsjs", + match case { + "missing" => "missing bundles", + "empty" => "empty bundles", + "extra" | "unrelated" | "directory" => "unexpected extra bundle directory entry", + "link" => "cannot open bundle", + "hardlink" => "exactly one hard link", + _ => unreachable!("should use listed case"), + }, ); } } @@ -375,7 +494,16 @@ fn rejects_ambiguous_or_unbounded_json() { fs::write(fixture.path("inventory.json"), raw).expect("should write malformed inventory"); fixture.rejected( &fixture.command().output().expect("should execute fixture"), - "inventory", + match case { + "duplicate" | "nested" => "duplicate JSON key", + "nonfinite" | "bom" => "expected value", + "overflow" => "inventory number overflows", + "integer_overflow" => "unsafe integer", + "depth" => "JSON depth exceeds 64", + "oversize" => "inventory exceeds 1 MiB", + "utf8" => "inventory must be UTF-8", + _ => unreachable!("should use listed case"), + }, ); } } @@ -383,10 +511,28 @@ fn rejects_ambiguous_or_unbounded_json() { #[test] fn development_prebuilt_without_inventory_remains_supported() { let fixture = Fixture::new(); + fs::write(fixture.path("bundles/manifest.json"), b"{}").expect("should write legacy manifest"); + fs::write(fixture.path("bundles/.DS_Store"), b"metadata") + .expect("should write legacy metadata"); + fs::create_dir(fixture.path("bundles/prebid")).expect("should create legacy tree"); + fs::write(fixture.path("bundles/prebid/package.json"), b"{}") + .expect("should write legacy package output"); + fs::write(fixture.path("bundles/prebid/prebid.js"), BYTES) + .expect("should write legacy external bundle output"); + symlink( + fixture.path("bundles/prebid"), + fixture.path("bundles/legacy-link"), + ) + .expect("should create legacy directory alias"); + fs::hard_link( + fixture.path("bundles/tsjs-core.js"), + fixture.path("alias.js"), + ) + .expect("should retain legacy bundle hard links"); let output = fixture .command() .env_remove("TRUSTED_SERVER__RELEASE_BUILD") - .env_remove("TSJS_PREBUILT_INVENTORY") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") .output() .expect("should execute fixture"); assert!( @@ -409,10 +555,20 @@ fn rejects_source_or_copied_bytes_changed_during_copy() { fixture.write_inventory(); let output = fixture .command() - .env("FIXTURE_MUTATION", mutation) + .env("TRUSTED_SERVER__TSJS_TEST_MUTATION", mutation) .output() .expect("should execute fixture"); - fixture.rejected(&output, "tsjs"); + fixture.rejected( + &output, + match mutation { + "source" => "changed during copy", + "swap" => "cannot open bundle", + "private" => "private copied bundle does not match", + "extra" => "unexpected extra bundle directory entry", + "identity" | "directory" => "identity changed during copy", + _ => unreachable!("should use listed mutation"), + }, + ); } } @@ -425,7 +581,7 @@ fn rejects_release_controls_and_relative_paths_before_npm() { ("TRUSTED_SERVER__GIT_VERSION", ""), ("TRUSTED_SERVER__GIT_VERSION", "abcdef"), ("TSJS_PREBUILT_DIR", "bundles"), - ("TSJS_PREBUILT_INVENTORY", "inventory.json"), + ("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", "inventory.json"), ] { let fixture = Fixture::new(); fixture.write_inventory(); @@ -435,7 +591,16 @@ fn rejects_release_controls_and_relative_paths_before_npm() { .env(variable, value) .output() .expect("should execute fixture"), - "tsjs", + match variable { + "TRUSTED_SERVER__RELEASE_BUILD" => { + "TRUSTED_SERVER__RELEASE_BUILD must be exactly 1" + } + "TRUSTED_SERVER__GIT_VERSION" if value.is_empty() => { + "release source must be exactly 40" + } + "TRUSTED_SERVER__GIT_VERSION" => "release source must be exactly 40", + _ => "must be an absolute path", + }, ); } let fixture = Fixture::new(); @@ -451,21 +616,31 @@ fn rejects_release_controls_and_relative_paths_before_npm() { } #[test] -fn development_inventory_checks_explicit_source_and_bundle_bytes() { - for case in ["source", "bytes"] { +fn development_inventory_checks_bundle_bytes_without_comparing_display_source() { + for identity in ["main", "v1.2.3", "a".repeat(40).as_str()] { + let fixture = Fixture::new(); + fixture.write_inventory(); + let output = fixture + .command() + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env("TRUSTED_SERVER__GIT_VERSION", identity) + .output() + .expect("should execute development fixture"); + assert!( + output.status.success(), + "should permit development display identity: {output:?}" + ); + } + { let fixture = Fixture::new(); fixture.write_inventory(); let mut command = fixture.command(); command.env_remove("TRUSTED_SERVER__RELEASE_BUILD"); - if case == "source" { - command.env("TRUSTED_SERVER__GIT_VERSION", "a".repeat(40)); - } else { - fs::write(fixture.path("bundles/tsjs-core.js"), b"tampered bytes") - .expect("should change bundle"); - } + fs::write(fixture.path("bundles/tsjs-core.js"), b"tampered bytes") + .expect("should change bundle"); fixture.rejected( &command.output().expect("should execute fixture"), - "inventory", + "inventory bundle size mismatch", ); } let fixture = Fixture::new(); @@ -490,7 +665,7 @@ fn rejects_skip_presence_in_development_source_and_prebuilt_modes() { let mut command = fixture.command(); command .env_remove("TRUSTED_SERVER__RELEASE_BUILD") - .env_remove("TSJS_PREBUILT_INVENTORY") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") .env("TSJS_SKIP_BUILD", value); if !prebuilt { command.env_remove("TSJS_PREBUILT_DIR"); @@ -518,7 +693,7 @@ fn development_source_build_uses_fresh_npm_output() { .command() .env_remove("TRUSTED_SERVER__RELEASE_BUILD") .env_remove("TSJS_PREBUILT_DIR") - .env_remove("TSJS_PREBUILT_INVENTORY") + .env_remove("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY") .output() .expect("should execute fixture"); assert!( @@ -581,7 +756,7 @@ fn rejects_inventory_inside_source_and_private_destination_links() { ) .expect("should place inventory inside source"); command.env( - "TSJS_PREBUILT_INVENTORY", + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", fixture.path("bundles/inventory.json"), ); } @@ -591,7 +766,14 @@ fn rejects_inventory_inside_source_and_private_destination_links() { .expect("should write conflicting destination"), _ => unreachable!("should use listed case"), } - fixture.rejected(&command.output().expect("should execute fixture"), "tsjs"); + fixture.rejected( + &command.output().expect("should execute fixture"), + match case { + "inside" => "inventory must be outside", + "link" | "file" => "private bundle destination must be a directory", + _ => unreachable!("should use listed case"), + }, + ); assert_eq!( fs::read(fixture.path("bundles/tsjs-core.js")).expect("should preserve input bundle"), BYTES, @@ -610,7 +792,7 @@ fn emits_rerun_directives_for_inventory_and_release_inputs() { for variable in [ "TSJS_SKIP_BUILD", "TSJS_PREBUILT_DIR", - "TSJS_PREBUILT_INVENTORY", + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", "TRUSTED_SERVER__RELEASE_BUILD", "TRUSTED_SERVER__GIT_VERSION", ] { @@ -653,7 +835,7 @@ fn exposes_raw_inventory_digest_for_later_cargo_build_evidence() { let stdout = String::from_utf8(output.stdout).expect("should capture UTF-8 directives"); assert!( stdout.contains(&format!( - "cargo:rustc-env=TSJS_INPUT_INVENTORY_SHA256={}", + "cargo:rustc-env=TRUSTED_SERVER__TSJS_INVENTORY_SHA256={}", hex::encode(Sha256::digest(&raw)) )), "should report digest of the exact held inventory bytes: {stdout}" @@ -661,7 +843,7 @@ fn exposes_raw_inventory_digest_for_later_cargo_build_evidence() { } #[test] -fn development_inventory_rejects_present_non_unicode_source() { +fn development_inventory_does_not_compare_non_unicode_display_source() { let fixture = Fixture::new(); fixture.write_inventory(); @@ -675,7 +857,49 @@ fn development_inventory_rejects_present_non_unicode_source() { .output() .expect("should execute fixture"); - fixture.rejected(&output, "source"); + assert!( + output.status.success(), + "should leave development identity resolution to core: {output:?}" + ); +} + +#[test] +fn rejects_source_leaf_links_with_lexical_directory_suffixes() { + for suffix in ["", "/", "//", "/.", "/./"] { + let fixture = Fixture::new(); + fixture.write_inventory(); + symlink(fixture.path("bundles"), fixture.path("bundles-link")) + .expect("should link source directory"); + let output = fixture + .command() + .env( + "TSJS_PREBUILT_DIR", + format!("{}{suffix}", fixture.path("bundles-link").display()), + ) + .output() + .expect("should execute linked source fixture"); + fixture.rejected(&output, "bundles-link"); + } +} + +#[test] +fn rejects_unsafe_integral_opaque_numbers() { + for literal in ["9007199254740993", "-9007199254740993", "1e20", "1.5e300"] { + for field in ["tools", "materials", "contributions"] { + let fixture = Fixture::new(); + let raw = serde_json::to_string(&fixture.inventory) + .expect("should serialize inventory") + .replace( + &format!("\"{field}\":[]"), + &format!("\"{field}\":[{{\"nested\":{literal}}}]"), + ); + fs::write(fixture.path("inventory.json"), raw).expect("should write opaque number"); + fixture.rejected( + &fixture.command().output().expect("should execute fixture"), + "unsafe integer", + ); + } + } } #[test] @@ -730,7 +954,7 @@ fn rejects_parent_link_redirection_to_byte_identical_directory() { let output = fixture .command() .env("TSJS_PREBUILT_DIR", fixture.path("parent-alias/bundles")) - .env("FIXTURE_MUTATION", "parent") + .env("TRUSTED_SERVER__TSJS_TEST_MUTATION", "parent") .output() .expect("should execute fixture"); diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml new file mode 100644 index 0000000000..ac2f69aedb --- /dev/null +++ b/crates/trusted-server-release/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "trusted-server-release" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true +rust-version = "1.95" + +[dependencies] +derive_more.workspace = true +error-stack.workspace = true +hex.workspace = true +libc.workspace = true +jsonschema = { version = "=0.58.6", default-features = false } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/trusted-server-release/schemas/SPDX-LICENSE b/crates/trusted-server-release/schemas/SPDX-LICENSE new file mode 100644 index 0000000000..44a22d370b --- /dev/null +++ b/crates/trusted-server-release/schemas/SPDX-LICENSE @@ -0,0 +1,92 @@ +Creative Commons Attribution 3.0 Unported + +CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM ITS USE. +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS. + +1. Definitions + +a. "Adaptation" means a work based upon the Work, or upon the Work and other pre-existing works, such as a translation, adaptation, derivative work, arrangement of music or other alterations of a literary or artistic work, or phonogram or performance and includes cinematographic adaptations or any other form in which the Work may be recast, transformed, or adapted including in any form recognizably derived from the original, except that a work that constitutes a Collection will not be considered an Adaptation for the purpose of this License. For the avoidance of doubt, where the Work is a musical work, performance or phonogram, the synchronization of the Work in timed-relation with a moving image ("synching") will be considered an Adaptation for the purpose of this License. + +b. "Collection" means a collection of literary or artistic works, such as encyclopedias and anthologies, or performances, phonograms or broadcasts, or other works or subject matter other than works listed in Section 1(f) below, which, by reason of the selection and arrangement of their contents, constitute intellectual creations, in which the Work is included in its entirety in unmodified form along with one or more other contributions, each constituting separate and independent works in themselves, which together are assembled into a collective whole. A work that constitutes a Collection will not be considered an Adaptation (as defined above) for the purposes of this License. + +c. "Distribute" means to make available to the public the original and copies of the Work or Adaptation, as appropriate, through sale or other transfer of ownership. + +d. "Licensor" means the individual, individuals, entity or entities that offer(s) the Work under the terms of this License. + +e. "Original Author" means, in the case of a literary or artistic work, the individual, individuals, entity or entities who created the Work or if no individual or entity can be identified, the publisher; and in addition (i) in the case of a performance the actors, singers, musicians, dancers, and other persons who act, sing, deliver, declaim, play in, interpret or otherwise perform literary or artistic works or expressions of folklore; (ii) in the case of a phonogram the producer being the person or legal entity who first fixes the sounds of a performance or other sounds; and, (iii) in the case of broadcasts, the organization that transmits the broadcast. + +f. "Work" means the literary and/or artistic work offered under the terms of this License including without limitation any production in the literary, scientific and artistic domain, whatever may be the mode or form of its expression including digital form, such as a book, pamphlet and other writing; a lecture, address, sermon or other work of the same nature; a dramatic or dramatico-musical work; a choreographic work or entertainment in dumb show; a musical composition with or without words; a cinematographic work to which are assimilated works expressed by a process analogous to cinematography; a work of drawing, painting, architecture, sculpture, engraving or lithography; a photographic work to which are assimilated works expressed by a process analogous to photography; a work of applied art; an illustration, map, plan, sketch or three-dimensional work relative to geography, topography, architecture or science; a performance; a broadcast; a phonogram; a compilation of data to the extent it is protected as a copyrightable work; or a work performed by a variety or circus performer to the extent it is not otherwise considered a literary or artistic work. + +g. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation. + +h. "Publicly Perform" means to perform public recitations of the Work and to communicate to the public those public recitations, by any means or process, including by wire or wireless means or public digital performances; to make available to the public Works in such a way that members of the public may access these Works from a place and at a place individually chosen by them; to perform the Work to the public by any means or process and the communication to the public of the performances of the Work, including by public digital performance; to broadcast and rebroadcast the Work by any means including signs, sounds or images. + +i. "Reproduce" means to make copies of the Work by any means including without limitation by sound or visual recordings and the right of fixation and reproducing fixations of the Work, including storage of a protected performance or phonogram in digital form or other electronic medium. + +2. Fair Dealing Rights. Nothing in this License is intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in connection with the copyright protection under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below: + +a. to Reproduce the Work, to incorporate the Work into one or more Collections, and to Reproduce the Work as incorporated in the Collections; + +b. to create and Reproduce Adaptations provided that any such Adaptation, including any translation in any medium, takes reasonable steps to clearly label, demarcate or otherwise identify that changes were made to the original Work. For example, a translation could be marked "The original work was translated from English to Spanish," or a modification could indicate "The original work has been modified."; + +c. to Distribute and Publicly Perform the Work including as incorporated in Collections; and, + +d. to Distribute and Publicly Perform Adaptations. + +e. For the avoidance of doubt: + +i. Non-waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme cannot be waived, the Licensor reserves the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; + +ii. Waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme can be waived, the Licensor waives the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; and, + +iii. Voluntary License Schemes. The Licensor waives the right to collect royalties, whether individually or, in the event that the Licensor is a member of a collecting society that administers voluntary licensing schemes, via that society, from any exercise by You of the rights granted under this License. + +The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. Subject to Section 8(f), all rights not expressly granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions: + +a. You may Distribute or Publicly Perform the Work only under the terms of this License. You must include a copy of, or the Uniform Resource Identifier (URI) for, this License with every copy of the Work You Distribute or Publicly Perform. You may not offer or impose any terms on the Work that restrict the terms of this License or the ability of the recipient of the Work to exercise the rights granted to that recipient under the terms of the License. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties with every copy of the Work You Distribute or Publicly Perform. When You Distribute or Publicly Perform the Work, You may not impose any effective technological measures on the Work that restrict the ability of a recipient of the Work from You to exercise the rights granted to that recipient under the terms of the License. This Section 4(a) applies to the Work as incorporated in a Collection, but this does not require the Collection apart from the Work itself to be made subject to the terms of this License. If You create a Collection, upon notice from any Licensor You must, to the extent practicable, remove from the Collection any credit as required by Section 4(b), as requested. If You create an Adaptation, upon notice from any Licensor You must, to the extent practicable, remove from the Adaptation any credit as required by Section 4(b), as requested. + +b. If You Distribute, or Publicly Perform the Work or any Adaptations or Collections, You must, unless a request has been made pursuant to Section 4(a), keep intact all copyright notices for the Work and provide, reasonable to the medium or means You are utilizing: (i) the name of the Original Author (or pseudonym, if applicable) if supplied, and/or if the Original Author and/or Licensor designate another party or parties (e.g., a sponsor institute, publishing entity, journal) for attribution ("Attribution Parties") in Licensor's copyright notice, terms of service or by other reasonable means, the name of such party or parties; (ii) the title of the Work if supplied; (iii) to the extent reasonably practicable, the URI, if any, that Licensor specifies to be associated with the Work, unless such URI does not refer to the copyright notice or licensing information for the Work; and (iv) , consistent with Section 3(b), in the case of an Adaptation, a credit identifying the use of the Work in the Adaptation (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). The credit required by this Section 4 (b) may be implemented in any reasonable manner; provided, however, that in the case of a Adaptation or Collection, at a minimum such credit will appear, if a credit for all contributing authors of the Adaptation or Collection appears, then as part of these credits and in a manner at least as prominent as the credits for the other contributing authors. For the avoidance of doubt, You may only use the credit required by this Section for the purpose of attribution in the manner set out above and, by exercising Your rights under this License, You may not implicitly or explicitly assert or imply any connection with, sponsorship or endorsement by the Original Author, Licensor and/or Attribution Parties, as appropriate, of You or Your use of the Work, without the separate, express prior written permission of the Original Author, Licensor and/or Attribution Parties. + +c. Except as otherwise agreed in writing by the Licensor or as may be otherwise permitted by applicable law, if You Reproduce, Distribute or Publicly Perform the Work either by itself or as part of any Adaptations or Collections, You must not distort, mutilate, modify or take other derogatory action in relation to the Work which would be prejudicial to the Original Author's honor or reputation. Licensor agrees that in those jurisdictions (e.g. Japan), in which any exercise of the right granted in Section 3(b) of this License (the right to make Adaptations) would be deemed to be a distortion, mutilation, modification or other derogatory action prejudicial to the Original Author's honor and reputation, the Licensor will waive or not assert, as appropriate, this Section, to the fullest extent permitted by the applicable national law, to enable You to reasonably exercise Your right under Section 3(b) of this License (right to make Adaptations) but not otherwise. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + +a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Adaptations or Collections from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License. + +b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above. + +8. Miscellaneous + +a. Each time You Distribute or Publicly Perform the Work or a Collection, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License. + +b. Each time You Distribute or Publicly Perform an Adaptation, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License. + +c. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. + +d. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. + +e. This License may not be modified without the mutual written agreement of the Licensor and You. + +f. The rights granted under, and the subject matter referenced, in this License were drafted utilizing the terminology of the Berne Convention for the Protection of Literary and Artistic Works (as amended on September 28, 1979), the Rome Convention of 1961, the WIPO Copyright Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 and the Universal Copyright Convention (as revised on July 24, 1971). These rights and subject matter take effect in the relevant jurisdiction in which the License terms are sought to be enforced according to the corresponding provisions of the implementation of those treaty provisions in the applicable national law. If the standard suite of rights granted under applicable copyright law includes additional rights not granted under this License, such additional rights are deemed to be included in the License; this License is not intended to restrict the license of any rights under applicable law. + +Creative Commons Notice + +Creative Commons is not a party to this License, and makes no warranty whatsoever in connection with the Work. Creative Commons will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, if Creative Commons has expressly identified itself as the Licensor hereunder, it shall have all rights and obligations of Licensor. + +Except for the limited purpose of indicating to the public that the Work is licensed under the CCPL, Creative Commons does not authorize the use by either party of the trademark "Creative Commons" or any related trademark or logo of Creative Commons without the prior written consent of Creative Commons. Any permitted use will be in compliance with Creative Commons' then-current trademark usage guidelines, as may be published on its website or otherwise made available upon request from time to time. For the avoidance of doubt, this trademark restriction does not form part of this License. + +Creative Commons may be contacted at http://creativecommons.org/. \ No newline at end of file diff --git a/crates/trusted-server-release/schemas/spdx-2.3.schema.json b/crates/trusted-server-release/schemas/spdx-2.3.schema.json new file mode 100644 index 0000000000..ee61e6686e --- /dev/null +++ b/crates/trusted-server-release/schemas/spdx-2.3.schema.json @@ -0,0 +1,740 @@ +{ + "$schema" : "http://json-schema.org/draft-07/schema#", + "$id" : "http://spdx.org/rdf/terms/2.3", + "title" : "SPDX 2.3", + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "comment" : { + "type" : "string" + }, + "creationInfo" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "created" : { + "description" : "Identify when the SPDX document was originally created. The date is to be specified according to combined date and time in UTC format as specified in ISO 8601 standard.", + "type" : "string" + }, + "creators" : { + "description" : "Identify who (or what, in the case of a tool) created the SPDX document. If the SPDX document was created by an individual, indicate the person's name. If the SPDX document was created on behalf of a company or organization, indicate the entity name. If the SPDX document was created using a software tool, indicate the name and version for that tool. If multiple participants or tools were involved, use multiple instances of this field. Person name or organization name may be designated as “anonymous” if appropriate.", + "minItems" : 1, + "type" : "array", + "items" : { + "description" : "Identify who (or what, in the case of a tool) created the SPDX document. If the SPDX document was created by an individual, indicate the person's name. If the SPDX document was created on behalf of a company or organization, indicate the entity name. If the SPDX document was created using a software tool, indicate the name and version for that tool. If multiple participants or tools were involved, use multiple instances of this field. Person name or organization name may be designated as “anonymous” if appropriate.", + "type" : "string" + } + }, + "licenseListVersion" : { + "description" : "An optional field for creators of the SPDX file to provide the version of the SPDX License List used when the SPDX file was created.", + "type" : "string" + } + }, + "required" : [ "created", "creators" ], + "additionalProperties" : false, + "description" : "One instance is required for each SPDX file produced. It provides the necessary information for forward and backward compatibility for processing tools." + }, + "dataLicense" : { + "description" : "License expression for dataLicense. See SPDX Annex D for the license expression syntax. Compliance with the SPDX specification includes populating the SPDX fields therein with data related to such fields (\"SPDX-Metadata\"). The SPDX specification contains numerous fields where an SPDX document creator may provide relevant explanatory text in SPDX-Metadata. Without opining on the lawfulness of \"database rights\" (in jurisdictions where applicable), such explanatory text is copyrightable subject matter in most Berne Convention countries. By using the SPDX specification, or any portion hereof, you hereby agree that any copyright rights (as determined by your jurisdiction) in any SPDX-Metadata, including without limitation explanatory text, shall be subject to the terms of the Creative Commons CC0 1.0 Universal license. For SPDX-Metadata not containing any copyright rights, you hereby agree and acknowledge that the SPDX-Metadata is provided to you \"as-is\" and without any representations or warranties of any kind concerning the SPDX-Metadata, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non-infringement, or the absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.", + "type" : "string" + }, + "externalDocumentRefs" : { + "description" : "Identify any external SPDX documents referenced within this SPDX document.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "checksum" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexidecimal encoded digest value produced using a specific algorithm.", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + }, + "externalDocumentId" : { + "description" : "externalDocumentId is a string containing letters, numbers, ., - and/or + which uniquely identifies an external document within this document.", + "type" : "string" + }, + "spdxDocument" : { + "description" : "SPDX ID for SpdxDocument. A property containing an SPDX document.", + "type" : "string" + } + }, + "required" : [ "checksum", "externalDocumentId", "spdxDocument" ], + "additionalProperties" : false, + "description" : "Information about an external SPDX document reference including the checksum. This allows for verification of the external references." + } + }, + "hasExtractedLicensingInfos" : { + "description" : "Indicates that a particular ExtractedLicensingInfo was defined in the subject SpdxDocument.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "crossRefs" : { + "description" : "Cross Reference Detail for a license SeeAlso URL", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "isLive" : { + "description" : "Indicate a URL is still a live accessible location on the public internet", + "type" : "boolean" + }, + "isValid" : { + "description" : "True if the URL is a valid well formed URL", + "type" : "boolean" + }, + "isWayBackLink" : { + "description" : "True if the License SeeAlso URL points to a Wayback archive", + "type" : "boolean" + }, + "match" : { + "description" : "Status of a License List SeeAlso URL reference if it refers to a website that matches the license text.", + "type" : "string" + }, + "order" : { + "description" : "The ordinal order of this element within a list", + "type" : "integer" + }, + "timestamp" : { + "description" : "Timestamp", + "type" : "string" + }, + "url" : { + "description" : "URL Reference", + "type" : "string" + } + }, + "required" : [ "url" ], + "additionalProperties" : false, + "description" : "Cross reference details for the a URL reference" + } + }, + "extractedText" : { + "description" : "Provide a copy of the actual text of the license reference extracted from the package, file or snippet that is associated with the License Identifier to aid in future analysis.", + "type" : "string" + }, + "licenseId" : { + "description" : "A human readable short form license identifier for a license. The license ID is either on the standard license list or the form \"LicenseRef-[idString]\" where [idString] is a unique string containing letters, numbers, \".\" or \"-\". When used within a license expression, the license ID can optionally include a reference to an external document in the form \"DocumentRef-[docrefIdString]:LicenseRef-[idString]\" where docRefIdString is an ID for an external document reference.", + "type" : "string" + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "seeAlsos" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "required" : [ "extractedText", "licenseId" ], + "additionalProperties" : false, + "description" : "An ExtractedLicensingInfo represents a license or licensing notice that was found in a package, file or snippet. Any license text that is recognized as a license may be represented as a License rather than an ExtractedLicensingInfo." + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "revieweds" : { + "description" : "Reviewed", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "reviewDate" : { + "description" : "The date and time at which the SpdxDocument was reviewed. This value must be in UTC and have 'Z' as its timezone indicator.", + "type" : "string" + }, + "reviewer" : { + "description" : "The name and, optionally, contact information of the person who performed the review. Values of this property must conform to the agent and tool syntax. The reviewer property is deprecated in favor of Annotation with an annotationType review.", + "type" : "string" + } + }, + "required" : [ "reviewDate" ], + "additionalProperties" : false, + "description" : "This class has been deprecated in favor of an Annotation with an Annotation type of review." + } + }, + "spdxVersion" : { + "description" : "Provide a reference number that can be used to understand how to parse and interpret the rest of the file. It will enable both future changes to the specification and to support backward compatibility. The version number consists of a major and minor version indicator. The major field will be incremented when incompatible changes between versions are made (one or more sections are created, modified or deleted). The minor field will be incremented when backwards compatible changes are made.", + "type" : "string" + }, + "documentNamespace" : { + "type" : "string", + "description" : "The URI provides an unambiguous mechanism for other SPDX documents to reference SPDX elements within this SPDX document." + }, + "documentDescribes" : { + "description" : "Packages, files and/or Snippets described by this SPDX document", + "type" : "array", + "items" : { + "type" : "string", + "description" : "SPDX ID for each Package, File, or Snippet." + } + }, + "packages" : { + "description" : "Packages referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "builtDate" : { + "description" : "This field provides a place for recording the actual date the package was built.", + "type" : "string" + }, + "checksums" : { + "description" : "The checksum property provides a mechanism that can be used to verify that the contents of a File or Package have not changed.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexidecimal encoded digest value produced using a specific algorithm.", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "description" : { + "description" : "Provides a detailed description of the package.", + "type" : "string" + }, + "downloadLocation" : { + "description" : "The URI at which this package is available for download. Private (i.e., not publicly reachable) URIs are acceptable as values of this property. The values http://spdx.org/rdf/terms#none and http://spdx.org/rdf/terms#noassertion may be used to specify that the package is not downloadable or that no attempt was made to determine its download location, respectively.", + "type" : "string" + }, + "externalRefs" : { + "description" : "An External Reference allows a Package to reference an external source of additional information, metadata, enumerations, asset identifiers, or downloadable content believed to be relevant to the Package.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "comment" : { + "type" : "string" + }, + "referenceCategory" : { + "description" : "Category for the external reference", + "type" : "string", + "enum" : [ "OTHER", "PERSISTENT-ID", "SECURITY", "PACKAGE-MANAGER" ] + }, + "referenceLocator" : { + "description" : "The unique string with no spaces necessary to access the package-specific information, metadata, or content within the target location. The format of the locator is subject to constraints defined by the .", + "type" : "string" + }, + "referenceType" : { + "description" : "Type of the external reference. These are definined in an appendix in the SPDX specification.", + "type" : "string" + } + }, + "required" : [ "referenceCategory", "referenceLocator", "referenceType" ], + "additionalProperties" : false, + "description" : "An External Reference allows a Package to reference an external source of additional information, metadata, enumerations, asset identifiers, or downloadable content believed to be relevant to the Package." + } + }, + "filesAnalyzed" : { + "description" : "Indicates whether the file content of this package has been available for or subjected to analysis when creating the SPDX document. If false indicates packages that represent metadata or URI references to a project, product, artifact, distribution or a component. If set to false, the package must not contain any files.", + "type" : "boolean" + }, + "hasFiles" : { + "description" : "Indicates that a particular file belongs to a package.", + "type" : "array", + "items" : { + "description" : "SPDX ID for File. Indicates that a particular file belongs to a package.", + "type" : "string" + } + }, + "homepage" : { + "type" : "string" + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseDeclared" : { + "description" : "License expression for licenseDeclared. See SPDX Annex D for the license expression syntax. The licensing that the creators of the software in the package, or the packager, have declared. Declarations by the original software creator should be preferred, if they exist.", + "type" : "string" + }, + "licenseInfoFromFiles" : { + "description" : "The licensing information that was discovered directly within the package. There will be an instance of this property for each distinct value of alllicenseInfoInFile properties of all files contained in the package.\n\nIf the licenseInfoFromFiles field is not present for a package and filesAnalyzed property for that same pacakge is true or omitted, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoFromFiles. See SPDX Annex D for the license expression syntax. The licensing information that was discovered directly within the package. There will be an instance of this property for each distinct value of alllicenseInfoInFile properties of all files contained in the package.\n\nIf the licenseInfoFromFiles field is not present for a package and filesAnalyzed property for that same pacakge is true or omitted, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "originator" : { + "description" : "The name and, optionally, contact information of the person or organization that originally created the package. Values of this property must conform to the agent and tool syntax.", + "type" : "string" + }, + "packageFileName" : { + "description" : "The base name of the package file name. For example, zlib-1.2.5.tar.gz.", + "type" : "string" + }, + "packageVerificationCode" : { + "type" : "object", + "properties" : { + "packageVerificationCodeExcludedFiles" : { + "description" : "A file that was excluded when calculating the package verification code. This is usually a file containing SPDX data regarding the package. If a package contains more than one SPDX file all SPDX files must be excluded from the package verification code. If this is not done it would be impossible to correctly calculate the verification codes in both files.", + "type" : "array", + "items" : { + "description" : "A file that was excluded when calculating the package verification code. This is usually a file containing SPDX data regarding the package. If a package contains more than one SPDX file all SPDX files must be excluded from the package verification code. If this is not done it would be impossible to correctly calculate the verification codes in both files.", + "type" : "string" + } + }, + "packageVerificationCodeValue" : { + "description" : "The actual package verification code as a hex encoded value.", + "type" : "string" + } + }, + "required" : [ "packageVerificationCodeValue" ], + "additionalProperties" : false, + "description" : "A manifest based verification code (the algorithm is defined in section 4.7 of the full specification) of the SPDX Item. This allows consumers of this data and/or database to determine if an SPDX item they have in hand is identical to the SPDX item from which the data was produced. This algorithm works even if the SPDX document is included in the SPDX item." + }, + "primaryPackagePurpose" : { + "description" : "This field provides information about the primary purpose of the identified package. Package Purpose is intrinsic to how the package is being used rather than the content of the package.", + "type" : "string", + "enum" : [ "OTHER", "INSTALL", "ARCHIVE", "FIRMWARE", "APPLICATION", "FRAMEWORK", "LIBRARY", "CONTAINER", "SOURCE", "DEVICE", "OPERATING_SYSTEM", "FILE" ] + }, + "releaseDate" : { + "description" : "This field provides a place for recording the date the package was released.", + "type" : "string" + }, + "sourceInfo" : { + "description" : "Allows the producer(s) of the SPDX document to describe how the package was acquired and/or changed from the original source.", + "type" : "string" + }, + "summary" : { + "description" : "Provides a short description of the package.", + "type" : "string" + }, + "supplier" : { + "description" : "The name and, optionally, contact information of the person or organization who was the immediate supplier of this package to the recipient. The supplier may be different than originator when the software has been repackaged. Values of this property must conform to the agent and tool syntax.", + "type" : "string" + }, + "validUntilDate" : { + "description" : "This field provides a place for recording the end of the support period for a package from the supplier.", + "type" : "string" + }, + "versionInfo" : { + "description" : "Provides an indication of the version of the package that is described by this SpdxDocument.", + "type" : "string" + } + }, + "required" : [ "SPDXID", "downloadLocation", "name" ], + "additionalProperties" : false + } + }, + "files" : { + "description" : "Files referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "artifactOfs" : { + "description" : "Indicates the project in which the SpdxElement originated. Tools must preserve doap:homepage and doap:name properties and the URI (if one is known) of doap:Project resources that are values of this property. All other properties of doap:Projects are not directly supported by SPDX and may be dropped when translating to or from some SPDX formats.", + "type" : "array", + "items" : { + "type" : "object" + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "checksums" : { + "description" : "The checksum property provides a mechanism that can be used to verify that the contents of a File or Package have not changed.", + "minItems" : 1, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "algorithm" : { + "description" : "Identifies the algorithm used to produce the subject Checksum. Currently, SHA-1 is the only supported algorithm. It is anticipated that other algorithms will be supported at a later time.", + "type" : "string", + "enum" : [ "SHA1", "BLAKE3", "SHA3-384", "SHA256", "SHA384", "BLAKE2b-512", "BLAKE2b-256", "SHA3-512", "MD2", "ADLER32", "MD4", "SHA3-256", "BLAKE2b-384", "SHA512", "MD6", "MD5", "SHA224" ] + }, + "checksumValue" : { + "description" : "The checksumValue property provides a lower case hexidecimal encoded digest value produced using a specific algorithm.", + "type" : "string" + } + }, + "required" : [ "algorithm", "checksumValue" ], + "additionalProperties" : false, + "description" : "A Checksum is value that allows the contents of a file to be authenticated. Even small changes to the content of the file will change its checksum. This class allows the results of a variety of checksum and cryptographic message digest algorithms to be represented." + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "fileContributors" : { + "description" : "This field provides a place for the SPDX file creator to record file contributors. Contributors could include names of copyright holders and/or authors who may not be copyright holders yet contributed to the file content.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX file creator to record file contributors. Contributors could include names of copyright holders and/or authors who may not be copyright holders yet contributed to the file content.", + "type" : "string" + } + }, + "fileDependencies" : { + "description" : "This field is deprecated since SPDX 2.0 in favor of using Section 7 which provides more granularity about relationships.", + "type" : "array", + "items" : { + "description" : "SPDX ID for File. This field is deprecated since SPDX 2.0 in favor of using Section 7 which provides more granularity about relationships.", + "type" : "string" + } + }, + "fileName" : { + "description" : "The name of the file relative to the root of the package.", + "type" : "string" + }, + "fileTypes" : { + "description" : "The type of the file.", + "type" : "array", + "items" : { + "description" : "The type of the file.", + "type" : "string", + "enum" : [ "OTHER", "DOCUMENTATION", "IMAGE", "VIDEO", "ARCHIVE", "SPDX", "APPLICATION", "SOURCE", "BINARY", "TEXT", "AUDIO" ] + } + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseInfoInFiles" : { + "description" : "Licensing information that was discovered directly in the subject file. This is also considered a declared license for the file.\n\nIf the licenseInfoInFile field is not present for a file, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoInFile. See SPDX Annex D for the license expression syntax. Licensing information that was discovered directly in the subject file. This is also considered a declared license for the file.\n\nIf the licenseInfoInFile field is not present for a file, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "noticeText" : { + "description" : "This field provides a place for the SPDX file creator to record potential legal notices found in the file. This may or may not include copyright statements.", + "type" : "string" + } + }, + "required" : [ "SPDXID", "checksums", "fileName" ], + "additionalProperties" : false + } + }, + "snippets" : { + "description" : "Snippets referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "SPDXID" : { + "type" : "string", + "description" : "Uniquely identify any element in an SPDX document which may be referenced by other elements." + }, + "annotations" : { + "description" : "Provide additional information about an SpdxElement.", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "annotationDate" : { + "description" : "Identify when the comment was made. This is to be specified according to the combined date and time in the UTC format, as specified in the ISO 8601 standard.", + "type" : "string" + }, + "annotationType" : { + "description" : "Type of the annotation.", + "type" : "string", + "enum" : [ "OTHER", "REVIEW" ] + }, + "annotator" : { + "description" : "This field identifies the person, organization, or tool that has commented on a file, package, snippet, or the entire document.", + "type" : "string" + }, + "comment" : { + "type" : "string" + } + }, + "required" : [ "annotationDate", "annotationType", "annotator", "comment" ], + "additionalProperties" : false, + "description" : "An Annotation is a comment on an SpdxItem by an agent." + } + }, + "attributionTexts" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "array", + "items" : { + "description" : "This field provides a place for the SPDX data creator to record acknowledgements that may be required to be communicated in some contexts. This is not meant to include the actual complete license text (see licenseConculded and licenseDeclared), and may or may not include copyright notices (see also copyrightText). The SPDX data creator may use this field to record other acknowledgements, such as particular clauses from license texts, which may be necessary or desirable to reproduce.", + "type" : "string" + } + }, + "comment" : { + "type" : "string" + }, + "copyrightText" : { + "description" : "The text of copyright declarations recited in the package, file or snippet.\n\nIf the copyrightText field is not present, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseComments" : { + "description" : "The licenseComments property allows the preparer of the SPDX document to describe why the licensing in spdx:licenseConcluded was chosen.", + "type" : "string" + }, + "licenseConcluded" : { + "description" : "License expression for licenseConcluded. See SPDX Annex D for the license expression syntax. The licensing that the preparer of this SPDX document has concluded, based on the evidence, actually applies to the SPDX Item.\n\nIf the licenseConcluded field is not present for an SPDX Item, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + }, + "licenseInfoInSnippets" : { + "description" : "Licensing information that was discovered directly in the subject snippet. This is also considered a declared license for the snippet.\n\nIf the licenseInfoInSnippet field is not present for a snippet, it implies an equivalent meaning to NOASSERTION.", + "type" : "array", + "items" : { + "description" : "License expression for licenseInfoInSnippet. See SPDX Annex D for the license expression syntax. Licensing information that was discovered directly in the subject snippet. This is also considered a declared license for the snippet.\n\nIf the licenseInfoInSnippet field is not present for a snippet, it implies an equivalent meaning to NOASSERTION.", + "type" : "string" + } + }, + "name" : { + "description" : "Identify name of this SpdxElement.", + "type" : "string" + }, + "ranges" : { + "description" : "This field defines the byte range in the original host file (in X.2) that the snippet information applies to", + "minItems" : 1, + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "endPointer" : { + "type" : "object", + "properties" : { + "reference" : { + "description" : "SPDX ID for File", + "type" : "string" + }, + "offset" : { + "type" : "integer", + "description" : "Byte offset in the file" + }, + "lineNumber" : { + "type" : "integer", + "description" : "line number offset in the file" + } + }, + "required" : [ "reference" ], + "additionalProperties" : false + }, + "startPointer" : { + "type" : "object", + "properties" : { + "reference" : { + "description" : "SPDX ID for File", + "type" : "string" + }, + "offset" : { + "type" : "integer", + "description" : "Byte offset in the file" + }, + "lineNumber" : { + "type" : "integer", + "description" : "line number offset in the file" + } + }, + "required" : [ "reference" ], + "additionalProperties" : false + } + }, + "required" : [ "endPointer", "startPointer" ], + "additionalProperties" : false + } + }, + "snippetFromFile" : { + "description" : "SPDX ID for File. File containing the SPDX element (e.g. the file contaning a snippet).", + "type" : "string" + } + }, + "required" : [ "SPDXID", "name", "ranges", "snippetFromFile" ], + "additionalProperties" : false + } + }, + "relationships" : { + "description" : "Relationships referenced in the SPDX document", + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "spdxElementId" : { + "type" : "string", + "description" : "Id to which the SPDX element is related" + }, + "comment" : { + "type" : "string" + }, + "relatedSpdxElement" : { + "description" : "SPDX ID for SpdxElement. A related SpdxElement.", + "type" : "string" + }, + "relationshipType" : { + "description" : "Describes the type of relationship between two SPDX elements.", + "type" : "string", + "enum" : [ "VARIANT_OF", "COPY_OF", "PATCH_FOR", "TEST_DEPENDENCY_OF", "CONTAINED_BY", "DATA_FILE_OF", "OPTIONAL_COMPONENT_OF", "ANCESTOR_OF", "GENERATES", "CONTAINS", "OPTIONAL_DEPENDENCY_OF", "FILE_ADDED", "REQUIREMENT_DESCRIPTION_FOR", "DEV_DEPENDENCY_OF", "DEPENDENCY_OF", "BUILD_DEPENDENCY_OF", "DESCRIBES", "PREREQUISITE_FOR", "HAS_PREREQUISITE", "PROVIDED_DEPENDENCY_OF", "DYNAMIC_LINK", "DESCRIBED_BY", "METAFILE_OF", "DEPENDENCY_MANIFEST_OF", "PATCH_APPLIED", "RUNTIME_DEPENDENCY_OF", "TEST_OF", "TEST_TOOL_OF", "DEPENDS_ON", "SPECIFICATION_FOR", "FILE_MODIFIED", "DISTRIBUTION_ARTIFACT", "AMENDS", "DOCUMENTATION_OF", "GENERATED_FROM", "STATIC_LINK", "OTHER", "BUILD_TOOL_OF", "TEST_CASE_OF", "PACKAGE_OF", "DESCENDANT_OF", "FILE_DELETED", "EXPANDED_FROM_ARCHIVE", "DEV_TOOL_OF", "EXAMPLE_OF" ] + } + }, + "required" : [ "spdxElementId", "relatedSpdxElement", "relationshipType" ], + "additionalProperties" : false + } + } + }, + "required" : [ "SPDXID", "creationInfo", "dataLicense", "name", "spdxVersion" ], + "additionalProperties" : false +} \ No newline at end of file diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs new file mode 100644 index 0000000000..42a10e14b8 --- /dev/null +++ b/crates/trusted-server-release/src/documents.rs @@ -0,0 +1,559 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use jsonschema::Draft; +use serde::Deserialize; +use serde::Serialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::ser::{SerializeMap as _, SerializeSeq as _}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +#[derive(Clone, Copy, Debug)] +enum Role { + Metadata, + Companion, + Bundle, + Spdx, + Executable, +} + +impl Role { + fn limit(self) -> u64 { + match self { + Self::Metadata => 1024 * 1024, + Self::Companion | Self::Bundle => 32 * 1024 * 1024, + Self::Spdx => 16 * 1024 * 1024, + Self::Executable => 512 * 1024 * 1024, + } + } +} + +fn parse_json(bytes: &[u8], role: Role) -> Result> { + if bytes.len() as u64 > role.limit() { + return Err(invalid("document exceeds role byte limit")); + } + let text = std::str::from_utf8(bytes).map_err(|_| invalid("document must be UTF-8"))?; + if text.starts_with('\u{feff}') { + return Err(invalid("document must not start with a BOM")); + } + validate_numbers(text)?; + let mut parser = serde_json::Deserializer::from_str(text); + let value = StrictSeed { depth: 1 } + .deserialize(&mut parser) + .map_err(invalid)?; + parser.end().map_err(invalid)?; + Ok(value) +} + +struct RawBytes { + bytes: Vec, + sha256: String, +} + +fn read_bounded(path: &Path, role: Role) -> Result> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(invalid)?; + let before = file.metadata().map_err(invalid)?; + read_held(&file, &before, role) +} + +fn read_held( + file: &File, + before: &std::fs::Metadata, + role: Role, +) -> Result> { + if !before.is_file() || before.nlink() != 1 { + return Err(invalid("input must be a regular file with one hard link")); + } + if before.len() > role.limit() { + return Err(invalid("input exceeds role byte limit")); + } + let mut bytes = Vec::new(); + file.take(role.limit() + 1) + .read_to_end(&mut bytes) + .map_err(invalid)?; + let after = file.metadata().map_err(invalid)?; + if stamp(before) != stamp(&after) || bytes.len() as u64 != before.len() { + return Err(invalid("input changes while reading held descriptor")); + } + if bytes.len() as u64 > role.limit() { + return Err(invalid("input exceeds role byte limit")); + } + let sha256 = hex::encode(Sha256::digest(&bytes)); + Ok(RawBytes { bytes, sha256 }) +} + +fn stamp(metadata: &std::fs::Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +struct FlatName<'a>(&'a str); + +fn safe_name(name: &str) -> Result, Report> { + let mut parts = name.split('.'); + let first = parts.next().unwrap_or_default(); + if name.len() > 160 + || !first + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + || std::iter::once(first).chain(parts).any(|part| { + part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + { + return Err(invalid("unsafe flat filename")); + } + Ok(FlatName(name)) +} + +struct LimitedBytes { + bytes: Vec, + limit: u64, +} + +impl io::Write for LimitedBytes { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() as u64 > self.limit.saturating_sub(self.bytes.len() as u64) { + return Err(io::Error::other("output exceeds role byte limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct Canonical<'a> { + value: &'a Value, + depth: usize, +} + +impl Serialize for Canonical<'_> { + fn serialize(&self, serializer: S) -> Result { + if self.depth > 64 && matches!(self.value, Value::Object(_) | Value::Array(_)) { + return Err(serde::ser::Error::custom("JSON container depth exceeds 64")); + } + match self.value { + Value::Object(values) => { + let ordered: BTreeMap<_, _> = values.iter().collect(); + let mut map = serializer.serialize_map(Some(ordered.len()))?; + for (key, value) in ordered { + map.serialize_entry( + key, + &Canonical { + value, + depth: self.depth + 1, + }, + )?; + } + map.end() + } + Value::Array(values) => { + let mut sequence = serializer.serialize_seq(Some(values.len()))?; + for value in values { + sequence.serialize_element(&Canonical { + value, + depth: self.depth + 1, + })?; + } + sequence.end() + } + scalar => scalar.serialize(serializer), + } + } +} + +fn canonical_json(value: &Value, role: Role) -> Result, Report> { + let mut output = LimitedBytes { + bytes: Vec::new(), + limit: role.limit(), + }; + serde_json::to_writer(&mut output, &Canonical { value, depth: 1 }).map_err(invalid)?; + output.write_all(b"\n").map_err(invalid)?; + Ok(output.bytes) +} + +// The caller must select a trusted parent directory; Task 3 owns source/path policy. +fn atomic_json(path: &Path, value: &Value, role: Role) -> Result<(), Report> { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| invalid("output needs a UTF-8 flat filename"))?; + let name = safe_name(name)?; + let bytes = canonical_json(value, role)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(parent) + .map_err(invalid)?; + let destination = parent.join(name.0); + match std::fs::symlink_metadata(&destination) { + Ok(metadata) if !metadata.is_file() || metadata.nlink() != 1 => { + return Err(invalid("output must be an unlinked regular file or absent")); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(invalid(error)), + } + let mut temporary = tempfile::Builder::new() + .prefix(".release-") + .tempfile_in(parent) + .map_err(invalid)?; + temporary + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(invalid)?; + temporary.write_all(&bytes).map_err(invalid)?; + temporary.as_file().sync_all().map_err(invalid)?; + let held = directory.metadata().map_err(invalid)?; + let current = std::fs::metadata(parent).map_err(invalid)?; + if (held.dev(), held.ino()) != (current.dev(), current.ino()) { + return Err(invalid("output parent changes before replacement")); + } + commit_output(temporary, &destination, &directory) +} + +fn commit_output( + temporary: tempfile::NamedTempFile, + path: &Path, + directory: &File, +) -> Result<(), Report> { + temporary + .persist(path) + .map_err(|error| invalid(error.error))?; + directory.sync_all().map_err(invalid) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FileRecord { + name: String, + size: u64, + sha256: String, +} + +fn validate_file_records(values: &[Value], role: Role) -> Result<(), Report> { + let mut names = BTreeSet::new(); + for value in values { + let record = FileRecord::deserialize(value).map_err(invalid)?; + record.check(role, &mut names)?; + } + Ok(()) +} + +impl FileRecord { + fn check(&self, role: Role, names: &mut BTreeSet) -> Result<(), Report> { + safe_name(&self.name)?; + if self.size > role.limit() + || !lower_hex(&self.sha256, 64) + || !names.insert(self.name.to_ascii_lowercase()) + { + return Err(invalid( + "file record has invalid size, digest or duplicate identity", + )); + } + Ok(()) + } + + fn fields(value: &Value) -> Result> { + Ok(Self { + name: value["name"] + .as_str() + .ok_or_else(|| invalid("missing file name"))? + .to_owned(), + size: value["size"] + .as_u64() + .ok_or_else(|| invalid("file length must be a nonnegative integer token"))?, + sha256: value["sha256"] + .as_str() + .ok_or_else(|| invalid("missing file digest"))? + .to_owned(), + }) + } +} + +fn lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[derive(Clone, Copy)] +enum KnownSchema { + ReleaseIndex, + Qualification, + Spdx, +} + +impl KnownSchema { + fn bytes(&self) -> &'static [u8] { + match self { + Self::ReleaseIndex => include_bytes!( + "../../../docs/superpowers/specs/schemas/release-index-v1.schema.json" + ), + Self::Qualification => { + include_bytes!("../../../scripts/release/qualification/results.schema.json") + } + Self::Spdx => include_bytes!("../schemas/spdx-2.3.schema.json"), + } + } + fn draft(&self) -> Draft { + match self { + Self::ReleaseIndex | Self::Qualification => Draft::Draft202012, + Self::Spdx => Draft::Draft7, + } + } +} + +fn schema_accepts( + schema: &Value, + draft: Draft, + value: &Value, +) -> Result> { + let validator = jsonschema::options() + .with_draft(draft) + .offline() + .build(schema) + .map_err(invalid)?; + Ok(validator.is_valid(value)) +} + +fn validate_schema(schema: KnownSchema, value: &Value) -> Result<(), Report> { + let retained = if matches!(schema, KnownSchema::Spdx) { + parse_pinned_spdx_schema(schema.bytes())? + } else { + parse_json(schema.bytes(), Role::Metadata)? + }; + if !schema_accepts(&retained, schema.draft(), value)? { + return Err(invalid("document does not match retained schema")); + } + if matches!( + schema, + KnownSchema::ReleaseIndex | KnownSchema::Qualification + ) { + validate_safe_integers(value)?; + } + if matches!(schema, KnownSchema::ReleaseIndex) { + let mut names = BTreeSet::new(); + FileRecord::fields(&value["tsjs_inventory"])?.check(Role::Metadata, &mut names)?; + for artifact in value["artifacts"] + .as_array() + .ok_or_else(|| invalid("missing artifacts"))? + { + FileRecord::fields(artifact)?.check(Role::Executable, &mut names)?; + for (kind, companion) in artifact["companions"] + .as_object() + .ok_or_else(|| invalid("missing companions"))? + { + let role = if kind == "sbom" { + Role::Spdx + } else { + Role::Companion + }; + FileRecord::fields(companion)?.check(role, &mut names)?; + } + } + } + Ok(()) +} + +fn parse_pinned_spdx_schema(bytes: &[u8]) -> Result> { + if bytes.len() != 45_312 + || hex::encode(Sha256::digest(bytes)) + != "239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b" + { + return Err(invalid( + "retained SPDX schema raw identity does not match pinned source", + )); + } + let value = parse_json(bytes, Role::Metadata)?; + if value["$schema"] != "http://json-schema.org/draft-07/schema#" { + return Err(invalid("retained SPDX schema must declare Draft 7")); + } + Ok(value) +} + +fn validate_safe_integers(value: &Value) -> Result<(), Report> { + match value { + Value::Number(number) + if number + .as_u64() + .is_none_or(|integer| integer > 9_007_199_254_740_991) => + { + return Err(invalid( + "closed role requires nonnegative safe integer tokens", + )); + } + Value::Object(values) => { + for value in values.values() { + validate_safe_integers(value)?; + } + } + Value::Array(values) => { + for value in values { + validate_safe_integers(value)?; + } + } + _ => {} + } + Ok(()) +} + +fn invalid(message: impl fmt::Display) -> Report { + Report::new(ReleaseError::Document).attach(message.to_string()) +} + +// Serde may otherwise convert overflowing integer tokens to finite floats. +fn validate_numbers(text: &str) -> Result<(), Report> { + let bytes = text.as_bytes(); + let mut cursor = 0; + while cursor < bytes.len() { + if bytes[cursor] == b'"' { + cursor += 1; + while cursor < bytes.len() { + match bytes[cursor] { + b'\\' => cursor += 2, + b'"' => { + cursor += 1; + break; + } + _ => cursor += 1, + } + } + } else if bytes[cursor] == b'-' || bytes[cursor].is_ascii_digit() { + let start = cursor; + while cursor < bytes.len() + && matches!( + bytes[cursor], + b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9' + ) + { + cursor += 1; + } + let token = &text[start..cursor]; + let valid = if token.contains(['.', 'e', 'E']) { + token.parse::().is_ok_and(f64::is_finite) + } else if token.starts_with('-') { + token.parse::().is_ok() + } else { + token.parse::().is_ok() + }; + if !valid { + return Err(invalid("JSON number exceeds its finite numeric range")); + } + } else { + cursor += 1; + } + } + Ok(()) +} + +struct StrictSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictSeed { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictSeed { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON with unique object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite number")) + } + + fn visit_seq>(self, mut access: A) -> Result { + if self.depth > 64 { + return Err(serde::de::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Vec::new(); + while let Some(value) = access.next_element_seed(StrictSeed { + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map>(self, mut access: A) -> Result { + if self.depth > 64 { + return Err(serde::de::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Map::new(); + while let Some(key) = access.next_key::()? { + if values.contains_key(&key) { + return Err(serde::de::Error::custom("duplicate object key")); + } + values.insert( + key, + access.next_value_seed(StrictSeed { + depth: self.depth + 1, + })?, + ); + } + Ok(Value::Object(values)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/documents/tests.rs b/crates/trusted-server-release/src/documents/tests.rs new file mode 100644 index 0000000000..56a6c7ecbc --- /dev/null +++ b/crates/trusted-server-release/src/documents/tests.rs @@ -0,0 +1,767 @@ +use std::ffi::CString; +use std::fs; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{MetadataExt as _, symlink}; + +use serde_json::json; +use sha2::{Digest as _, Sha256}; +use tempfile::tempdir; + +use super::*; + +fn parse(bytes: &[u8]) -> Result> { + parse_json(bytes, Role::Metadata) +} + +#[test] +fn rejects_duplicate_keys_at_every_depth() { + for depth in 0..64 { + let input = format!( + "{}{{\"same\":1,\"same\":2}}{}", + "[".repeat(depth), + "]".repeat(depth) + ); + assert!( + parse(input.as_bytes()).is_err(), + "should reject duplicate at depth {depth}" + ); + } +} + +#[test] +fn rejects_duplicate_decoded_keys_in_opaque_objects() { + assert!( + parse(br#"{"opaque":{"x":0,"\u0078":1}}"#).is_err(), + "should reject escaped duplicate key" + ); +} + +#[test] +fn rejects_top_level_integer_overflow() { + for input in ["18446744073709551616", "-9223372036854775809"] { + assert!( + parse(input.as_bytes()).is_err(), + "should reject integer overflow: {input}" + ); + } +} + +#[test] +fn rejects_opaque_integer_overflow() { + for input in ["18446744073709551616", "-9223372036854775809"] { + let bytes = format!("{{\"opaque\":[{{\"value\":{input}}}]}}"); + assert!( + parse(bytes.as_bytes()).is_err(), + "should reject opaque integer overflow" + ); + } +} + +#[test] +fn rejects_nonfinite_and_decimal_exponent_overflow() { + for input in [ + "NaN", + "Infinity", + "-Infinity", + "1e309", + "-1e309", + "1.8e308", + "9".repeat(400).as_str(), + ] { + assert!( + parse(input.as_bytes()).is_err(), + "should reject nonfinite or overflowing number" + ); + } + for number in [ + format!("{}.0", "9".repeat(400)), + "1e999".to_owned(), + "-1.8e308".to_owned(), + ] { + let opaque = format!("{{\"opaque\":{{\"number\":{number}}}}}"); + assert!( + parse(opaque.as_bytes()).is_err(), + "should reject decimal/exponent overflow in opaque values" + ); + } +} + +#[test] +fn retains_exact_integer_boundaries() { + assert_eq!( + parse(b"18446744073709551615") + .expect("should accept u64 boundary") + .as_u64(), + Some(u64::MAX), + "should retain exact u64" + ); + assert_eq!( + parse(b"-9223372036854775808") + .expect("should accept i64 boundary") + .as_i64(), + Some(i64::MIN), + "should retain exact i64" + ); +} + +#[test] +fn number_like_quoted_text_and_escapes_remain_text() { + let value = json!({"text": "18446744073709551616 -9223372036854775809 1e999 \" quoted \\ backslash \\\" 1.8e308", "nested": ["9".repeat(400)]}); + let bytes = serde_json::to_vec(&value).expect("should serialize text controls"); + assert_eq!( + parse(&bytes).expect("should preserve number-like text"), + value, + "should skip strings including escaped quotes and backslashes" + ); +} + +#[test] +fn rejects_invalid_utf8_before_deserialization() { + let error = parse(b"\"\xff\"").expect_err("should reject invalid UTF-8"); + assert!( + format!("{error:?}").contains("UTF-8"), + "should diagnose byte encoding before deserialization" + ); +} + +#[test] +fn rejects_bom_before_deserialization() { + let error = parse(b"\xef\xbb\xbf{}").expect_err("should reject BOM"); + assert!( + format!("{error:?}").contains("BOM"), + "should diagnose BOM before deserialization" + ); +} + +#[test] +fn rejects_surrogates_and_trailing_controls() { + for input in [ + br#""\ud800""#.as_slice(), + br#""\udfff""#, + b"{}\0", + b"{}\x0b", + b"{}\x1f", + b"{}{}", + ] { + assert!( + parse(input).is_err(), + "should reject surrogate or trailing data" + ); + } +} + +#[test] +fn rejects_container_depth_65_including_root() { + for (open, close) in [("[", "]"), ("{\"nested\":", "}")] { + let good = format!("{}0{}", open.repeat(64), close.repeat(64)); + let bad = format!("{}0{}", open.repeat(65), close.repeat(65)); + assert!( + parse(good.as_bytes()).is_ok(), + "should accept 64 containers" + ); + assert!( + parse(bad.as_bytes()).is_err(), + "should reject 65 containers" + ); + } +} + +#[test] +fn rejects_oversized_json_before_encoding_or_parsing() { + for role in [Role::Metadata, Role::Companion, Role::Bundle, Role::Spdx] { + let bytes = vec![0xff; role.limit() as usize + 1]; + let error = parse_json(&bytes, role).expect_err("should reject role byte overflow"); + assert!( + format!("{error:?}").contains("byte limit"), + "should enforce size before encoding" + ); + } +} + +#[test] +fn rejects_oversized_files_before_reading() { + let root = tempdir().expect("should create fixtures"); + for role in [ + Role::Metadata, + Role::Companion, + Role::Bundle, + Role::Spdx, + Role::Executable, + ] { + let path = root.path().join("oversized"); + let file = fs::File::create(&path).expect("should create sparse oversized file"); + file.set_len(role.limit() + 1).expect("should size fixture"); + assert!( + read_bounded(&path, role).is_err(), + "should reject oversized role before read" + ); + } +} + +#[test] +fn retains_hash_of_exact_original_bytes() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("source.json"); + let bytes = b" { \"a\" : 1 } \n"; + fs::write(&path, bytes).expect("should write original bytes"); + let raw = read_bounded(&path, Role::Metadata).expect("should read exact bytes"); + assert_eq!(raw.bytes, bytes, "should retain original bytes"); + assert_eq!( + raw.sha256, + hex::encode(Sha256::digest(bytes)), + "should hash original bytes before normalization" + ); +} + +#[test] +fn rejects_unsafe_flat_names() { + for name in [ + "", ".", "..", ".hidden", "../a", "a/b", "a\\b", "a..b", "a.", "a b", "a\n", "a\0", "é", + "-a", "a:b", + ] { + assert!( + safe_name(name).is_err(), + "should reject unsafe name {name:?}" + ); + } + assert!( + safe_name(&"a".repeat(161)).is_err(), + "should reject long name" + ); + for name in [ + "a", + "ts-x86_64-unknown-linux-gnu", + "file.spdx.json", + "A_0-z", + ] { + assert!(safe_name(name).is_ok(), "should accept safe flat name"); + } +} + +#[test] +fn rejects_symlinks_hardlinks_and_directories_at_read() { + let root = tempdir().expect("should create fixtures"); + let real = root.path().join("real"); + fs::write(&real, b"{}").expect("should write fixture"); + let link = root.path().join("symlink"); + symlink(&real, &link).expect("should create symlink"); + assert!( + read_bounded(&link, Role::Metadata).is_err(), + "should reject symbolic leaf" + ); + let hard = root.path().join("hardlink"); + fs::hard_link(&real, &hard).expect("should create hardlink"); + assert_eq!( + fs::metadata(&hard).expect("should inspect links").nlink(), + 2, + "should exercise hardlink fixture" + ); + assert!( + read_bounded(&hard, Role::Metadata).is_err(), + "should reject hardlink" + ); + assert!( + read_bounded(root.path(), Role::Metadata).is_err(), + "should reject directory" + ); +} + +#[test] +fn canonical_serialization_sorts_recursively_and_adds_one_newline() { + let value = json!({"z":[{"z": "é", "a":true}], "a":0}); + let bytes = canonical_json(&value, Role::Metadata).expect("should serialize canonical bytes"); + assert_eq!( + bytes, + "{\"a\":0,\"z\":[{\"a\":true,\"z\":\"é\"}]}\n".as_bytes(), + "should sort recursively and retain compact UTF-8" + ); +} + +#[test] +fn rejects_oversized_serialization_before_replacement() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("output.json"); + fs::write(&path, b"old").expect("should write previous document"); + let value = json!({"large":"x".repeat(Role::Metadata.limit() as usize)}); + assert!( + atomic_json(&path, &value, Role::Metadata).is_err(), + "should reject oversized output" + ); + assert_eq!( + fs::read(&path).expect("should retain old file"), + b"old", + "should preserve old bytes on rejected output" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should list fixtures") + .count(), + 1, + "should leave no temporary output" + ); +} + +#[test] +fn atomic_output_replaces_inode_with_private_mode() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("output.json"); + fs::write(&path, b"old").expect("should write previous document"); + let old = fs::metadata(&path).expect("should inspect old file").ino(); + atomic_json(&path, &json!({"answer":42}), Role::Metadata) + .expect("should replace complete document"); + let new = fs::metadata(&path).expect("should inspect replaced file"); + assert_ne!( + old, + new.ino(), + "should replace rather than truncate original inode" + ); + assert_eq!( + new.mode() & 0o777, + 0o600, + "should install private 0600 output" + ); + assert_eq!( + fs::read(&path).expect("should read complete output"), + b"{\"answer\":42}\n", + "should install full bytes" + ); +} + +#[test] +fn rejects_output_links_and_cleans_failed_replacement() { + let root = tempdir().expect("should create fixture"); + let real = root.path().join("real"); + fs::write(&real, b"old").expect("should write linked target"); + let link = root.path().join("link"); + symlink(&real, &link).expect("should create symbolic output"); + assert!( + atomic_json(&link, &json!({}), Role::Metadata).is_err(), + "should reject symbolic output" + ); + let hard = root.path().join("hard"); + fs::hard_link(&real, &hard).expect("should create hardlinked output"); + assert!( + atomic_json(&hard, &json!({}), Role::Metadata).is_err(), + "should reject hardlinked output" + ); + let directory = root.path().join("directory"); + fs::create_dir(&directory).expect("should create invalid destination"); + assert!( + atomic_json(&directory, &json!({}), Role::Metadata).is_err(), + "should reject directory output" + ); + assert_eq!( + fs::read(&real).expect("should retain original target"), + b"old", + "should preserve linked target bytes" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should inspect cleanup") + .count(), + 4, + "should remove every failed sibling output" + ); +} + +#[test] +fn rejects_negative_noninteger_and_out_of_role_lengths() { + for size in [ + json!(-1), + json!(1.0), + json!(1.5), + json!("1"), + json!(9007199254740992_u64), + json!(Role::Metadata.limit() + 1), + ] { + let record = json!({"name":"fixture.json", "size":size, "sha256":"a".repeat(64)}); + assert!( + validate_file_records(&[record], Role::Metadata).is_err(), + "should reject invalid length representation or bounds" + ); + } + for size in [0, 1, Role::Metadata.limit()] { + assert!( + validate_file_records( + &[json!({"name":"fixture.json", "size":size, "sha256":"a".repeat(64)})], + Role::Metadata + ) + .is_ok(), + "should accept exact valid role boundary" + ); + } +} + +#[test] +fn rejects_ambiguous_duplicate_file_identities() { + for names in [["same.json", "same.json"], ["File.json", "file.json"]] { + let records = names.map(|name| json!({"name":name,"size":1,"sha256":"a".repeat(64)})); + assert!( + validate_file_records(&records, Role::Metadata).is_err(), + "should reject duplicate identity including portable case collisions" + ); + } +} + +#[test] +fn rejects_invalid_file_record_fields() { + for record in [ + json!({"name":"../file", "size":1, "sha256":"a".repeat(64)}), + json!({"name":"file", "size":1, "sha256":"A".repeat(64)}), + json!({"name":"file", "size":1, "sha256":"a".repeat(64), "extra":true}), + ] { + assert!( + validate_file_records(&[record], Role::Metadata).is_err(), + "should reject unsafe, malformed or extra fields" + ); + } +} + +#[test] +fn draft_202012_prefix_items_and_items_are_enforced() { + let schema = + json!({"type":"array","prefixItems":[{"const":"first"},{"type":"integer"}],"items":false}); + assert!( + schema_accepts(&schema, Draft::Draft202012, &json!(["first", 2])) + .expect("should compile explicit 2020-12 schema"), + "should accept prefix items" + ); + for value in [ + json!(["wrong", 2]), + json!(["first", "wrong"]), + json!(["first", 2, 3]), + ] { + assert!( + !schema_accepts(&schema, Draft::Draft202012, &value).expect("should compile schema"), + "should reject invalid prefix or additional item" + ); + } +} + +#[test] +fn refuses_unregistered_remote_and_file_references() { + for reference in [ + "https://example.com/unregistered.json", + "http://example.com/unregistered.json", + "file:///tmp/unregistered-schema.json", + ] { + assert!( + schema_accepts(&json!({"$ref":reference}), Draft::Draft202012, &json!(null)).is_err(), + "should refuse unregistered references without retrieval" + ); + } +} + +#[test] +fn refuses_existing_local_reference_even_with_file_resolution_feature() { + let root = tempdir().expect("should create local schema fixture"); + let path = root.path().join("unregistered.json"); + fs::write( + &path, + serde_json::to_vec(&json!({"type":"null"})) + .expect("should serialize fictional local schema"), + ) + .expect("should write local schema"); + let reference = format!("file://{}", path.display()); + let result = schema_accepts(&json!({"$ref":reference}), Draft::Draft202012, &Value::Null); + assert!( + result.is_err(), + "should refuse existing file without reading an unregistered reference" + ); +} + +#[test] +fn retained_spdx_identity_and_draft7_are_exact() { + let bytes = KnownSchema::Spdx.bytes(); + assert_eq!( + bytes.len(), + 45312, + "should retain exact original schema length" + ); + assert_eq!( + hex::encode(Sha256::digest(bytes)), + "239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b", + "should retain pinned original schema hash" + ); + let value = parse(bytes).expect("should parse official retained schema"); + assert_eq!( + value["$schema"], "http://json-schema.org/draft-07/schema#", + "should use official Draft 7 dialect" + ); +} + +#[test] +fn rejects_altered_spdx_schema_before_evaluation() { + let original = KnownSchema::Spdx.bytes(); + let mut altered = original.to_vec(); + let title = altered + .windows(8) + .position(|bytes| bytes == b"SPDX 2.3") + .expect("should find official schema title"); + altered[title] = b'T'; + assert!( + parse_pinned_spdx_schema(&altered).is_err(), + "should reject altered but parseable retained schema" + ); + let mut extended = original.to_vec(); + extended.push(b'\n'); + assert!( + parse_pinned_spdx_schema(&extended).is_err(), + "should reject changed raw byte count before use" + ); + assert!( + parse_pinned_spdx_schema(original).is_ok(), + "should accept exact retained schema identity and dialect" + ); +} + +#[test] +fn official_spdx_schema_checks_structure_only() { + let valid = json!({"SPDXID":"SPDXRef-DOCUMENT", "spdxVersion":"SPDX-2.3", "creationInfo":{"creators":["Tool: fictional-fixture"],"created":"2026-01-01T00:00:00Z"}, "name":"Fictional fixture", "dataLicense":"CC0-1.0", "documentNamespace":"https://example.com/spdx/fictional-fixture"}); + assert!( + validate_schema(KnownSchema::Spdx, &valid).is_ok(), + "should accept a structural toy fixture only" + ); + for value in [json!({}), json!({"SPDXID":42}), { + let mut invalid = valid.clone(); + invalid["packages"] = json!([{"SPDXID":"SPDXRef-Package","name":"fictional"}]); + invalid + }] { + assert!( + validate_schema(KnownSchema::Spdx, &value).is_err(), + "should reject structurally incomplete SPDX" + ); + } +} + +#[test] +fn retained_governing_schemas_reject_missing_fields() { + for schema in [KnownSchema::ReleaseIndex, KnownSchema::Qualification] { + assert!( + validate_schema(schema, &json!({})).is_err(), + "should apply retained governing required fields" + ); + } +} + +#[test] +fn retained_qualification_schema_accepts_structural_observation_only() { + let mut value = json!({ + "schema_version":1, "purpose":"disposable-rehearsal-observation", "source_commit":"a".repeat(40), + "closure_sha256":"a".repeat(64), "selection_sha256":"a".repeat(64), "raw_tools_sha256":"a".repeat(64), "dependency_sha256":"a".repeat(64), + "run":{"repository_id":"1","owner_id":"1","run_id":"1","attempt":1,"job":"fictional-fixture","runner":"github-hosted"}, + "host":{"environment_id":"macos15-aarch64","kind":"github-hosted-macos","architecture":"aarch64","kernel_architecture":"aarch64", + "os_version":"fictional-fixture","translated":false,"runner_image_version":null,"oci_index_sha256":null,"oci_manifest_sha256":null, + "glibc_version":null,"identity_log_sha256":"a".repeat(64)}, + "sdk":{"kind":"apple-sdk","version":"fictional","build_version":"fictional","tree_sha256":"a".repeat(64),"vendor_evidence_sha256":"a".repeat(64)}, + "tools":[], + "recipe":{"id":"tsjs","boundary_sha256":"a".repeat(64),"preparation_log_sha256":"a".repeat(64),"compile_log_sha256":"a".repeat(64), + "native_log_sha256":"a".repeat(64),"exit_code":1,"full_recipe":false,"same_boundary":false,"executable_sha256":"a".repeat(64), + "native_materials_sha256":"a".repeat(64),"deployment_target":null,"glibc_symbols":[],"unconfined":[],"confined":[], + "download_attempts":{"cargo":"unavailable","npm":"unavailable","missing_tool":"unavailable"}}, + "runtime":[] + }); + assert!( + validate_schema(KnownSchema::Qualification, &value).is_ok(), + "should accept structural observation fixture without qualifying a release" + ); + value["run"]["attempt"] = json!(1.0); + assert!( + validate_schema(KnownSchema::Qualification, &value).is_err(), + "should reject float representation of integer observation" + ); + value["run"]["attempt"] = json!(1); + value["purpose"] = json!("production-qualified"); + assert!( + validate_schema(KnownSchema::Qualification, &value).is_err(), + "should enforce retained observation purpose" + ); +} + +fn index_fixture() -> Value { + let identities = [ + ( + "trusted-server-fastly.wasm", + "wasm32-wasip1", + "wasm32", + "fastly-compute", + "wasip1", + ), + ( + "ts-x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + "x86_64", + "linux-gnu", + "glibc-2.34", + ), + ( + "ts-aarch64-apple-darwin", + "aarch64-apple-darwin", + "aarch64", + "macos", + "13.0", + ), + ( + "ts-x86_64-apple-darwin", + "x86_64-apple-darwin", + "x86_64", + "macos", + "13.0", + ), + ]; + let artifacts: Vec<_> = identities.iter().map(|(name,target,architecture,runtime,minimum)| { + let is_server = *runtime == "fastly-compute"; + let companions: Map<_,_> = ["sbom", "provenance", "sbom_attestation", "build_evidence", "build_recipe", "rebuild_check"].iter() + .map(|role| (role.to_string(), json!({"name":format!("{name}.{role}.json"), "size":1, "sha256":"a".repeat(64)}))).collect(); + json!({"name":name, "size":1, "sha256":"a".repeat(64), "role":if is_server {"server"} else {"operator-cli"}, + "kind":if is_server {"wasm-core"} else {"native-executable"}, "package":if is_server {"trusted-server-adapter-fastly"} else {"trusted-server-cli"}, + "target":target, "variant":"default", "assurance_profile":if is_server {"provenance-sbom-repeatable"} else {"provenance-sbom"}, + "capabilities":if is_server {vec!["fastly-production"]} else {vec!["cli-execute"]}, + "compatibility":{"runtime":runtime,"minimum_version":minimum,"architecture":architecture}, + "js_inventory_sha256":"a".repeat(64), "companions":companions}) + }).collect(); + let schema = + parse(KnownSchema::ReleaseIndex.bytes()).expect("should load retained index schema"); + json!({"schema_version":1,"repository":schema["properties"]["repository"]["const"],"tag":"v1.0.0", + "source_commit":"a".repeat(40),"recipe_source_commit":"a".repeat(40),"cohort_source_commit":"a".repeat(40), + "cohort":"phase1-fastly-cli", "policy_version":1,"policy_sha256":"a".repeat(64), + "tsjs_inventory":{"name":"tsjs-inventory.json","size":1,"sha256":"a".repeat(64)},"artifacts":artifacts}) +} + +#[test] +fn retained_index_schema_accepts_only_structural_fixed_cohort() { + let value = index_fixture(); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_ok(), + "should accept structural fixed-cohort fixture only" + ); + let mut extra = value.clone(); + extra["artifacts"] + .as_array_mut() + .expect("should access artifacts") + .push(value["artifacts"][0].clone()); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &extra).is_err(), + "should reject fifth artifact" + ); + let mut wrong = value; + wrong["artifacts"][1]["target"] = json!("aarch64-apple-darwin"); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &wrong).is_err(), + "should reject mismatched fixed cohort identity" + ); +} + +#[test] +fn index_role_checks_reject_fractional_lengths_and_duplicate_companions() { + let good = index_fixture(); + for size in [ + json!(-1), + json!(1.0), + json!(1.5), + json!(9007199254740992_u64), + ] { + let mut value = good.clone(); + value["artifacts"][0]["companions"]["sbom"]["size"] = size; + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_err(), + "should reject noninteger or unsafe lengths after schema checks" + ); + } + let mut duplicate = good; + duplicate["artifacts"][1]["companions"]["sbom"]["name"] = + duplicate["artifacts"][0]["companions"]["sbom"]["name"].clone(); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &duplicate).is_err(), + "should reject duplicate companion identities across artifacts" + ); +} + +#[test] +fn index_role_checks_reject_unsafe_policy_integer() { + let mut value = index_fixture(); + value["policy_version"] = json!(9007199254740992_u64); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_err(), + "should reject integers above interoperable safe limit" + ); + value["policy_version"] = json!(1.0); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &value).is_err(), + "should require integer token representation" + ); +} + +#[test] +fn detects_mutation_on_held_input_descriptor() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("held"); + fs::write(&path, b"old").expect("should create input"); + let file = File::open(&path).expect("should hold original descriptor"); + let before = file.metadata().expect("should capture original metadata"); + fs::write(&path, b"new").expect("should mutate same-length input"); + assert!( + read_held(&file, &before, Role::Metadata).is_err(), + "should detect same-length mutation against captured metadata" + ); +} + +#[test] +fn held_read_does_not_reopen_replaced_path() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("held"); + fs::write(&path, b"original").expect("should create input"); + let file = File::open(&path).expect("should hold original descriptor"); + let before = file.metadata().expect("should capture input metadata"); + fs::remove_file(&path).expect("should remove input pathname"); + fs::write(&path, b"replacement").expect("should create different file"); + assert!( + read_held(&file, &before, Role::Metadata).is_err(), + "should reject removed held file rather than reopen replacement" + ); +} + +#[test] +fn rejects_fifo_without_waiting_for_a_writer() { + let root = tempdir().expect("should create fixture"); + let path = root.path().join("input-fifo"); + let name = CString::new(path.as_os_str().as_bytes()).expect("should encode fixture pathname"); + // The CString supplies a valid NUL-terminated pathname for this Unix syscall. + assert_eq!( + unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, + 0, + "should create FIFO fixture" + ); + assert!( + read_bounded(&path, Role::Metadata).is_err(), + "should reject FIFO without blocking on open or read" + ); +} + +#[test] +fn failed_rename_removes_private_sibling() { + let root = tempdir().expect("should create fixture"); + let destination = root.path().join("directory"); + fs::create_dir(&destination).expect("should create conflicting destination"); + let directory = File::open(root.path()).expect("should hold parent"); + let temporary = tempfile::Builder::new() + .prefix(".release-") + .tempfile_in(root.path()) + .expect("should create sibling"); + let temporary_path = temporary.path().to_owned(); + assert!( + commit_output(temporary, &destination, &directory).is_err(), + "should fail file-to-directory replacement" + ); + assert!( + !temporary_path.exists(), + "should clean sibling after actual rename failure" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should inspect cleanup") + .count(), + 1, + "should leave only existing destination" + ); +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs new file mode 100644 index 0000000000..7356392be7 --- /dev/null +++ b/crates/trusted-server-release/src/error.rs @@ -0,0 +1,12 @@ +/// Reports a closed release execution gate or invalid document input. +#[derive(Debug, derive_more::Display)] +pub(crate) enum ReleaseError { + #[display( + "Rust release execution remains disabled pending authenticated bootstrap and preflight" + )] + Pending, + #[display("Invalid release document")] + Document, +} + +impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs new file mode 100644 index 0000000000..11a9ec52c7 --- /dev/null +++ b/crates/trusted-server-release/src/main.rs @@ -0,0 +1,11 @@ +// Task 2 utilities remain private until Task 3 wires authenticated preflight. +#[allow(dead_code)] +mod documents; +mod error; + +use error::ReleaseError; +use error_stack::Report; + +fn main() -> Result<(), Report> { + Err(Report::new(ReleaseError::Pending)) +} diff --git a/crates/trusted-server-release/tests/commands.rs b/crates/trusted-server-release/tests/commands.rs new file mode 100644 index 0000000000..852eb76491 --- /dev/null +++ b/crates/trusted-server-release/tests/commands.rs @@ -0,0 +1,68 @@ +use std::fs; +use std::process::Command; + +use tempfile::tempdir; + +#[test] +fn producer_dispatch_remains_closed_for_every_invocation() { + let root = tempdir().expect("should create private process fixture"); + fs::write(root.path().join("sentinel"), b"untouched").expect("should create sentinel"); + for arguments in [ + vec![], + vec!["--help"], + vec!["--version"], + vec!["preflight", "--require-qualified"], + vec!["schema", "--schema-file", "override.json"], + vec!["publish"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_trusted-server-release")) + .args(&arguments) + .current_dir(root.path()) + .output() + .expect("should run closed producer binary"); + assert!( + !output.status.success(), + "should reject pending invocation {arguments:?}" + ); + assert!( + output.stdout.is_empty(), + "should emit no result or release bytes" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("release execution remains disabled"), + "should explain pending execution gate" + ); + assert_eq!( + fs::read_dir(root.path()) + .expect("should inspect process writes") + .count(), + 1, + "should create no release output" + ); + } + assert_eq!( + fs::read(root.path().join("sentinel")).expect("should read sentinel"), + b"untouched", + "should preserve existing operator files" + ); +} + +#[test] +fn inherited_approval_labels_cannot_enable_pending_execution() { + let output = Command::new(env!("CARGO_BIN_EXE_trusted-server-release")) + .args(["preflight", "--require-qualified"]) + .env("TS_RELEASE_BUILD", "1") + .env("TS_RELEASE_SOURCE_COMMIT", "a".repeat(40)) + .env("RELEASE_AUTHENTICATED", "true") + .env("RELEASE_QUALIFIED", "true") + .output() + .expect("should run pending producer"); + assert!( + !output.status.success(), + "should reject inherited labels as execution authority" + ); + assert!( + output.stdout.is_empty(), + "should return no release evidence" + ); +} 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..7fa5abd804 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/README.md @@ -0,0 +1,11 @@ +# Release document fixtures + +Private document unit tests in `src/documents/tests.rs` are registered by `src/documents.rs`; public process tests exercise only the closed producer dispatch in `tests/commands.rs`. 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. Authenticated source/path policy and the complete helper source closure belong to Task 3. Producer execution and the POSIX bootstrap remain closed pending that stage and independent authentication. Full real-artifact SPDX coverage remains Task 11 work. diff --git a/docs/guide/edgezero.md b/docs/guide/edgezero.md index ed9c4f2f2c..129529b1b8 100644 --- a/docs/guide/edgezero.md +++ b/docs/guide/edgezero.md @@ -69,8 +69,11 @@ store before deletion, especially when using `--store` or `--no-env`. 4. Run `ts config push --adapter ADAPTER`. 5. Start or deploy the adapter and verify a non-health publisher route. A healthy endpoint alone may not prove that application configuration loaded. - Confirm that its `x-ts-version` response header reports the release you - deployed. + Confirm that its `x-ts-version` response header reports the source identity + you deployed. A canonical attested release uses the approved full source + commit; its external release tag belongs to the signed release index. + Development source builds retain the override/tag/branch/short-commit + display fallback. The header alone does not verify artifact provenance. See [Configuration](/guide/configuration) for the field contract, the [CLI guide](/guide/cli) for exact command help, and diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index c8d48be43d..ad9f5f266a 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -620,7 +620,11 @@ TSJS_PREBUILT_DIR="$PWD/crates/trusted-server-js/dist" cargo build-fastly ``` `TSJS_PREBUILT_DIR` is checked the same way as a normal build: every expected -bundle must be present and non-empty. `TSJS_SKIP_BUILD` is no longer supported. +bundle must be present and non-empty. Inventory-absent development builds keep +unrelated non-bundle entries such as an external Prebid directory. Rust Cargo +builds reject ANY presence of `TSJS_SKIP_BUILD`, including empty, `0` and `1`. +Node development build/Vitest entry points preserve development behavior when +`TRUSTED_SERVER__RELEASE_BUILD` is absent. --- @@ -635,14 +639,37 @@ writes. | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tsjs: npm not found on PATH` | Node.js is not installed or not on `PATH`. Install Node.js, or set `TSJS_PREBUILT_DIR` as shown above. | | `tsjs: TSJS_PREBUILT_DIR must be an absolute path` | Cargo runs build scripts from `crates/trusted-server-js`, so a relative path would resolve there. Pass an absolute path, for example `"$PWD/crates/trusted-server-js/dist"`. | -| `tsjs: TSJS_SKIP_BUILD is no longer supported` | `TSJS_SKIP_BUILD=1` no longer skips the build. Unset it and use `TSJS_PREBUILT_DIR` instead. | +| `tsjs: TSJS_SKIP_BUILD is no longer supported whenever present` | Any present value, including empty, `0` or `1`, fails Rust Cargo builds. Unset it and use `TSJS_PREBUILT_DIR` instead. | | `tsjs: node_modules is out of date with package-lock.json` | Dependencies changed, for example after switching branches. Run `npm ci` in `crates/trusted-server-js/lib`. The check compares file times, so a checkout that rewrites an unchanged lockfile also triggers it; `npm ci` clears it. | | `tsjs: npm ci failed` | `node_modules` was missing and the automatic install failed. Run `npm ci` in `crates/trusted-server-js/lib` to see the error. | | `tsjs: invalid bundle set ... missing bundles` / `empty bundles` | The Node build or the `TSJS_PREBUILT_DIR` directory did not produce one `tsjs-.js` per `core` and `lib/src/integrations//index.ts`. | | `tsjs: invalid bundle set ... unexpected bundles` | `TSJS_PREBUILT_DIR` holds bundles from a different source tree. Rebuild it with `npm run build`. | +When `TRUSTED_SERVER__RELEASE_BUILD=1`, the source must be a full lowercase +40-character `TRUSTED_SERVER__GIT_VERSION`; missing/malformed source and other +present release-control values fail without Git fallback. Release Cargo builds +require absolute `TSJS_PREBUILT_DIR` and +`TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths and do not run npm. + +| Error message contains | Cause and fix | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tsjs: release builds require TSJS_PREBUILT_DIR` / `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` | Supply both absolute paths from the checked producer output. | +| `tsjs: TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY requires TSJS_PREBUILT_DIR` | An inventory cannot select bundles without a prebuilt directory. | +| `tsjs: TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY must be an absolute path` | Pass the absolute sibling inventory pathname. | +| `tsjs: invalid inventory path` | Inventory must be outside the exact bundle directory and a regular single-link file. | +| `tsjs: invalid inventory` / `invalid prebuilt inventory inputs` | Fix source/module order, byte sizes/digests or unsafe entries. The checked path rejects bundle links, hard links, empty/extra entries and changed copied bytes. | + +Inventory-present development builds use the same strict checked path; the +inventory validates its own source syntax/hash, while the development source +display label is not compared with its commit. Strict checked-copy support is +Linux/macOS; inventory-absent development follows the existing portable path. +The exact raw inventory digest is exposed as +`TRUSTED_SERVER__TSJS_INVENTORY_SHA256` for composition evidence. + Set `TSJS_TEST=1` to run the TypeScript tests before the build; a failing test -fails the build. +fails the build. The default development suite skips only two slow Git collector +fixtures; `TRUSTED_SERVER__RELEASE_JS_TESTS=1` enables the full registered suite, +as required in CI and both future release A/B executions. --- diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index d83b0cc863..b8b2a1cf47 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -219,6 +219,12 @@ outside a git checkout omits the header. The value is public on every response, so pass a neutral value such as the commit hash when a branch name is sensitive. +This is the source-build development path. Canonical attested release builds +use `TRUSTED_SERVER__RELEASE_BUILD=1` with an approved full 40-character source +commit and no Git display fallback. Their `x-ts-version` is that source identity; +the external release tag is recorded separately in the signed release index. +The header is not a binary checksum or attestation verification. + ## Next Steps - Learn about [Edge Cookies](/guide/edge-cookies) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 9e5dba87b9..84ae3f808a 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -4,9 +4,9 @@ **Goal:** Publish and verify the four canonical Fastly/`ts` artifacts required by #1235, with complete evidence and a validated manual Fastly production route. -**Architecture:** Controlled producer scripts build and describe bytes; a reusable workflow signs only validated outputs; one caller publishes the complete immutable distribution. A host-only Trusted Server verifier separately establishes build evidence, publication and consumer authorization before retaining verified bytes. Packaging and integrated EdgeZero deployment are separate milestones that consume these boundaries. +**Architecture:** A small domain-specific host-only Rust release tool builds and describes bytes; POSIX wrappers select its independently approved native bootstrap; a reusable workflow signs only validated outputs; one caller publishes the complete immutable distribution. A host-only Trusted Server verifier separately establishes build evidence, publication and consumer authorization before retaining verified bytes. Packaging and integrated EdgeZero deployment are separate milestones that consume these boundaries. -**Tech Stack:** Rust 1.95.0/2024, existing error-stack/serde/serde_json/tokio/reqwest/tempfile, workspace sha2, Python unittest and pinned JSON Schema validation, Node 24.12.0/npm, Vite/Rollup, SPDX 2.3, GitHub CLI 2.97.0, actions/attest 4.2.2, Fastly CLI 15.1.0. +**Tech Stack:** Rust 1.95.0/2024, existing workspace serde/serde_json/error-stack/derive_more/clap/sha2/log/tempfile/toml/gix/libc/flate2 as needed, qualified Rust schema/archive/binary parsers, Node 24.12.0/npm 11.6.2, Vite/Rollup, SPDX 2.3, GitHub CLI 2.97.0, actions/attest 4.2.2, Fastly CLI 15.1.0. CLI consumer tasks retain their existing tokio/reqwest architecture. --- @@ -32,76 +32,192 @@ Two linked plans keep independent work out of these closure criteria: - [CLI packaging attestation integration](2026-10-13-cli-packaging-attestation-integration.md): reconcile #1292, package exact canonical bytes and authenticate member identities. Later releases only after the required spec/schema amendments are approved. - [Fastly prebuilt EdgeZero integration](2026-10-13-fastly-prebuilt-edgezero-integration.md): upstream prepared payload, integrated online production/staging and durable recovery; AC-8 is separate from initial #1235 closure. +## Rust migration and current execution boundary + +The user's 2026-10-14 correction prohibits introduced Python release tooling. The [migration design addendum](../specs/2026-10-14-release-tooling-rust-migration.md) selects `crates/trusted-server-release`: a Rust 2024/1.95.0 host-only workspace member, excluded from default members and independent of core, CLI and JS crates. The existing host-only codegen member is the precedent. The tool is internal producer tooling, not a fifth canonical asset, a generic attestation framework or a publisher abstraction. `ts` remains the consumer in Tasks 17–20: CLI → core → JS makes the CLI unsuitable as the JS/bootstrap producer. + +Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrappers use an independently approved-source, OS-native Rust toolchain to build/select the release tool before executing it; a colocated SHA file or self-reported source label supplies no authority. Developer Cargo commands below establish local behavior only. Production needs the authenticated bootstrap, full qualified source/dependency/tool closure and reviewed native OS adapters. No language migration waives native SDK/tool acquisition or authenticating the original Fastly CLI archive through independently reviewed vendor-source evidence. + +- [x] **M0: Remove superseded tooling.** Introduced Python release code and validator requirements are removed. Deleted prototype behavior is not implemented or fixed by removal; pending Rust regressions below remain mandatory. +- [ ] **M1: Port primitives and preflight (Tasks 2–3).** The host crate and strict document/schema utilities are implemented. Fixed policy/source preflight, complete helper closure and environment sanitation remain Task 3 work. Register each private test module before applying the nonzero-selection guard. +- [ ] **M2: Port material/process/OS boundaries (Tasks 7–9).** Establish original archive/Git identities, bounded child execution, kill/reap/cleanup, private staging, source/object read-only protections and real native denial controls. Preserve independent all-cohort remote receipts separately from rehashing tools actually installed in the current execution environment. +- [ ] **M3: Restore real JS orchestration (Task 7).** Perform two fresh offline installations, actual lifecycle observations and denied missing-tool probes, all tracked bundle comparisons and both full registered JS/type-check suites. Only actual successful execution constructs a private typed completion bound to source, tools, settings and retained A bytes; parsed JSON, paths or booleans cannot construct it. +- [ ] **M4: Restore canonical recipes and inspection (Tasks 8–9).** Compose actual Cargo preparation, compile, final acceptance and copying; collect Task 10 metadata/tree/native evidence through that same boundary before hosted qualification. Local tiny artifacts cannot qualify the full product or actual macOS 15/GNU cohort. +- [ ] **Then Tasks 10–22.** Implement the remaining Rust producer modules, workflow contracts, consumer verification and manual route in dependency order. Complete every selected qualification and full CI gate before release enablement/handoff. + +The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. + +Current implementation includes the host-only crate and strict document/schema utilities, owning build facts, checked JS inventory and Node composition. Source/preflight, the complete helper closure, real native process/OS boundaries, two-installation orchestration and canonical recipes remain pending. Source approvals, acquisition roots and qualification pins remain pending; production wrappers and producer selection remain closed. Local utility checks are not actual macOS 15/GNU or hosted release proof. + ## Work breakdown and ownership Use successive focused commit/review groups within PR #1262: (1) input/fact/JS guards, (2) qualified recipes and composition, (3) collection/signing/publication, (4) verifier/manual route/docs. Each depends on its predecessor; tag enablement waits for all four. No new Trusted Server implementation PR is created. Build scripts can be proved locally before production YAML exists. No intermediate PR may enable an incomplete release trigger. -| File group | Responsibility | -| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| `scripts/release/release-cohort.json`, `release-tools.json` | Reviewed fixed identities, profiles, approved RC branches and qualified environment pins | -| `scripts/release/common.py`, `preflight.py`, `prepare.py` | Strict bounded JSON, source/material authentication, explicit environment and preparation | -| `scripts/release/build-js.sh`, `collect-js.mjs` | Independent locked JS A/B and final contribution inventory | -| `scripts/release/build.sh`, `inspect-native.py`, `collect-composition.py` | Fixed artifact recipes, native qualification and observed build evidence | -| `scripts/release/generate-sbom.py`, `collect-builds.py`, `make-index.py` | SPDX coverage, exact transfer/comparison gates and distribution assembly | -| `.github/workflows/release-validation.yml`, `release-build.yml`, `release.yml` | Same-commit gates; isolated builds/signing; sole draft/publication owner | -| `crates/trusted-server-cli/src/release/` | Host-only reusable verifier: policy, strict documents, bounded gh, GitHub publication and evidence graph | -| `crates/trusted-server-cli/src/commands/release.rs` | Public read-only `ts release verify` adapter to the verifier | -| `scripts/release/inspect-fastly-package.py` | Trusted-source manual-route archive inspection, never a replacement verifier | -| `scripts/release/tests/`, CLI/core/JS tests | Negative producer fixtures, complete consumer fixtures and actual artifact qualification | +| File group | Responsibility | +| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `scripts/release/release-cohort.json`, `release-tools.json` | Reviewed fixed identities, profiles, approved RC branches and qualified environment pins | +| `crates/trusted-server-release/Cargo.toml`, `src/main.rs` | Host-only producer dispatch, with no product-crate dependencies | +| Release tool `src/documents.rs`, `policy.rs`, `preflight.rs`, `source.rs` | Strict bounded JSON, fixed contracts, authenticated source and explicit environment | +| Release tool `src/materials.rs`, `process.rs`, `boundary.rs`, `preparation.rs`, `javascript.rs`, `qualification.rs` | Original acquisitions, bounded execution, real OS isolation, fresh JS and native qualification | +| `scripts/release/bootstrap.sh`, `build-js.sh`, `build.sh`, `collect-js.mjs` | Approved native Rust bootstrap, fixed POSIX adapters and retained Node observer | +| Release tool `src/recipe.rs`, `inspect.rs`, `composition.rs`, `spdx.rs`, `collection.rs`, `distribution.rs`, `publication.rs` | Fixed recipes, actual composition, SPDX, transfers, distribution and sole-owner publication support | +| Release tool `src/package.rs` | Bounded trusted-source manual Fastly inspection; later approved packaging adapters | +| `.github/workflows/release-validation.yml`, `release-build.yml`, `release.yml` | Same-commit gates; isolated builds/signing; sole draft/publication owner | +| `crates/trusted-server-cli/src/release/` | Host-only consumer verifier: policy, strict documents, bounded gh, GitHub publication and evidence graph | +| `crates/trusted-server-cli/src/commands/release.rs` | Public read-only `ts release verify` adapter | +| Release tool private tests, `tests/commands.rs`, `tests/fixtures/`; CLI/core/JS tests | Producer negatives, public-command process tests, consumer fixtures and actual qualification | Execute each listed negative fixture as a separate red/green microcycle: write one case, run its focused filter, implement the smallest relevant behavior and rerun it, then move to the next case. A task groups related work for review/commit; its fixture list is not one giant implementation step. -New files below are proposed, not existing APIs. Keep security parsing and operation decisions in focused modules. Use concrete error-stack reports, top-level imports, descriptive assertions and project commit conventions. Reuse existing libraries; add host-only sha2 and a pinned schema validator only with dependency justification. An archive parser belongs to the manual helper initially, so do not add a Rust archive dependency merely for future deployment. +Task 2 utilities and the retained Rust/Node guard work are implemented. Remaining Rust module names below are proposed, not existing APIs. Create only modules needed by the current stage, without placeholder files. Private unit tests live alongside `src/.rs` under `#[cfg(test)]`, or in registered `src//tests.rs`; fixture data lives in `crates/trusted-server-release/tests/fixtures/`. `tests/commands.rs` drives the public binary. Do not make internal APIs public merely for integration tests. Use concrete error-stack reports, top-level imports, descriptive assertions, documentation on public APIs and project commit conventions. + +Keep direct dependency features minimal. Reuse workspace dependencies only where an actual stage needs them; the producer has no dependency on core, CLI or JS. Maintained TAR/ZIP, schema and binary-format parsers are justified by existing bounded archive, Draft 2020-12/Draft 7 and ELF/Mach-O/core-Wasm requirements. Research exact maintained versions from primary sources and qualify semantics/acquisition before pinning; this plan invents no replacement version or digest. Library parsing does not replace archive limits or source authentication. ### Task 1: Freeze approved scope and reconcile the packaging owner **Files:** Modify this plan and the governing spec only for approved corrections; reference PR #1292's `docs/superpowers/specs/2026-10-12-863-cli-release-packaging-design.md` and `docs/superpowers/plans/2026-10-12-cli-release-packaging.md` after their owning branch is available. - [ ] Read the current spec revision; record its commit and the same-branch/same-PR delivery direction. Work through reviewed tasks in PR #1262; obtain maintainer contract/code approval before merge. Do not require a separate spec merge or open another implementation PR. -- [ ] Record one publication owner, `.github/workflows/release.yml`, and one signing identity, same-revision `release-build.yml`. #1292 must not independently publish an overlapping immutable tag while #1235 assets are still pending. +- [ ] Record one publication owner, `.github/workflows/release.yml`, and one signing identity, same-revision `release-build.yml`. #1292 MUST NOT independently publish an overlapping immutable tag while #1235 assets are still pending. - [ ] Confirm the current four-artifact cohort and three CLI baselines. Initial #1235 delivery may precede packaging on a different new tag. Adding package records or ARM64 requires the linked plan's approved delta, never a covert schema expansion here. -- [ ] Settle one strict producer control channel: `TRUSTED_SERVER__RELEASE_BUILD=1`, injected by trusted recipe after environment sanitation, recorded as an input, rejected if present with another value. This is a fact/input guard, not the prohibited parallel JS fallback mode. Name the checked prebuilt inventory input `TSJS_PREBUILT_INVENTORY`. +- [ ] Settle one strict producer control channel: `TRUSTED_SERVER__RELEASE_BUILD=1`, injected by trusted recipe after environment sanitation, recorded as an input, rejected if present with another value. This is a fact/input guard, not the prohibited parallel JS fallback mode. Name the checked prebuilt inventory input `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY`. - [ ] Confirm permission policy remains runtime-variable; conflicting #1045/#1084 code blocks release enablement. Record the owner and dependency rather than designing a new permission transport. - [ ] Review milestone exits with maintainers in PR #1262. Commit any approved documentation delta as a focused commit on this branch with `Clarify release implementation prerequisites`. **Exit:** An engineer can identify approved source, scope, producer controls and the single release authority; no unspecified cross-PR ownership decision remains. -### Task 2: Establish producer fixtures and strict document utilities - -**Create:** `scripts/release/common.py`, `scripts/release/tests/test_common.py`, `scripts/release/tests/fixtures/README.md`, `scripts/release/requirements.txt`. - -- [x] Write unittest cases for duplicate keys, nonfinite JSON, trailing controls, unsafe names, depth 65, oversized input, negative lengths and ambiguous duplicate identities. -- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_common.py' -v`; expect failure because the module does not exist. -- [x] Implement strict byte-first loading, bounded reads, SHA-256, safe flat asset names, deterministic output and atomic writes. Start duplicate handling with: - -```python -def unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - raise ValueError(f"duplicate JSON key: {key}") - result[key] = value - return result +### Task 2: Establish Rust producer fixtures and strict document utilities + +**Create:** `crates/trusted-server-release/Cargo.toml`, `src/main.rs`, `src/error.rs`, `src/documents.rs`, private registered `documents::tests`, `tests/commands.rs`, `tests/fixtures/README.md`. +**Modify:** workspace `Cargo.toml` membership only; keep default members unchanged. Retain `scripts/release/tests/fixtures/README.md` as the migration pointer. + +- [x] Write private Rust tests for duplicate keys at every depth, nonfinite/out-of-range JSON, invalid UTF-8/surrogates/BOM/trailing controls, unsafe names, depth 65, oversized role inputs, negative/noninteger lengths and ambiguous duplicate identities. Include lexical integer overflow in opaque nested fields and top-level values (`18446744073709551616`, `-9223372036854775809`), decimal and exponent float overflow, exact integer boundary controls, and large number-like text inside strings with escaped quotes/backslashes that MUST NOT be rejected as a numeric token. +- [x] Register tests, derive the host target as below, and run `cargo test -p trusted-server-release --target "$HOST_TARGET" documents::tests` with the nonzero-selection guard. Expect compilation/assertion failure for absent behavior, not an empty successful selection. +- [x] Implement bounded byte-first loading, SHA-256 of original bytes, safe flat names, deterministic output and atomic writes. Use a recursive Serde visitor/seed that rejects duplicate object keys **before insertion**, rather than deserializing into `serde_json::Value` first. A representative parser core is: + +```rust +use std::fmt; + +use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; + +struct StrictSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictSeed { + type Value = Value; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictSeed { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("bounded unambiguous JSON") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + if self.depth > 64 { + return Err(A::Error::custom("JSON container depth exceeds 64")); + } + let mut object = Map::new(); + while let Some(key) = access.next_key::()? { + if object.contains_key(&key) { + return Err(A::Error::custom("duplicate JSON key")); + } + let value = access.next_value_seed(StrictSeed { + depth: self.depth + 1, + })?; + object.insert(key, value); + } + Ok(Value::Object(object)) + } + + fn visit_seq(self, mut access: A) -> Result + where + A: SeqAccess<'de>, + { + if self.depth > 64 { + return Err(A::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Vec::new(); + while let Some(value) = access.next_element_seed(StrictSeed { + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: serde::de::Error, + { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite JSON number")) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } +} ``` -Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_constant)` after size/depth checks. Hash original file bytes, not parsed/reserialized objects. +The implemented utility chooses the mandatory role byte limit before reading/allocating and reject BOM/invalid encoding. Before `deserialize_any`, validate numeric tokens across the **whole document**, including opaque fields and top-level values, with a string-aware lexical scanner that skips quoted text and handles escapes. Negative integer tokens MUST fit `i64`; nonnegative integer tokens MUST fit `u64`. Decimal/exponent tokens MUST parse as finite `f64`; overflow fails. `serde_json` otherwise converts some overflowing integer tokens to finite floats, so the visitor's finite-float check alone is insufficient. Use the existing scanner in `crates/trusted-server-js/build/inventory.rs` as a behavioral reference without adding a JS-crate dependency. Then start with `StrictSeed { depth: 1 }` and require `serde_json::Deserializer::end()` so grammar/trailing data fails. Closed role validators additionally enforce stricter safe-integer limits and reject negative/noninteger lengths. The representative visitor above is the ambiguity parser, not the complete strict front end; schema validation never substitutes for lexical prevalidation, byte bounds, exact-original hashing or semantic joins. -- [x] Qualify and pin the schema-validator distribution and hashes in `requirements.txt`; install through a private venv. Add explicit dependency installation to producer validation, rather than relying on ambient Python packages. -- [x] Re-run the tests; expect all negatives rejected and deterministic positive bytes. Commit `Add strict release document utilities`. +- [x] Qualify a maintained Rust schema evaluator. The locked `jsonschema` 0.58.6 is a locally checked evaluator candidate, not an approved production tool pin: use minimal features, explicit dialect and offline construction, with only known retained schemas and no arbitrary schema-file CLI override. Governing schemas require Draft 2020-12 `prefixItems`/`items` positive/negative cases; the official SPDX 2.3 schema uses Draft 7. Disable remote/file retrieval and reject unregistered references. +- [x] Retain the official SPDX schema at source commit `aadf3b0b8dbbabdb4d880b0fc714255fea436ff7`, path `schemas/spdx-schema.json`, 45,312 bytes and SHA-256 `239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b`. Confirm raw-byte identity and dialect before use. Candidate compatibility or a schema-valid toy document does not establish complete real-artifact coverage. +- [x] Rerun documents/private atomic-write negatives and command-process tests; run host-target clippy/format. Commit only after authorized review with `Add strict Rust release document utilities`. ### Task 3: Encode cohort, tools and fail-closed release preflight -**Create:** `scripts/release/release-cohort.json`, `release-tools.json`, `preflight.py`, `tests/test_preflight.py`, `crates/trusted-server-cli/release-policy.json`. +**Retain:** `scripts/release/release-cohort.json`, `release-tools.json`, `crates/trusted-server-cli/release-policy.json`. +**Create as needed:** release tool `src/policy.rs`, `preflight.rs`, `source.rs` and private registered tests; wire the approved POSIX bootstrap adapter without weakening pending selections. + +- [ ] Port cases for missing/extra artifacts, profiles changed after failure, unknown target/feature, missing pins and every skip value (`""`, `"0"`, `"1"`). Include wrapper, Cargo config, NODE_OPTIONS, native selectors, unexpected ignored/nested validation files, modes/links and source mutation. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" preflight::tests` and `source::tests` separately with the nonzero-selection guard; expect the missing Rust behavior to fail. +- [ ] Preserve exact tracked producer/consumer policy bytes, concrete fixed identity/profile/action/bounds and the raw source-owned tools input. Runtime consumer enforcement remains Tasks 17–20. No downloaded policy override or silent schema/cohort/capability amendment is permitted. +- [ ] Map four asset IDs to fixed package/bin/target/features; never accept JSON commands. Preserve Rust/Node/npm, gh 2.97.0, attest 4.2.2/full SHA and Fastly 15.1.0. Populate binary/image/SDK digests only from authenticated acquisition and Task 8 measured review. The production preflight/selector requires all qualifications by default and MUST fail on pending/null pins; omitting a flag MUST NOT return success. A separately named audit diagnostic MAY describe incomplete inputs but MUST NOT construct authorization state. No JSON qualified boolean or dummy digest is accepted. +- [ ] Audit the actual inherited environment before clearing it; reject skips and unreviewed channels, then build an allowlisted child environment. Do not hide hosted/setup overrides under `env -i`. Accept vetted setup exports such as `RUSTFLAGS`/`VCPKG_INSTALLATION_ROOT` only with explicitly reviewed, qualified values and path identities; otherwise reject. Remove publisher prefixes and inject only approved commit/control facts. Derive epoch from approved Git committer time. Check tracked and unexpected source-only files, including the full JS validation library, modes/links before and after execution; preserve narrow reviewed coordination exceptions. +- [ ] Select generic and target-suffixed AWS-LC settings, bundled SQLite, Cargo config/home layers, sysroot/tool paths and Vite mode. Set `AWS_LC_SYS_USE_SYSTEM=0`, `LIBSQLITE3_SYS_USE_PKG_CONFIG=0`; reject alternate selectors rather than setting weaker defaults. +- [ ] Authenticate approved source and acquisitions into private typed states. Keep independent all-cohort remote qualification receipts separate from current-environment installation identity: rehash the actual selected native binaries/SDK inputs immediately before use. Serialized `authenticated`/`qualified`/`completed` booleans, source labels or checksum files cannot construct either state. +- [ ] Rerun negatives, including fictional operator-config independence and wrong-bootstrap source; run host lint/format. Commit `Define fixed Rust release inputs and preflight policy`. -- [x] Write cases for missing/extra artifacts, profiles changed after failure, unknown target/feature, missing pins and every skip value (`""`, `"0"`, `"1"`). Add wrapper, Cargo config, NODE_OPTIONS, native selector and source mutation cases. -- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_preflight.py' -v`; expect missing module/behavior failures. -- [x] Create the tracked consumer/producer policy bytes now, with the spec's concrete fixed identity/profile/action/bounds fields, so recipe/index assembly can bind their source identity before signing. Runtime enforcement is implemented in Tasks 17–20; this source-controlled contract is not a downloadable policy override. -- [x] Map the four asset IDs to fixed package/bin/target/features. Never accept shell commands from JSON. Pin Rust/Node/npm, gh 2.97.0, attest 4.2.2/full SHA and Fastly 15.1.0; populate actual binary/image/SDK digests only from authenticated acquisition and Task 8 qualification. Missing qualified pins fail `--require-qualified`; no dummy digest is accepted. -- [x] Inspect the inherited environment before clearing it. Reject skip presence and unreviewed override channels; create a fresh allowlisted child environment. Remove publisher prefixes and inject only approved commit/control facts. Derive epoch from the approved Git commit's committer timestamp. Check tracked bytes plus unexpected source-only files, modes and links before/after execution. -- [x] Select both generic and target-suffixed AWS-LC selectors, bundled SQLite, Cargo config/home layers, sysroot/tool paths and Vite mode. Set `AWS_LC_SYS_USE_SYSTEM=0`, `LIBSQLITE3_SYS_USE_PKG_CONFIG=0`; reject alternate selectors rather than merely setting weaker generic defaults. -- [x] Re-run fixtures, including a fictional operator-config independence case. Commit `Define fixed release inputs and preflight policy`. +**Interface:** after independently approved native bootstrap, `trusted-server-release preflight --cohort scripts/release/release-cohort.json --source-commit --require-qualified`; exit 0 only for a complete selected contract, otherwise fail before preparation/signing. A developer may exercise it with `cargo run -p trusted-server-release --target "$HOST_TARGET" -- preflight --cohort scripts/release/release-cohort.json --source-commit "$APPROVED_SOURCE_COMMIT" --require-qualified`, which provides no official tool or release assurance. -**Interface:** `python3 scripts/release/preflight.py --cohort scripts/release/release-cohort.json --source-commit --require-qualified`; exit 0 only for a complete selected contract, exit nonzero before preparation/signing otherwise. +- [ ] Reject unlisted RC branches and a malicious candidate cohort that lists itself; require independently reviewed tracked branch/governance inclusion, actual remote-tip ancestry and active ruleset/bypass validation. Current eligibility is exactly `["main"]`; reject local remote-ref substitutions. +- [ ] Audit the true Git common directory, including linked worktrees, for alternates, grafts, shallow/replacement/config/environment channels. Explicitly reject unsupported linked indirections rather than partially checking them. Preserve bounded detailed Git errors. Reject an untracked extensionless `rust-toolchain` before bootstrap/rustup selection, including alongside the tracked TOML pin. Join approved source to the original JS inventory commit. ### Task 4: Reconcile immutable build facts and CLI source resources @@ -110,10 +226,10 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Modify when adding the owning facts surface:** `crates/trusted-server-adapter-fastly/src/main.rs`; retain existing version-header/service-version behavior. - [x] Add strict full-commit tests: missing/malformed/branch/tag values fail in release mode without local-git fallback; development behavior stays covered. Add two external tags/same source yielding identical facts, owning-target/variant checks and compiled-path fallback rejection. -- [x] Run `cargo test -p trusted-server-core --test git_version_resolve` and `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_build_facts`; expect the new behavior to fail. -- [x] Extend the merged #1213 helper using the approved release control. Derive target from Cargo `TARGET`, package version from the owning product, and default/reuse variant at the Fastly owner. Expose typed immutable facts without importing verifier/publisher keys into core. Do not embed tag, run ID, time or final checksum. -- [x] Set clap's version text to package version plus source identity. Official releases compile out `find_js_lib_dir`'s literal developer checkout fallback; preserve explicit/current-checkout resource discovery. Keep the tracked example config as a fictional scaffold, never active settings. -- [x] Run core fact/template tests, CLI tests/clippy and Fastly version-header tests. Outside-checkout help/version/config-init must pass; Prebid source-dependent commands return documented prerequisites. Commit `Derive immutable release build facts`. +- [x] Run `cargo test -p trusted-server-core --test git_version_resolve` and `cargo test -p trusted-server-cli --target "$HOST_TARGET" --test release_build_facts`; expect the new behavior to fail. +- [x] Extend the merged #1213 helper using the approved release control. Derive target from Cargo `TARGET` through `TRUSTED_SERVER__BUILD_TARGET`, package version from the owning product, and default/reuse variant at the Fastly owner. Expose typed immutable facts without importing verifier/publisher keys into core. Do not embed tag, run ID, time or final checksum. +- [x] Set clap's version text to `ts package= source= target= variant=default`. Read Fastly owning package/target/variant facts through existing startup diagnostics. Remove unread variant and source-owner re-emissions; core remains the source identity owner. Official releases compile out `find_js_lib_dir`'s literal developer checkout fallback; preserve explicit/current-checkout resource discovery. Keep the tracked example config as a fictional scaffold, never active settings. +- [x] Run core fact/template tests, CLI tests/clippy and Fastly version-header tests. Outside-checkout help/version/config-init MUST pass; Prebid source-dependent commands return documented prerequisites. Commit `Derive immutable release build facts`. ### Task 5: Authenticate the existing prebuilt JS interface @@ -121,9 +237,9 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons **Create:** `crates/trusted-server-js/tests/release_prebuilt.rs`. - [x] Write process/fixture tests for prebuilt mode plus any skip presence, absent inventory, wrong source/digest/size, duplicate IDs, unsafe filenames, missing/empty/extra modules and source changed during copy. -- [x] Run `cargo test -p trusted-server-js --test release_prebuilt`; expect current prebuilt behavior to fail these guards. -- [x] Extend the existing bundle-set validator; require `TSJS_PREBUILT_INVENTORY` in release mode and validate exact input and private copied bytes. Track both paths/control values with Cargo rerun directives. Reject skip before selecting prebuilt/source path; no TSJS_RELEASE_BUILD parallel mode and no read from old `dist/`. -- [x] Preserve ordinary development's supported fresh source build. In release Rust jobs require prebuilt inputs and prohibit npm invocation; a fake failing npm proves it is never run. +- [x] Run `cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt` with the nonzero-selection guard; expect current prebuilt behavior to fail these guards. +- [x] Extend the existing bundle-set validator; require `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` in release mode and validate exact input and private copied bytes. Track both paths/control values with Cargo rerun directives. Reject skip before selecting prebuilt/source path; no TSJS_RELEASE_BUILD parallel mode and no read from old `dist/`. +- [x] Preserve ordinary development's supported fresh source build and #1214's inventory-absent prebuilt path, including unrelated non-bundle extras and supported directory aliases. An inventory-present development build validates inventory source syntax/hash but MUST NOT compare it with the override/tag/branch display label. In release Rust jobs require prebuilt inputs and prohibit npm invocation; a fake failing npm proves it is never run. - [x] Run JS crate tests, Fastly/CLI target checks and prebuilt negatives. Commit `Bind release JS inputs to a checked inventory`. ### Task 6: Collect JS contributions without changing bundle bytes @@ -139,116 +255,123 @@ Use `json.loads(..., object_pairs_hook=unique_object, parse_constant=reject_cons ### Task 7: Prepare and compare two independent JS builds -**Create:** `scripts/release/build-js.sh`, `prepare.py`, `tests/test_prepare.py`, `tests/test_js_release.py`. +**Retain/modify:** `scripts/release/build-js.sh`, `bootstrap.sh`, `collect-js.mjs`. **Create:** release tool `src/materials.rs`, `process.rs`, `boundary.rs`, `preparation.rs`, `javascript.rs` and private registered tests; add public process fixtures to `tests/commands.rs`. -- [x] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. -- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_*release.py' -v`; expect missing orchestration. -- [x] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Task 7 initially proves this with local fixtures; Task 8 must qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Bundle under qualified network denial; compilation-time downloads fail. -- [x] Compare final core and all tracked integration files byte-for-byte. Publish only A plus inventory after equality. Run release JS tests regardless of a local test flag; verify source-only trees remained intact. -- [x] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Commit `Build and compare shared release JS inputs`. +- [ ] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" materials::tests`, `process::tests`, `boundary::tests`, `preparation::tests` and `javascript::tests` separately with the nonzero-selection guard; expect missing Rust orchestration. +- [ ] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Rust migration MUST reprove this with actual full local execution and fresh fixtures; Task 8 MUST qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Read-only original sources/object stores and authenticated archive/tool inputs remain outside private installation outputs. Timeouts/cancellation/pipe limits kill and reap descendant process groups; bounded Docker stop/remove and all temporary-profile/resource cleanup run on success and failure. Bundle under qualified network denial; compilation-time downloads fail. +- [ ] Compare final core and all tracked integration files byte-for-byte. Retain only A plus its original inventory after equality. Construct a private typed `JsBuildCompleted` only from the actual successful two-install/test/build/control/comparison path, binding authenticated source/tools/settings, inventory raw digest and retained module bytes. Rehash at handoff; a checked inventory or JSON completion assertion cannot substitute. Unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` into BOTH actual A/B Vitest processes, independent of inherited developer flags; run every registered test/type check and reject a zero selected suite; verify source-only trees remained intact. +- [ ] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Require both actual fresh full registered JS/type-check suites and all tracked final bundles before `Build and compare shared Rust release JS inputs`; fixture-only counts remain separate. + +- [ ] Port actual regressions for immutable macOS source/object regular files with `nlink == 1`; reject pre-existing hard links and prove write-through/restoration cannot bypass the boundary. Restrict signals to `same-sandbox`: own-child positive, outside same-user sibling and orchestrator denial negatives. Parent/child/grandchild install lifecycle, Cargo/compiler/proc-macro/native children MUST inherit the same OS-denied controls. +- [ ] Port Docker boundary regressions: use Docker's default private PID namespace (no invalid `--pid=private`), and actually execute positive/denial/recipe controls instead of testing argv alone. Keep BOTH host seccomp profiles and an empty Docker config disjoint from every container-writable root; scan before creating them and rehash BOTH before EVERY host command. Select fixed authenticated Docker client/config/Unix daemon, no TTY/plugins/context or environment overrides, and read-only anchors. Reject escaping and dangling aliases in the CONTAINER namespace. +- [ ] Bound child pipes/deadlines; timeout/cancellation/group kill MUST reap descendants and clear token/provider/injection environment. Record real errno without inventing `ETIMEDOUT`. Use host-independent tool/path/environment fixtures; no ambient shim or interpreter supplies trust. ### Task 8: Qualify real platform environments before fixing recipes -**Create:** `scripts/release/qualification/qualify.py`, `socket_probe.py`, `results.schema.json`, `scripts/release/tests/test_qualification.py`; update `release-tools.json` with measured pins. +**Retain:** `scripts/release/qualification/results.schema.json` and reviewed seccomp baseline/license. **Create:** release tool `src/qualification.rs`, native probe dispatch in `src/boundary.rs`, private registered tests; update `release-tools.json` only with authenticated measured pins. -- [x] Write schema/preflight fixtures rejecting wrong architecture, missing SDK/tool digest, failed startup, permitted confined sockets and invented host compatibility. -- [x] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_qualification.py' -v`; expect unimplemented qualification checks. +- [ ] Write schema/preflight fixtures rejecting wrong architecture, missing SDK/tool digest, failed startup, permitted confined sockets and invented host compatibility. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" qualification::tests` with the nonzero-selection guard; expect unimplemented Rust qualification checks. - [ ] Execute throwaway full recipes on the actual proposed GitHub-hosted environments: digest-pinned GNU builder <=2.34, matching macOS 15 ARM/Intel, explicit loader floor 13.0. Record host, rustup paths, compiler/linker/SDK/native identities. Docker absence on a developer Mac is not replaced by an inferred Linux result. -- [ ] On each recipe run an unconfined socket success control and a confined denied socket probe, then full compilation under the same qualified boundary. Linux container uses process network isolation; macOS profile must permit required filesystem/process/tool activity while denying sockets. Lifecycle preparation is separate and authenticated. +- [ ] On each recipe run an unconfined socket success control and a confined denied socket probe, then full compilation under the same qualified boundary. Linux container uses process network isolation; macOS profile MUST permit required filesystem/process/tool activity while denying sockets. Archive/tool acquisition is separate and authenticated; actual offline install lifecycle hooks execute under denial. - [ ] Record actual startup/link evidence and complete Fastly runtime request, not only host health. Missing Intel execution or GNU baseline stops qualification. A different platform/cohort requires reviewed spec changes, not a silent downgrade. - [ ] Pin authenticated observed digests/bootstrap data and add result validation; commit `Qualify controlled release build environments`. **Gate:** This task produces measured pins, not placeholder configuration. It can run only with the separately authorized disposable hosted resources. It does not publish official tags or deploy a real publisher service. -Implementation sequencing: review the local validation framework and candidate boundaries first, then implement the shared Task 9 recipes and Task 10 composition captures. Complete the authenticated candidate/production adapters and shared-JS producer binding before measuring the executable closure. Hosted qualification must invoke those same recipes with independently authenticated candidate selections; it cannot require its own future qualification result. Production selection remains closed until the hosted evidence and measured pins are reviewed. Local framework completion does not satisfy the hosted checkboxes above. +Implementation sequencing: review the local validation framework and candidate boundaries first, then implement the shared Task 9 recipes and Task 10 composition captures. Complete the authenticated candidate/production adapters and shared-JS producer binding before measuring the executable closure. Hosted qualification MUST invoke those same recipes with independently authenticated candidate selections; it cannot require its own future qualification result. Production selection remains closed until the hosted evidence and measured pins are reviewed. Local framework completion does not satisfy the hosted checkboxes above. + +- [ ] Join observations to the reviewed catalog at the approved source: exact selection digest, tool versions/authentication methods, complete helper compiled closure and every path-dependency manifest, including `crates/trusted-server-openrtb/Cargo.toml`, tool-version files, authenticated executable digests, SDK identities and raw receipts. A code change invalidates the closure; labels/checksums cannot self-qualify. Hash the actual selected shell; do not assume `/bin/sh --version` works on dash. Unsupported pending selections keep production closed. +- [ ] Derive container resource limits no higher than actual host capacity (a private Ubuntu runner may have two vCPUs). A Cargo jobs A=4/B=2 variation does not authorize `--cpus 4` on that host. Use actual controls and whole-product builds, not deleted prototype claims. ### Task 9: Implement fixed fresh Rust recipes and native inspection -**Create:** `scripts/release/build.sh`, `inspect-native.py`, `tests/test_recipe.py`, `tests/test_native.py`, `scripts/release/validate-fastly.py`, `tests/test_fastly_runtime.py`. +**Retain/modify:** `scripts/release/build.sh` and approved native bootstrap. **Create:** release tool `src/recipe.rs`, `inspect.rs`, private registered recipe/native/final-Fastly tests and public-command process fixtures. -- [x] Test unknown asset/variant, reused target output, source/material mutation, unselected native library, GLIBC symbol ceiling violations, runner-only paths and Mach-O deployment-target mismatch. Add role-specific variation fixtures: missing selected B variation, normalized values misreported as varied, unsupported user variation claimed as passed and incorrect observed concurrency/umask. -- [x] Run both new unittest modules; expect missing recipe/inspection behavior. +- [ ] Test unknown asset/variant, reused target output, source/material mutation, unselected native library, GLIBC symbol ceiling violations, runner-only paths and Mach-O deployment-target mismatch. Add role-specific variation fixtures: missing selected B variation, normalized values misreported as varied, unsupported user variation claimed as passed and incorrect observed concurrency/umask. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" recipe::tests` and `inspect::tests` separately with the nonzero-selection guard; expect missing Rust recipe/inspection behavior. - [ ] Implement a closed interface: `build.sh --asset --role A|B --source-commit --js-dir --js-inventory --materials --output `. Select package/target/features from reviewed source; JSON never supplies executable commands. - [ ] Implement checked A/B ambient profiles in the recipe and record them in `release-tools.json`: different external checkout roots, A umask 022/B umask 027, UTC versus a qualified alternate timezone, distinct qualified locale settings, different observed start times and A jobs 4/B jobs 2 within the qualified insensitive envelope. Vary execution user only where Task 8 proves support; otherwise record it unsupported without claiming it changed. Capture both ambient inputs and effective compiler values after sanitation/remapping; fixed internal locale/path/epoch values are normalized, not varied. No unreviewed caller overrides can choose profiles. Tests assert genuinely observed supported differences and reject dishonest/omitted records. -- [ ] Apply Task 8's qualified OS/container denial profile in the actual production `build.sh`, not just the qualification prototype. Every production invocation must record a successful unconfined socket control and denied confined socket probe, then execute Cargo, compiler, proc-macro, build-script and native-tool children inside that same denied boundary. Preparation alone may fetch materials. A build-script/lifecycle download-attempt fixture must fail, and a missing or ineffective confinement proof rejects the output; `--frozen` is not the boundary. +- [ ] Apply Task 8's qualified OS/container denial profile in the actual production `build.sh`, not just the qualification prototype. Every production invocation MUST record a successful unconfined socket control and denied confined socket probe, then execute Cargo, compiler, proc-macro, build-script and native-tool children inside that same denied boundary. Authenticated archive/tool acquisition alone may fetch materials; offline installation lifecycle hooks and all compilation run under denial. A build-script/lifecycle download-attempt fixture MUST fail, and a missing or ineffective confinement proof rejects the output; `--frozen` is not the boundary. - [ ] Build explicit packages with `--release --frozen --message-format=json-render-diagnostics` after preparation. Keep `debug = 1`. Capture compiler/build-script output separately from diagnostic stderr; copy only final output after any reviewed transformations. Do not invoke Fastly source build/annotation. Never strip after evidence/signing. - [ ] Inspect ELF/Mach-O libraries and paths, run clean Linux startup fixtures and real ARM/Intel macOS startup outside checkout. With differing fictional operator config removed by preflight, require identical sanitized effective source/tool/build inputs for every artifact and reject embedded operator sentinels/config fallback. Require complete executable equality for Fastly; preserve native nonmatching outcomes as measurements rather than adding a native repeatability shipping gate. Separately show runtime configuration can change without rebuilding. -- [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture must not invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. +- [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture MUST NOT invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. - [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. ### Task 10: Record selected Cargo units and native materials -**Create:** `scripts/release/collect-composition.py`, `tests/test_composition.py`, `tests/fixtures/composition/`. +**Create:** release tool `src/composition.rs`, private `composition::tests`, `crates/trusted-server-release/tests/fixtures/composition/`. - [ ] Add fixtures for host/target dual-role packages with different unit features, ambiguous source IDs, dev-only graph entries, unknown native origin, system substitution and prepared-but-discarded CLI JS. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_composition.py' -v`; expect absent collector. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" composition::tests` with the nonzero-selection guard; expect absent Rust collector. - [ ] Join explicit-target compiler/build-script messages with metadata/tree and selected material identities; preserve each observed unit's features/context. Package union features cannot stand in for effective per-unit features. Retain raw observations for audit; do not claim an exact compiler dependency DAG. - [ ] Capture AWS-LC/ring/SQLite native source and output archive hashes/link directives, Rust sysroot and external platform libraries. Validate generic/target-specific selection against the recipe. Distinguish embedded components, external requirements and build tools. - [ ] Inspect final files conservatively for complete JS module strings. Record absence explicitly; Fastly and CLI do not need the same retained set. Do not force unused JS into CLI. Commit `Collect artifact-specific build composition` after negatives pass. ### Task 11: Generate complete artifact-specific SPDX -**Create:** `scripts/release/generate-sbom.py`, `tests/test_sbom.py`, `tests/fixtures/spdx/`. +**Create:** release tool `src/spdx.rs`, private `spdx::tests`, `crates/trusted-server-release/tests/fixtures/spdx/`. - [ ] Write cases for incorrect root digest, missing native coverage, ambiguous package identity, unknown license, duplicate SPDX IDs, dangling relationships, uncompiled dev dependencies, type-only/virtual JS and oversize predicates. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_sbom.py' -v`; expect missing generator. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" spdx::tests` with the nonzero-selection guard; expect missing Rust generator. - [ ] Emit SPDX 2.3 JSON from Task 10 normalized evidence. Root checksum is final executable SHA-256; contributors/materials/tools/external requirements have distinct relationships. Unknown licenses use NOASSERTION; missing contributing origin/coverage fails. Validator uses a pinned authoritative SPDX schema. - [ ] Generate full real Fastly and all three CLI SBOMs; schema-valid toy fixtures are not sufficient. Validate size <=16 MiB and document equality semantics: reject duplicate keys, ignore object order, preserve array order. - [ ] Re-run fixtures plus actual coverage inspection. Commit `Generate validated per-artifact SPDX evidence`. ### Task 12: Bind transfers and enforce selected comparisons -**Create:** `scripts/release/collect-builds.py`, `tests/test_collection.py`. +**Create:** release tool `src/collection.rs`, private `collection::tests` and exact transfer fixture data. - [ ] Test stale/wrong-run/source transfers, reused A/B artifact IDs, transfer hash warning, traversal/duplicate ZIP members, missing A, changed inventory, missing supported B variation or dishonest normalized/unsupported claims, absent final-byte runtime proof, missing/failed production socket controls or an unconfined build invocation, and a one-byte final wasm difference. -- [ ] Run collection tests; expect missing validator. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" collection::tests` with the nonzero-selection guard; expect missing validator. - [ ] Accept only expected upload-step outputs: IDs/archive digests mapped by trusted workflow dependencies to job/attempt/role/recipe. Re-download exact current-run artifacts and validate REST repository/run/source plus transfer bytes. Artifact API does not independently identify the producing job/attempt. Verify bounded declared ZIP members before extraction. -- [ ] Match expected role-specific ambient profiles from the approved recipe against observed A/B records before signing. For mandatory Fastly A/B, all qualified supported dimensions must differ as selected; normalized values remain explicitly identified and omitted supported dimensions fail. Successful native comparisons use their selected observed profile and result binding, but a bounded native B failed/unavailable before environment setup records unavailable fields honestly and cannot be rejected for lacking observations it never produced. Add that exact nonblocking fixture; all native A qualification/composition remains mandatory. Verify final-byte core-format/publisher-request results for both Fastly outputs. Fresh-job identity alone cannot satisfy the variation or runtime contract. +- [ ] Match expected role-specific ambient profiles from the approved recipe against observed A/B records before signing. For mandatory Fastly A/B, all qualified supported dimensions MUST differ as selected; normalized values remain explicitly identified and omitted supported dimensions fail. Successful native comparisons use their selected observed profile and result binding, but a bounded native B failed/unavailable before environment setup records unavailable fields honestly and cannot be rejected for lacking observations it never produced. Add that exact nonblocking fixture; all native A qualification/composition remains mandatory. Verify final-byte core-format/publisher-request results for both Fastly outputs. Fresh-job identity alone cannot satisfy the variation or runtime contract. - [ ] Compare complete final Fastly files with no normalized/excluded regions. For native B produce exactly matched/mismatched/failed/unavailable within its deadline; native A failures always block. Record ambient variations and normalized internal values honestly. - [ ] Preserve A as canonical only after all mandatory requirements pass. Add explicit tests that optional B failure cannot hide any A/composition/JS failure. Commit `Validate release transfers and comparison outcomes`. ### Task 13: Assemble and validate the exact evidence graph -**Create:** `scripts/release/make-index.py`, `validate-distribution.py`, `tests/test_index.py`, `tests/fixtures/index/`. -**Modify only with approval:** the governing index schema. +**Create:** release tool `src/distribution.rs`, private `distribution::tests`, `crates/trusted-server-release/tests/fixtures/index/`. +**Schema boundary:** governing index v1 raw bytes remain immutable; any change requires an explicit reviewed amendment. -- [ ] Port positive and adversarial schema fixtures: exact four artifacts, unknown capabilities, duplicate names/keys, source/cohort/recipe mismatch, unsafe names/newlines, altered lengths/digests and role-specific limits. -- [ ] Run index tests; expect missing assembly/semantic checks. +- [ ] Port positive and adversarial schema fixtures: exact four artifacts, unknown capabilities and structurally compatible but currently forbidden `fastly-staging`, duplicate names/keys, source/cohort/recipe mismatch, unsafe names/newlines, altered lengths/digests and role-specific limits. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" distribution::tests` with the nonzero-selection guard; expect missing assembly/semantic checks. - [ ] Assemble unsigned companions from successful observed outputs, then artifact provenance/SBOM bundles, then the signed index. Index names/hashes all companions and shared JS inventory; exclude own digest/bundle. Recipe/rebuild records are authenticated informational data, not executable programs or independent trust. - [ ] Require SPDX parsed predicate/document equality plus separate file hashes. Producer verifies every exact expected provenance subject; no additional unapproved code subject. SHA256SUMS covers every other published asset and not itself. - [ ] Cross-check schema and semantic joins on all real artifacts before signing/rehearsal. Commit `Assemble the authenticated release distribution`. ### Task 14: Expose trusted same-commit validation -**Create:** `.github/workflows/release-validation.yml`, `scripts/release/tests/test_workflow_contract.py`. +**Create:** `.github/workflows/release-validation.yml`, release tool private workflow-contract tests (register alongside the production policy/dispatch module; do not expose a test-only public API). **Modify:** `.github/workflows/test.yml`, `format.yml` only to extract/reuse existing required jobs without losing existing triggers/gates. -- [ ] Add static workflow fixtures for wrong SHA/event/ref, unauthorized RC branch, skipped/path-filtered gates, arbitrary commands, excessive permissions and any floating external action ref in the complete release/validation invocation. -- [ ] Run workflow contract tests; expect absent callable validation. -- [ ] SHA-pin every external action in release.yml, release-build.yml and all called release validation jobs, including checkout/setup/cache/upload/download and attest. Preserve readable version comments, audit dependencies and reject floating @v\*/main refs. Local workflow calls are allowed only at the caller's checked source revision. Existing test/format action refs must not be inherited unreviewed into this path. +- [ ] Add static workflow fixtures for wrong SHA/event/ref, unlisted RC branch, malicious candidate allowlist, forged local remote refs, missing governance/ruleset/bypass approval, skipped/path-filtered gates, arbitrary commands, excessive permissions and any floating external action ref in the complete release/validation invocation. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" workflow_contract` with the nonzero-selection guard; expect absent callable validation. +- [ ] SHA-pin every external action in release.yml, release-build.yml and all called release validation jobs, including checkout/setup/cache/upload/download and attest. Preserve readable version comments, audit dependencies and reject floating @v\*/main refs. Local workflow calls are allowed only at the caller's checked source revision. Existing test/format action refs MUST NOT be inherited unreviewed into this path. - [ ] Expose reviewed workflow_call or explicit jobs running every current AGENTS gate at the tag SHA. Maintain PR/main tests; RC is permitted only by exact reviewed cohort branch entries. Validate strict SemVer/lightweight tag independently; annotated tags fail. - [ ] Wait up to 90 minutes for this invocation's required terminal validation results; fail cancelled/skipped/timeout results with rerun guidance. Do not trust arbitrary named successful checks or unrelated main commits. -- [ ] Include JS build/tests/format, all Rust aliases/parity/CLI/build-digest, docs/Markdown and `scripts/lint-wasm-clock.sh`. Commit `Validate release source at the exact tag commit`. +- [ ] Include JS build/tests/format, all Rust aliases/parity/CLI/build-digest, the explicit host-only release-tool test/clippy gate (or reviewed aliases), docs/Markdown and `scripts/lint-wasm-clock.sh`. Commit `Validate release source at the exact tag commit`. ### Task 15: Orchestrate independent builds and isolated signing **Create:** `.github/workflows/release-build.yml`; extend workflow contract fixtures. -- [ ] Test never-started/queued or scheduler-cancelled optional native B jobs as well as pre-step failure: the trusted collector must emit a bounded failed/unavailable measurement with absent observations represented honestly and no fabricated upload identity. Confirm the workflow cannot wait indefinitely or hide a mandatory A failure. +- [ ] Test never-started/queued or scheduler-cancelled optional native B jobs as well as pre-step failure: the trusted collector MUST emit a bounded failed/unavailable measurement with absent observations represented honestly and no fabricated upload identity. Confirm the workflow cannot wait indefinitely or hide a mandatory A failure. - [ ] Test topology: shared JS A/B, separate fresh Fastly A/B, three mandatory native A jobs, bounded optional native B jobs, validated current-run collector and no signing credentials in executable/lifecycle jobs. Require job configurations to apply Task 9 role profiles and publish observed variation/runtime records; tests reject default-only A/B jobs or collectors that bypass these gates. -- [ ] Run workflow tests; expect absent graph. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" workflow_contract` with the nonzero-selection guard; expect absent graph. - [ ] Call local reusable workflow at source revision. Build jobs are read-only. Collector uses Task 12 outputs; signing grants only contents-read/id-token-write/attestations-write, plus actions-read when needed. No artifact-metadata write for file attestations. Caller grants enough permissions for called signing jobs but its own build jobs explicitly narrow them. - [ ] Invoke pinned attest 4.2.2 twice per canonical asset: exact executable/SBOM/evidence/recipe/rebuild provenance subjects, then executable subject + SPDX predicate. Save named bundles, validate all proofs/joins and sign index after collection. Do not construct caller predicates or sign source supplied commands. - [ ] Rehearse actual certificates/toolkit fields in a disposable repository with test-only policy fixtures. Verify repository IDs, exact SAN, issuer, build/caller/source/event/run fields for both predicate types. Commit `Sign validated release outputs through the reusable builder`. ### Task 16: Publish through one immutable-release owner -**Create:** `.github/workflows/release.yml`, `scripts/release/publish.py`, `tests/test_publication.py`. +**Create:** `.github/workflows/release.yml`, release tool `src/publication.rs` and private `publication::tests`. - [ ] Add fake API cases for matching/conflicting draft, missing/altered asset, concurrent rerun, lost response, already published tag, mutable/unknown final state and permission differences on approved RC commits. -- [ ] Run publication/workflow tests; expect absent publication owner. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" publication::tests` and `workflow_contract` separately with the nonzero-selection guard; expect absent publication owner. - [ ] Keep tag-scoped concurrency without cancellation. Draft, upload, re-download and validate the exact asset set before publication. Match source/bytes for draft resume; never overwrite published assets/tags. Publication has contents-write only, no OIDC/provider/settings token. -- [ ] Require maintainer governance approval and immutable/tag-rule setup before enabling tags; afterward read immutable=true and verify GitHub release statement/membership with the normal token. A confirmed mutable release fails publication, is removed only when the workflow release-write role authorizes that still-mutable cleanup, and produces an incident with the cleanup outcome. Never delete an immutable release. Unknown state produces an incident and read-only reconciliation; it must not trigger blind deletion. A lost publish response also triggers read-only reconciliation before another mutation. +- [ ] Require maintainer governance approval and immutable/tag-rule setup before enabling tags; afterward read immutable=true and verify GitHub release statement/membership with the normal token. A confirmed mutable release fails publication, is removed only when the workflow release-write role authorizes that still-mutable cleanup, and produces an incident with the cleanup outcome. Never delete an immutable release. Unknown state produces an incident and read-only reconciliation; it MUST NOT trigger blind deletion. A lost publish response also triggers read-only reconciliation before another mutation. - [ ] Exercise all failure paths in an authorized disposable public repository and record measured evidence. Production tag/permissions require separate release authorization. Commit `Publish complete immutable attested releases`. ### Task 17: Implement strict consumer documents and embedded policy @@ -258,9 +381,10 @@ Implementation sequencing: review the local validation framework and candidate b **Modify:** CLI `src/lib.rs`, `Cargo.toml` (host-only sha2/schema validator with justification). - [ ] Before the first filtered run, wire the host-only `mod release` in CLI `src/lib.rs`, the production child modules in `src/release/mod.rs`, `#[cfg(test)] mod tests` there, and `mod documents` in `src/release/tests/mod.rs`. Add the test cases before checking selection; Rust does not discover this directory automatically. Keep these internals private. -- [ ] Test closed schema/kind/profile/action combinations, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. -- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::documents`; expect the intended missing implementation or assertion failure. When it compiles, use the nonzero-selection guard below before accepting red or green; a successful run selecting zero tests is a failure of this plan, not a pass. -- [ ] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (trusted source snapshot or bounded official source API acquisition from that exact commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. +- [ ] Test closed schema/kind/profile/action combinations, structurally valid but policy-forbidden `fastly-staging`, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. +- [ ] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::documents`; expect the intended missing implementation or assertion failure. When it compiles, use the nonzero-selection guard below before accepting red or green; a successful run selecting zero tests is a failure of this plan, not a pass. +- [ ] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (independently approved source snapshot or fixed official GitHub Contents API acquisition from that exact independently approved full commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. +- [ ] Follow [R-POLICY](../specs/2026-10-09-1235-release-attestation-design.md#r-policy): fetch only `https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref=`, `Accept: application/vnd.github.raw+json`, explicit read-only credential, 1 MiB maximum and attempt deadline. Reject arbitrary host/path or credential-forwarding redirects. Add source/API mismatch, unsupported producer v2 under consumer v1, reviewed historical v1 under newer consumer, and current-consumer-bound enforcement negatives. - [ ] Keep the already registered tests active and require nonzero selected counts on every focused rerun; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. - [ ] Fix repository/owner IDs, supported combinations, exact gh/action contract and all R-BOUNDS limits. Re-run tests/clippy. Commit `Parse release evidence under embedded consumer policy`. @@ -270,10 +394,10 @@ Implementation sequencing: review the local validation framework and candidate b **Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. -- [ ] Register the new gh production modules and `mod gh` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches must never report a passing security check. +- [ ] Register the new gh production modules and `mod gh` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. - [ ] Write fake-child tests for hangs/cancellation, simultaneous stdout/stderr saturation, malformed/truncated JSON, exit failure, multiple attestations, token precedence, host/config overrides and log redaction. -- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin release::tests::gh`; expect absent runner. +- [ ] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::gh`; expect absent runner. - [ ] Resolve gh once through trusted PATH/explicit trusted installer configuration, require 2.97.0, and retain that executable target. Pass fixed argument vectors with exact certificate identity; never arbitrary forwarded verifier flags. Accept explicit GH_TOKEN then GITHUB_TOKEN, pass only GH_TOKEN, isolate home/config/state, disable prompts, reject loader injection and keep provider credentials out. - [ ] Concurrently drain bounded pipes: 120 seconds per child, 15 minutes per attempt, 128 MiB stdout/1 MiB stderr. Kill/reap on either limit/cancellation. Use real OS fake-child fixtures, not mocks that omit pipe pressure. Failed exit never yields trusted parsed claims. - [ ] Re-run runner negatives and an approved signed public fixture under the pinned gh binary. Commit `Bound and isolate release verification subprocesses`. @@ -284,7 +408,7 @@ Implementation sequencing: review the local validation framework and candidate b **Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. -- [ ] Register the new evidence/github/artifact production modules and `mod verification` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches must never report a passing security check. +- [ ] Register the new evidence/github/artifact production modules and `mod verification` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. - [ ] Add a valid graph and independently altered edges for index, all provenance subjects, SPDX subject/document/root, JS prepared/retained records, policy/source/run/caller identities, signed names, REST lengths/immutability and tag object type. - [ ] Run verification tests; expect no implemented evidence graph. @@ -300,24 +424,26 @@ Implementation sequencing: review the local validation framework and candidate b - [ ] Register `mod offline` in both the private production and test roots before its first focused run. Register the public command module/dispatch before process tests; run `release::tests::offline` with the nonzero-selection guard in addition to the CLI process target. - [ ] Test the required parse path `ts release verify v1.2.0 --asset ts-aarch64-apple-darwin --expected-source-commit `, invalid source/asset/unsupported offline-deploy flags and no provider/config/build hooks on all failure paths. -- [ ] Run `cargo test -p trusted-server-cli --target aarch64-apple-darwin --test release_cli`; expect unknown command. -- [ ] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag must be documented and reviewed, not smuggled into an implementation example. +- [ ] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" --test release_cli`; expect unknown command. +- [ ] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag MUST be documented and reviewed, not smuggled into an implementation example. - [ ] Document trusted gh installation and independent source/previous-verifier bootstrap before first executing downloaded ts. A source-built verifier provides approved-source trust, not official binary provenance for itself. Describe exact policy checks after primitive gh signatures; do not instruct self-verification or checksum-only installation. - [ ] Add an internal build-evidence-only offline verification path, reusing parser/policy/subject/SPDX joins and bounded gh runner with independently approved roots/source/digest. Test it with networking denied and token absent; its result cannot construct publication/deployment authorization. No initial public offline flag is added. Document the pinned primitive commands and the manual companion/document/policy checks needed beyond their exit status; offline success is build evidence only, no official release membership or offline deploy. Run CLI helper, docs lint/format/build and malformed/timeout/zero-provider-write fixtures. Commit `Expose official release verification and trusted bootstrap`. ### Task 21: Validate and document manual Fastly production consumption -**Create:** `scripts/release/inspect-fastly-package.py`, `tests/test_fastly_package.py`. +**Create:** release tool `src/package.rs`, private `package::tests`, bounded manual Fastly archive fixtures and public command tests. **Modify:** `docs/guide/release-verification.md`, `docs/guide/cli.md`, `CHANGELOG.md`; correct obsolete config-embedding comments in source/test/setup files identified below. - [ ] Test accepted four-member layout, generic manifest-only archive, swept-in files, reserved/empty package name, traversal, links, duplicates, wrong module/manifest, malformed/non-core wasm and max-size bounds. -- [ ] Run Fastly package tests; expect absent inspector. -- [ ] Use a fresh private directory, approved-source inspector, an allowlisted deployment-only manifest and verified wasm. `compute pack --wasm-binary` has two output archives; select named archive only after exact layout/content checks. Pack/validate accepting a file does not prove valid Wasm or application execution. Verify valid core format and positive publisher-request fixture separately. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" package::tests` with the nonzero-selection guard; expect absent inspector. +- [ ] Use a fresh private directory, independently bootstrapped approved-source `trusted-server-release inspect-fastly-package`, an allowlisted deployment-only manifest and verified wasm. `compute pack --wasm-binary` has two output archives; select named archive only after exact layout/content checks. Pack/validate accepting a file does not prove valid Wasm or application execution. Verify valid core format and positive publisher-request fixture separately. - [ ] Inspector takes the approved expected executable digest and manifest identity from the successful verification/context procedure, rehashes both the retained archive and its actual module member immediately before submission, and never trusts a digest supplied by the archive. Document trusted-source acquisition for the helper; it is not a separately authenticated release asset. - [ ] Document observed service/base-version bindings and explicit production command with --service-id, --version and --no-default-domain. Explain manifest precedence, automatic clone/activation, possible same-hash skip and inability to supply integrated staging/preactivation guarantees. Serialize manual operator changes. No compute publish/source build is permitted. - [ ] Run a separately authorized disposable Fastly service submission; compare submitted member and actual returned version, including skip/uncertain cases. Record live proof before declaring AC-9 passed. No customer context or provider credentials enter producer jobs. - [ ] Correct false config-embedding text in `.github/actions/setup-integration-test-env/action.yml`, `.github/workflows/test.yml`, integration runtime/framework/parity helpers, browser infra helper and Spin app/middleware/route comments; behavior changes remain separate. Commit `Document and validate verified-byte Fastly deployment`. +- [ ] Update `docs/guide/getting-started.md`, `edgezero.md` and `error-reference.md`: canonical `x-ts-version` is full source identity, external tag belongs to the index, and development retains #1213 display fallback. Annotate the historical #1213 goals. Validate manual retained-byte packaging/submission as AC-9/D6 and operator guidance as AC-9/D8; upstream EdgeZero delay MUST NOT block this manual route. Context/preactivation/staging/recovery remain AC-8. + ### Task 22: Full validation, qualification evidence and release handoff **Modify:** the implementation PR's evidence record and docs; no automatic production mutation. @@ -328,30 +454,38 @@ Implementation sequencing: review the local validation framework and candidate b - [ ] Review against AC-1–AC-7, AC-9–AC-10; keep AC-8 separate. Use @superpowers:requesting-code-review and @superpowers:verification-before-completion. Maintainer approval precedes any production tag. - [ ] Commit validated documentation/evidence with `Record initial release qualification`; update existing PR #1262 with implementation and validation evidence only when the required checks pass; never create a replacement implementation PR. +## Threat-to-negative-proof trace + +| Threat | Existing test families | Plan negative proofs | +| ------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------ | +| TH-1 | T-8, T-9 | Tasks 13, 17, 19: byte/subject/companion tampering and publication membership | +| TH-2 | T-8, T-9, T-10 | Tasks 17–20: independent source-policy acquisition, roots, historical identity and offline limits | +| TH-3 | T-2, T-3, T-4, T-5 | Tasks 3, 7–11: source/common-Git/env audit, actual lifecycle/compile denial, native and JS coverage | +| TH-4 | T-7 | Tasks 3, 14, 16: unlisted/self-approved RC, remote-ref substitution, governance bypass, skipped/wrong-SHA gates | +| TH-5 | T-4, T-7 | Tasks 8, 12, 15: authenticated tools/full closure, real boundary, current-run transfers and separated signer | +| TH-6 | T-9, T-11, T-12 | Tasks 18–21 and linked AC-8: exact gh bootstrap, retained substitution, bounded children, manual equality/recovery | +| TH-7 | T-7, T-8, T-9 | Tasks 15–19: exact certificate/source/caller/run semantics and immutable membership; GitHub remains a trust root | + ## Focused Rust test selection guard -Before accepting any filtered private-module run, require an actual selected test. Once the target compiles, repeat this guard for `documents`, `gh`, `verification` and `offline`; registration belongs to the preceding task step, not to a later implementation step. An intended compile failure while adding missing behavior can establish the first red state, but a zero-test successful command never can. +Before accepting any filtered private-module run, require an actual selected test. Register producer modules/tests before filters such as `documents::tests`, `preflight::tests`, `source::tests`, `materials::tests`, `process::tests`, `boundary::tests`, `preparation::tests`, `javascript::tests`, `qualification::tests`, `recipe::tests`, `inspect::tests`, `composition::tests`, `spdx::tests`, `collection::tests`, `distribution::tests`, `publication::tests` and `package::tests`. For Tasks 17–20 select `trusted-server-cli` and its registered `release::tests::{documents,gh,verification,offline}` filters instead. An intended compile failure for missing behavior can establish the first red state; successful zero selection never can. ```sh -set -e -RELEASE_HOST_TARGET="$(rustc -vV | awk '/host:/ { print $2 }')" -RELEASE_TEST_FILTER=release::tests::documents -RELEASE_TEST_LIST="$(mktemp)" +set -eu +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +test -n "$HOST_TARGET" +RELEASE_TEST_PACKAGE=trusted-server-release +RELEASE_TEST_FILTER=documents::tests +RELEASE_TEST_LIST=$(mktemp) trap 'rm -f "$RELEASE_TEST_LIST"' EXIT -cargo test -p trusted-server-cli --target "$RELEASE_HOST_TARGET" \ +cargo test -p "$RELEASE_TEST_PACKAGE" --target "$HOST_TARGET" \ "$RELEASE_TEST_FILTER" -- --list > "$RELEASE_TEST_LIST" -python3 - "$RELEASE_TEST_LIST" <<'PY' -import sys -from pathlib import Path -matches = [line for line in Path(sys.argv[1]).read_text().splitlines() - if line.endswith(": test")] -if not matches: - raise SystemExit("Focused release test filter selected zero tests") -print(f"Selected {len(matches)} release tests") -PY -cargo test -p trusted-server-cli --target "$RELEASE_HOST_TARGET" "$RELEASE_TEST_FILTER" +awk '/: test$/ { count++ } END { if (count == 0) { print "Focused release test filter selected zero tests"; exit 1 } print "Selected " count " release tests" }' "$RELEASE_TEST_LIST" +cargo test -p "$RELEASE_TEST_PACKAGE" --target "$HOST_TARGET" "$RELEASE_TEST_FILTER" ``` +Set `HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p')` before each independent shell session/filter. `$APPROVED_SOURCE_COMMIT` MUST come from independent source approval, never the release index. These Cargo commands build a local developer tool only. Qualified production wrappers MUST select the independently approved source and OS-native toolchain described above. The ordinary `test-release-native` CI matrix on Ubuntu 24.04/macOS 15 directly runs all native helper tests, clippy/all-targets/all-features, documentation and actual nonzero native JS `release_prebuilt` tests/clippy. The prior WASI alias selecting zero native tests supplies no coverage. This source-built CI matrix is not production bootstrap or qualification proof. Future aliases require separate review; adapter/CLI aliases do not cover the helper. The explicit host commands below remain required until aliases are reviewed/wired. Never use default WASI output to bootstrap a native producer. + ## Full implementation verification commands Run from the repository root with pinned tools and installed npm dependencies. Linux CI uses its host triple; macOS can use the CLI helper. Record each command independently so a later command cannot mask an earlier failure. @@ -376,95 +510,49 @@ cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml -- cargo test -p trusted-server-core --test template_build_digest cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings scripts/lint-wasm-clock.sh -python3 -m unittest discover -s scripts/release/tests -v +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +cargo test -p trusted-server-release --target "$HOST_TARGET" --all-features +cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features +cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt +cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features -- -D warnings +cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings ``` Run the remaining commands with the repository-pinned Node/npm selected explicitly (subdirectory shell startup can otherwise pick a different Node): ```sh -(cd crates/trusted-server-js/lib && node build-all.mjs && npx vitest run && npm run format) +(cd crates/trusted-server-js/lib && node build-all.mjs && TRUSTED_SERVER__RELEASE_JS_TESTS=1 npx vitest run && npm run format) (cd docs && npm run lint && npm run format && npm run build) -docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md" +docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "scripts/**/*.mjs" "tinybird/**/*.md" RELEASE_HOST_TARGET="$(rustc -vV | awk '/host:/ { print $2 }')" cargo run -p trusted-server-cli --target "$RELEASE_HOST_TARGET" -- dev lint domains --staged git diff --check git diff --cached --check ``` -Stage the reviewed implementation files before the staged-domain check; each grouped command stops at its first failure. New producer/schema dependencies must be installed at their exact reviewed versions first. No bare workspace cargo test/build or global all-features clippy. +Stage the reviewed implementation files before the staged-domain check; each grouped command stops at its first failure. New producer/schema dependencies MUST be installed at their exact reviewed versions first. No bare workspace cargo test/build or global all-features clippy. ## Traceability and milestone exits -| Requirements | Tasks / principal negative proof | Acceptance | -| ------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------- | -| R-COHORT, R-PLATFORM, R-VARIANT | 1,3,8,9: exact cohort/profile, incompatible native runtime, unsupported reuse | AC-2, AC-6 | -| R-CONFIG, R-INPUTS, R-ENV | 3–5,7,9: operator inputs, unexpected source files, skips/overrides, source integrity | AC-2 | -| R-JS | 5–7,10: locked A/B, inventory joins, input versus retained bytes | AC-3, AC-4 | -| R-BUILD, R-REPEAT, R-NATIVE | 8–12: actual confinement, complete wasm equality, valid transfers, honest native outcome | AC-3 | -| R-SBOM | 10,11,13,19: real Rust/native/JS coverage and SPDX root/predicate equality | AC-4 | -| R-FACTS | 4: strict full source, owning target/variant, external tag and unchanged service identity | AC-5 | -| R-SOURCE, R-SIGN, R-PUBLISH | 14–16: approved lightweight main/RC, exact-commit gates, isolated signer, immutable reconciliation | AC-6 | -| R-INDEX, R-EVIDENCE, R-POLICY | 2,13,17,19: schema/semantic joins, subjects and independent policy identity | AC-4, AC-7 | -| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | 18–20: trustworthy first use, limits/pipe pressure, exact REST/signed membership | AC-7 | -| R-OFFLINE | 19,20: approved build proof only; offline official deploy rejected | AC-7 | -| R-DEPLOY, R-PACK, R-CONTEXT, R-MANUAL | 19,21: verified bytes before mutations, explicit manual limits and live submission | AC-9 | -| R-EDGEZERO, R-RECOVERY | Linked upstream plan, not first-release closure | AC-8 | -| R-FOLLOWUP | 1,20–22: explicit unsupported adapters/reconstruction/offline; January supersession | AC-10 | +| Requirements | Tasks / principal negative proof | Acceptance | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------- | +| R-COHORT, R-PLATFORM, R-VARIANT | 1,3,8,9: exact cohort/profile, incompatible native runtime, unsupported reuse | AC-1, AC-2 | +| R-CONFIG, R-INPUTS, R-ENV | 3–5,7,9: operator inputs, unexpected source files, skips/overrides, source integrity | AC-2 | +| R-JS | 5–7,10: locked A/B, inventory joins, input versus retained bytes | AC-3 | +| R-BUILD, R-REPEAT, R-NATIVE | 8–12: actual confinement, complete wasm equality, valid transfers, honest native outcome | AC-3 | +| R-SBOM | 10,11,13,19: real Rust/native/JS coverage and SPDX root/predicate equality | AC-4 | +| R-FACTS | 4: strict full source, owning target/variant, external tag and unchanged service identity | AC-5 | +| R-SOURCE, R-SIGN, R-PUBLISH | 14–16: approved lightweight main/RC, exact-commit gates, isolated signer, immutable reconciliation | AC-6 | +| R-INDEX, R-EVIDENCE, R-POLICY | 2,13,17,19: schema/semantic joins, subjects and independent policy identity | AC-4, AC-7 | +| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | 18–20: trustworthy first use, limits/pipe pressure, exact REST/signed membership | AC-7 | +| R-OFFLINE | 19,20: approved build proof only; offline official deploy rejected | AC-7 | +| R-DEPLOY/R-PACK manual portion, R-MANUAL | 19,21: verified bytes before mutations, explicit manual limits and live submission | AC-9 | +| R-DEPLOY/R-PACK integrated portion, R-CONTEXT, R-EDGEZERO, R-RECOVERY | Linked upstream plan, not first-release closure | AC-8 | +| Operator/CLI/changelog guidance | 20–22: source-header identity, bootstrap and manual-route instructions (D8) | AC-9 | +| R-FOLLOWUP | 1,20–22: explicit unsupported adapters/reconstruction/offline; January supersession | AC-10 | A completed task means its described tests pass and reviewed code exists, not merely that its checkbox was checked. Recipe pins/qualification are concrete outputs of Task 8, not assumed values. An unavailable required host blocks the selected contract; it does not authorize weakening it. For optional CLI B only, bounded unavailability is an honest nonblocking result. ## Planning-only verification and handoff -For these Markdown plans, validate formatting, links, shell syntax, requirement coverage, task dependencies and independent review. Do not claim that runtime tests verify implementation that has not been written. Continue in the main checkout `/Users/prk-jr/Desktop/opensource/rust/trusted-server`, branch `spec/1235-release-attestation`, and existing PR #1262. The temporary planning worktree/branch were used only to draft the plans; their commits have been incorporated into this branch. - -## Review record - -On 2026-10-13, independent whole-package review approved these three plans after three passes; separate producer and consumer/upstream reviews informed the corrections. Findings resolved: required Fastly B variation enforcement, final-byte application qualification, mutable-publication recovery, lightweight tagging, native comparison/profile boundaries, all-action SHA pins, approved-source policy-byte binding, lifecycle network denial, and noncircular staging qualification with complete durable outcomes. - -This is planning-completeness approval, not maintainer spec approval or proof of implementation/hosted qualification. Document verification covers formatting/domain rules, relative references, shell/Python example syntax and all 29 requirement mappings. - -A subsequent fresh spec-to-plan review on 2026-10-13 found three execution-coverage gaps despite the earlier review: applying actual production network confinement, registering private Rust tests before filtered runs, and independently qualifying integrated production activation. All were corrected; the two fresh reviewers then reported clean primary and milestone alignment. The optional native scheduler-failure fixture was also strengthened. These reviews do not replace the future implementation and qualification gates. - -## Implementation execution record - -Implementation starts on 2026-10-13 at `d157766fa4b9c13b9b4e666a8961009f1133857b`, the current governing-spec revision, in the main checkout on `spec/1235-release-attestation` and PR #1262. The schema revision inspected is `24a8a7180a32a30e0438d9f5b77697c058c6e9c3`. These are investigation revisions, not an approved release source commit. The user has authorized implementation on this branch; maintainer contract/code approval and release qualification remain outstanding. - -Task 1 investigation confirms the four-artifact cohort, its existing compatibility baselines, `TRUSTED_SERVER__RELEASE_BUILD=1` and `TSJS_PREBUILT_INVENTORY`. No release workflow exists at this revision. The selected publication owner remains `release.yml`, with same-revision `release-build.yml` owning signing. PR #1292 is a documentation draft at `bbba62aca580b94192628495826ae9551efad24b`; its package-only distribution, additional target, compatibility changes and annotated tags require reconciliation through the linked packaging plan. It must not independently publish an overlapping immutable tag. - -Permission-policy reconciliation remains an enablement dependency owned by the #1045/#1084 work (`jwrosewell`), not a blocker for local implementation. The current checkout loads app configuration at runtime and has no embedded permission-policy module. #1045 at `a028c00da096cc722dd95307ab8ee0e3514a448e` embeds `config/permissions/sample.yaml`; #1084 at `3c43876fc8b0d316d812a8c74aba47cc87d17279` explicitly defers runtime policy. Those proposals must satisfy R-CONFIG before release enablement. This record neither approves their embedded policy nor selects a new transport. - -Task completion requires implementation evidence and both independent reviews. Task 1's owner reconciliation and maintainer milestone review remain pending; subsequent local implementation can proceed. Hosted qualification, governance setup, signing/publication rehearsal and live Fastly submission remain distinct evidence gates, and no production release trigger is enabled by this record. - -Task 2 is implemented in `1cfca1357`, with review corrections in `c29940908` and `fbb88d2e`. Independent spec and quality reviews pass after resolving tuple-contained key validation, serialization exception consistency and iterator identity selectors. The controller's fresh utility run passes 35 tests. A fresh private Python 3.12 environment installs all six pinned dependencies with `--require-hashes --only-binary=:all:`; `pip check`, the normative release-index schema check and Draft 2020-12 positive/negative semantics pass. The reproducible installation/validation procedure is in `scripts/release/tests/fixtures/README.md`; Task 14 must wire this explicit installation into hosted producer validation before using the validator. These checks establish utility behavior and local validator compatibility, not artifact or hosted qualification. - -Task 3 is implemented in `c9076d0f1`, with review corrections in `31ee0739` and `b89552d1`. Independent spec and quality reviews pass after correcting the SPDX 2.3 predicate identity and including executable JS/Rust validation sources in the source checks. The controller's fresh Python run passes 72 tests, the complete native CLI test command passes, and `cargo clippy-cli` passes. Source snapshots accept Git-equivalent executable modes under the selected umask variations while retaining actual permissions for before/after checks. Narrow domain-lint support for the three mandatory standards hosts is tested against the production policy and hostile lookalikes. Real-checkout inspection rejects a pre-existing ignored CLI source `.DS_Store`, which is preserved; actual build preparation requires a clean source snapshot. Tool, image, SDK and recipe digests remain explicitly pending, and `--require-qualified` cannot succeed until Task 8 supplies authenticated acquisition and measured evidence. Vite environment suppression, OS confinement, native graph coverage and consumer enforcement remain their later tasks. - -Task 4 is implemented in `bfb697404`. Independent spec and quality reviews pass. The controller freshly ran all 13 source-resolution tests and all six owning-build-script/facts/resource tests; the quality reviewer independently repeated both suites. Implementation verification also passes the full native CLI test command, core template tests, CLI/core/Fastly lint, Fastly source-version tests with default and reusable-sandbox features, formatting and staged domain lint. Canonical CLI/Fastly facts derive from their owning Cargo package, target and feature settings; the public data constructor cannot reconfigure those constants and carries no verification authority. Tests compile the actual release cfg and resource-discovery function, reject the existing developer checkout fallback, prove its literal is absent, and preserve ancestor resources. Help/version/config-init portability uses the Cargo-built CLI; complete official release executable startup and cross-tag final-byte equality remain later recipe qualification evidence. No release workflow, publication or provider action is introduced by this task. - -Task 5 is implemented in `e1606fa3d`. Independent spec and quality reviews pass. The controller freshly ran all 28 native actual-script/helper tests; the spec reviewer independently ran those and all 10 library tests. Implementation verification also passes WASM library tests, the full 50-file/1,211-test JS suite with type checking, Fastly/CLI target checks and lint, formatting, staged domain lint, and release-mode Cargo interface fixtures. The release fixtures use checked mock bundle bytes and a failing npm, proving prebuilt consumption and no npm execution; they do not prove official source approval or JS provenance. Checked inventories are bounded strict JSON, exact sorted identities and byte hashes; native held descriptors, identity checks and private-byte verification reject links and copy-time mutation. The exact raw inventory hash is exposed through Cargo build-script evidence for Task 10. Producers must honor the documented 32 MiB per-module input budget. Contribution/tool/material arrays remain opaque at this boundary; Task 6 supplies semantic composition evidence and Task 7 supplies independent JS builds. Source parent aliases are accepted only when the held resolved directory identity remains unchanged; Cargo-owned private output parents are an explicit trust boundary. Hosted qualification and final retained-module evidence remain pending. - -Task 3 source-check follow-up is implemented in `27080c540`. Independent spec and quality reviews pass after closing the ignored nested Vitest-input gap: the entire JS library is checked, with only its direct real `node_modules` directory and the exact empty regular single-link Cargo coordination file exempted. Nested dependency-directory lookalikes, links, nonempty coordination state and otherwise untracked validation inputs fail. The controller freshly ran all 78 Python tests. This does not approve the existing ignored operator/OS files or weaken tracked-byte, mode, environment or qualification checks. - -Task 6 is implemented in `eeb68f918`, with review corrections in `c9683ba30` and `9684fd868`. Independent spec and quality reviews pass. The controller freshly ran all 42 composition tests, the complete 51-file/1,253-test JS suite with type checking, and all 78 Python tests. Recording preserves all 13 final bundle files byte-for-byte. The producer joins conservative post-Rollup contributions to source, locked npm ownership and known virtual producers, measures final files, and checks closed preparation records and actual selected tool identities. Release Vite and Vitest suppress ambient environment-file loading. Lifecycle outputs cannot be claimed by parent packages when owned by separately locked children; installed-tree file headers and mutation checks use held-descriptor metadata, with two deterministic mode-race regressions. Recorded tool entry hashes and synthetic lifecycle fixtures are interface evidence, not authenticated acquisition or proof of execution history. Fresh installation, actual lifecycle observations, A/B isolation and comparison remain Task 7; actual hosted qualification remains Task 8. No signing, publication or provider action is enabled. - -Task 7 is implemented in `223687649`, with review corrections in `3e7fdc3dd`. Independent spec and quality reviews pass. On 2026-10-14 the controller freshly ran all 122 Python tests (three separately selected local probes skipped), the two actual local confinement controls, and the complete local A/B pipeline. Each fresh offline installation contains 602 packages and passes all 51 JS test files, 1,253 tests and type checks; all 13 final module files match byte-for-byte. Publication atomically retains only A’s checked raw inventory alongside a `bundles/` directory containing only JS files. Actual Vite and pinned esbuild installer probes fail after removing every prepared native copy, with no replacement remaining. Parent `TSJS_RUN_TESTS=0` cannot suppress the mandatory suites. These full-recipe tests use an explicitly synthetic source commit and return `local-unqualified`. - -Preparation authenticates original archive bytes, bounded decompressed tar metadata and exact Git object identities before fresh materialization. The source-owned exception for three exact locked npm archives permits only their identical `package/./dist/index.js` and `package/dist/index.js` pair; unknown aliases, differing bytes/modes/types/sizes and other duplicates fail. The controller and reviewers independently authenticated those originals. Actual lifecycle starts/completions, no-output hooks, changed owned files and swallowed optional failures are checked separately from archive and installed-tree identities. Effective lifecycle Node selection excludes package-bin shadowing. Private A/B resources, peer metadata/read/write/parent/symlink denial, shared-write denial and positive unconfined/negative confined TCP controls are exercised. - -Review corrections require exact integer inventory sizes and closed root/module shapes, and precheck every selected Git object’s identity/type/size and cumulative budget before requesting bodies. Export and repeated source checks share that reader, including direct commit-object timestamp inspection without parent history. Its guarantee explicitly assumes the sanitized trusted Git executable and an immutable object store; it does not claim adversarial race-proof streaming. Release Vitest uses a literal host and a narrowly scoped native Node-import resolver to preserve production-mode validation without DNS or browser builtin substitution; ordinary development and the separate IIFE collector retain their behavior. - -The controller’s first post-review full run was interrupted by overnight system sleep and failed closed with worker/test timeouts and unexpected temporary output. Power-log and clock inspection identified the interruption; an unchanged rerun with a process-scoped idle-sleep assertion passes in 226.755 seconds. No timeout, source exception or network permission was relaxed. Actual hosted OS/tool/SDK acquisition and boundary qualification, authenticated Python bootstrap/path selection and official recipe enablement remain Task 8; the public producer command continues to refuse unqualified execution. - -Task 8 has a reviewed local framework in `481f8c7fa`, with corrections in `7c8f279c6` and `b48b92c38`; Task 8 itself remains incomplete. Independent spec and quality reviews approve the local framework only. The controller freshly ran all 155 release-helper Python tests in 29.507 seconds, including all three actual macOS boundary controls; the separately selected full JS pipeline is the sole skipped test and retains its Task 7 evidence. The locked private validator environment installs with required hashes and binary distributions, and passes `pip check`. The controller also checked the retained primary-source Moby blob against the exact vendored seccomp bytes and recorded SHA-256. - -The local framework validates closed observations without authenticating their claims or conferring qualification. It distinguishes the GNU builder from the three compatibility runtimes and records actual hosted macOS image versions rather than inventing VM byte hashes. Domain-separated fingerprints bind the reviewed executable helper closure, settings and acquisition selections while excluding derived result pins; the raw tools-file hash remains a separate input. Actual TCP/UDP controls cover IPv4/IPv6, loopback/documentation destinations and three process generations, with successful private Unix communication. Unavailable routing is distinct from permission denial. Review corrections reject contradictory successful phases, normalize numeric compatibility versions, and prevent protected source/Git ancestors from being renamed or removed while preserving private installation/output writes. The ancestor bypass was reproduced before correction and independently rejected afterward. - -All tool, SDK, OCI and recipe qualification pins remain pending. The inert manual hosted-workflow proposal is outside `.github/workflows` and deliberately stops before incomplete shared recipes or unauthenticated staging can run. Actual GNU execution, native macOS 15 ARM/Intel compilation and startup, complete Fastly publisher-response validation, authenticated bootstrap/acquisition evidence, separately authorized hosted resources and reviewed measured pins remain required. No workflow registration, hosted dispatch, signing, publication or deployment occurred. Read-only Rust architecture and dependency investigations support implementing one shared recipe next; its candidate selector must remain internal and cannot supply consumer or publication authority. - -Task 9 has a reviewed local framework in `5476b7cce`, with corrections in `9988a566b`, `2a2db6d3a` and `7abb8e8f5`; Task 9 itself remains incomplete. Independent spec and quality reviews approve the local slice only. The controller freshly ran all 227 release-helper Python tests in 31.672 seconds, including actual local Cargo build-script HTTP success/permission-denial, all three macOS boundary controls and a compiled tiny Mach-O fixture. The separately selected full JS pipeline is the sole skipped test and retains its Task 7 evidence. These local fixtures do not qualify GNU or native macOS 15 ARM/Intel release artifacts. - -The implementation provides fixed asset/role parsing, fresh original-material preparation and Cargo vendor normalization, source-owned role profiles, bounded separate Cargo logs, source/tool/material checks, immutable accepted bytes, ELF/Mach-O inspection and exact-final-Wasm runtime gates. Review corrections compose generated Cargo configuration with the actual Linux `/work` namespace, preserve the complete origin HTML fixture, exercise native help/version/config initialization with source/package/scaffold checks, bind Docker authentication to its actual executor, protect its private client configuration and load one exact source-owned boundary module before candidate selection. A reproduced timeout leak is corrected by terminating the spawned process group even after its leader exits; the regression verifies descendant termination and leader reaping. - -The controller independently exercised the stricter Viceroy publisher fixture against an existing development Wasm. Both runtime-config variants reached the fictional origin, preserved complete HTML, injected and fetched exact prepared core/creative JS with its matching version hash, and left the Wasm unchanged. This diagnostic explicitly has no maintained-validator or canonical-build proof. An existing native development CLI generated the exact source scaffold outside checkout but correctly failed the approved-source version gate because it reported a development branch label. - -The central Rust recipe has not executed preparation, product compilation, final acceptance and output copy together. Its public selectors remain closed: candidate acquisitions are unpopulated, acquisition-evidence digests do not yet authenticate their evidence, Python bootstrap and reviewed qualification receipt consumption are not integrated, and checked JS inventory bytes alone cannot establish producer authority. The next focused Task 8 integration must add source-owned authenticated selection/dispatch and a private completion binding from the actual shared Task 7 producer, and correct hosted JS environment/cwd/interpreter mapping. Task 10 must capture metadata/tree and native materials through that same boundary before hosted closure measurement. Actual selected-platform compilation/startup, maintained Wasmtime validation, canonical Fastly A/B equality and reviewed measured pins remain mandatory. No hosted resources, signing, publication or provider operations were enabled. +For these Markdown plans, validate formatting, links, shell syntax, requirement coverage, task dependencies and independent review. Do not claim that runtime tests verify implementation that has not been written. Continue in the existing main checkout on branch `spec/1235-release-attestation` and PR #1262. Automated review is not maintainer approval. diff --git a/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md b/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md index 798e9b7a42..23ecf8caf5 100644 --- a/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md +++ b/docs/superpowers/plans/2026-10-13-cli-packaging-attestation-integration.md @@ -6,15 +6,17 @@ **Architecture:** Consume final canonical Build A executables and their qualified evidence; packaging never recompiles or alters those bytes. A separately signed package inventory binds outer package digests to exact executable members. One publication job uploads canonical and selected package evidence before publishing a new immutable tag. -**Tech Stack:** Existing #1292 shell helpers/nfpm packaging design, Python strict metadata and bounded archive readers, SPDX canonical executable evidence, pinned GitHub attest/gh contracts from the primary plan. +**Tech Stack:** Existing #1292 shell helpers/nfpm packaging design, the host-only Rust release tool's strict metadata and bounded package/archive modules, SPDX canonical executable evidence, pinned GitHub attest/gh contracts from the primary plan. --- +The [Rust migration addendum](../specs/2026-10-14-release-tooling-rust-migration.md) applies to every new packaging producer helper. Extend private `crates/trusted-server-release/src/package.rs` and `distribution.rs` modules; retain thin POSIX/nfpm adapters, no introduced Python or generic packaging framework. Native independently approved-source bootstrap and all exact executable/acquisition checks remain required. Unit tests stay private alongside modules; fixture data uses the crate's `tests/fixtures/` and public process tests use `tests/commands.rs`. + ## Inputs and prerequisites Read the [primary release plan](2026-10-13-attested-release-implementation.md), [#1235 contract](../specs/2026-10-09-1235-release-attestation-design.md), PR #1292's packaging spec/plan and its final reviewed revision. At planning time #1292 is documentation only; do not assume its workflow/helper paths exist. Fetch or stack the owning changes before editing them. This plan supplements that plan rather than copying its independent publish job. -The approved #1235 index v1 covers three raw CLI binaries plus Fastly, no package-inventory reference and no Linux ARM64. The package integration therefore requires a reviewed additive contract and new schema version. Task P1 must approve it before P2–P7 implementation. Neither package provenance alone nor SHA256SUMS satisfies the executable's SPDX/provenance graph. +The governing draft #1235 index v1 covers three raw CLI binaries plus Fastly, no package-inventory reference and no Linux ARM64. The package integration therefore requires a reviewed additive contract and new schema version. Task P1 MUST approve it before P2–P7 implementation. Neither package provenance alone nor SHA256SUMS satisfies the executable's SPDX/provenance graph. **Selected initial integration:** preserve the existing three qualified CLI targets. Produce three Linux x86_64 installer formats and two macOS archives (five packages). This deliberately does not complete #863's Linux ARM64 requirement. Task P8 independently extends canonical qualification/SBOM/policy to ARM64 before publishing its three packages. If maintainers require all eight installer assets in the first packaging release, P8 becomes a prerequisite before that release; no half-qualified ARM asset is slipped into the three-target cohort. @@ -29,7 +31,7 @@ Initial #1235 release can happen first; packaging appears on a later new tag onc - [ ] Agree on #1262's GNU/macOS baselines and real Intel startup. Do not treat Ubuntu 22.04/glibc 2.35 or Rosetta alone as satisfying GNU 2.34/Intel qualification. Keep package ecosystem smoke tests as additional evidence. - [ ] Resolve version policy explicitly: packaging milestone chooses #1292's tag equals owning CLI Cargo version, with documented maintainer bump/changelog procedure. This is a reviewed narrower packaging prerequisite, not an attempt to embed external tag into binaries. Initial un-packaged #1235 tags need not retroactively adopt it. - [ ] Approve index v2's optional `package_inventory` reference and its attestation references with exact name/length/digests. Enable it only in the reviewed `phase1-fastly-cli-packaged` cohort and updated consumer policy. Approve `ts release verify --package --expected-source-commit ` as the distinct package-selection interface; `--package` and `--asset` are mutually exclusive. Older v1 consumers reject unsupported v2, with an independently trusted verifier upgrade path documented. -- [ ] Define package inventory records: schema/source/cohort/recipe/policy identity, each package name/format/architecture/size/SHA-256, exact executable member path/mode/size/SHA-256 and canonical raw asset name. Refer to raw asset digests, not release-index digest: index → inventory → package/member must not form a self-hash cycle. Bundle references are in the index, not inside the inventory that the bundles authenticate. +- [ ] Define package inventory records: schema/source/cohort/recipe/policy identity, each package name/format/architecture/size/SHA-256, exact executable member path/mode/size/SHA-256 and canonical raw asset name. Refer to raw asset digests, not release-index digest: index → inventory → package/member MUST NOT form a self-hash cycle. Bundle references are in the index, not inside the inventory that the bundles authenticate. - [ ] Replace #1292's signed/annotated `git tag -s` release instruction with `git tag v ` followed by the explicit tag push. Validate lightweight object type; a tag signature does not replace the approved source/signing policy. - [ ] Get the delta approved/merged and update plan baseline. Commit `Align CLI packaging with canonical attestation`. @@ -37,40 +39,40 @@ Initial #1235 release can happen first; packaging appears on a later new tag onc ### Task P2: Consume canonical executables without transformations -**Modify/create according to #1292 ownership:** `scripts/release/package-linux.sh`, `package-macos.sh`, `packaging/nfpm.yaml`, `scripts/release/tests/test_package_inputs.py`. +**Modify/create according to #1292 ownership:** `scripts/release/package-linux.sh`, `package-macos.sh`, `packaging/nfpm.yaml`, release tool private `package::tests` and `tests/commands.rs`. - [ ] Write tests rejecting wrong source/target/role, source recompilation, strip/sign hooks, unverified raw input, altered member and CLI B output substituted for A. -- [ ] Run `python3 -m unittest discover -s scripts/release/tests -p 'test_package_inputs.py' -v`; expect missing integration behavior. +- [ ] Derive `HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p')`; run `cargo test -p trusted-server-release --target "$HOST_TARGET" package::tests` with the primary plan's nonzero-selection guard; expect missing integration behavior. These local source-built commands do not qualify native bootstrap. - [ ] Accept only Task 12/13 primary-plan validated A output and identity table. All final stripping/signature transformations occur in the canonical recipe before its digest/SBOM, never in packaging. Remove #1292's independent strip step and compiled-cache restoration from the canonical build path. - [ ] Add packaging configuration for the qualified x86_64 Linux executable and both Apple executables. Preserve member bytes; set documented installation modes. nfpm/tool acquisition has reviewed version/digest verification, not a floating installer. - [ ] Test canonical file unchanged before/after all package formats. Commit `Package the canonical CLI bytes without modification`. ### Task P3: Inspect whole package structure and bind members -**Create:** `scripts/release/inspect-cli-package.py`, `make-package-inventory.py`, `tests/test_package_inventory.py`, adversarial package fixtures. +**Extend:** release tool `src/package.rs`, `src/distribution.rs` and private registered tests; add adversarial data under `crates/trusted-server-release/tests/fixtures/packages/` and process cases to `tests/commands.rs`. Proposed fixed commands are `trusted-server-release inspect-cli-package` and `make-package-inventory`, only after P1 approval. - [ ] Write tests for nested traversal, links/hardlinks, duplicate members, oversized compressed/decompressed content, extra executable members, wrong architecture/mode, substituted maintainer script, unsupported compression and altered ts bytes. -- [ ] Run package inventory tests; expect absent inspector. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" package::tests` and `distribution::tests` separately with the nonzero-selection guard; expect absent inspector. - [ ] Implement bounded format-specific inspection: Debian ar/control/data archives, RPM payload/header, Arch tar and macOS tar. Use maintained parsers/pinned utilities where possible. Each adapter validates allowed installation paths, executable ts, license/readme and explicitly approved metadata/scripts; reject unknown code-bearing entries. A raw tar scan is insufficient for .deb/.rpm. - [ ] Compare extracted executable bytes against canonical retained SHA-256/size, check 0755 installation mode, then hash the final outer package. Generate deterministic inventory only after every selected wrapper passes. Do not compare an archive digest directly to executable digest. - [ ] Validate closed package schema and source/target/profile joins with the canonical index data. Reject inventory attempts to approve an unknown raw target. Commit `Bind installer packages to canonical CLI members`. ### Task P4: Smoke-test installation and provenance-aware extraction -**Modify:** #1292 smoke helpers/workflow jobs; `scripts/release/tests/test_package_smoke.py`. +**Modify:** #1292 smoke helpers/workflow jobs; release tool private package-smoke tests and public process fixtures. - [ ] Test that failed install/startup/version or member mismatch blocks the selected package cohort; metadata-only architecture checks do not count as execution qualification. -- [ ] Run smoke fixture tests; expect incomplete checks. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" package_smoke` with the nonzero-selection guard after registering these cases; expect incomplete checks. - [ ] Install Linux wrappers in reviewed clean Ubuntu/Fedora/Arch environments, run version/help/config-init outside a checkout, and compare installed /usr/bin/ts to canonical digest. Preserve the GNU compatibility qualification independently of package-manager tests. - [ ] Extract/run macOS archives on actual qualified ARM/Intel hosts. Rosetta may be extra coverage, never the sole Intel gate. Restore executable permissions without altering bytes; verify loader requirements. -- [ ] Record exact container/tool/OS identities and outcomes. Smoke images must be pinned and qualified, not `latest`. Commit `Qualify installed CLI package behavior and identity`. +- [ ] Record exact container/tool/OS identities and outcomes. Smoke images MUST be pinned and qualified, not `latest`. Commit `Qualify installed CLI package behavior and identity`. ### Task P5: Sign package evidence inside the existing trusted boundary -**Modify:** `.github/workflows/release-build.yml`, `scripts/release/validate-distribution.py`, workflow contract tests. +**Modify:** `.github/workflows/release-build.yml`, release tool `src/distribution.rs`, workflow contract tests. - [ ] Test package metadata cannot replace executable SPDX/provenance, wrong signer/action/run, modified outer bytes, inventory-member mismatch and unapproved package subject. -- [ ] Run workflow/graph fixtures; expect package evidence unsupported. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" distribution::tests` and `workflow_contract` separately with the nonzero-selection guard; expect package evidence unsupported. - [ ] Reuse exact same-revision signer and pinned attest 4.2.2; do not keep #1292's direct v2 provenance action in publish. Package provenance names each final package plus authenticated package inventory as exact subjects. Raw executable proofs still name only their approved executable/companions. - [ ] Add a signed package-inventory proof and index v2 references for inventory/bundles. Root index is assembled after package proofs exist and covers them; no signature/checksum cycle. For the CLI's component composition, consumers use the executable's SPDX proof. Do not advertise an enclosing installer SBOM unless a separate container-level scope is actually generated. - [ ] Verify actual pinned certificates/provenance and all wrapper/member edges before publication. Commit `Attest CLI installer and member evidence`. @@ -90,7 +92,7 @@ Initial #1235 release can happen first; packaging appears on a later new tag onc **Modify:** `.github/workflows/release.yml`, package release helpers, `CONTRIBUTING.md`, `CHANGELOG.md` and tests. - [ ] Test incomplete wrappers, mixed source versions, package-only draft, missing raw/SBOM assets, an existing immutable v1 tag and uncertain publish response. -- [ ] Run publication fixtures; expect package cohort unsupported. +- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" publication::tests` with the nonzero-selection guard; expect package cohort unsupported. - [ ] Publish exact canonical plus selected wrapper assets/evidence/checksums through the single owner. No independent #1292 publish job races or finalizes the tag first. Require all selected wrapper gates, complete downloads/joins and post-publication immutable checks. - [ ] Rehearse the whole v2 release and trusted package install in an authorized disposable repo, then run all AGENTS gates and producer/consumer tests. Only a separately authorized new production tag follows. - [ ] Commit `Publish canonical and packaged CLI evidence together`. diff --git a/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md index 9fd79eb4c1..bbbc5d41f9 100644 --- a/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md +++ b/docs/superpowers/plans/2026-10-13-fastly-prebuilt-edgezero-integration.md @@ -12,7 +12,7 @@ ## Authority and prerequisites -Read the [primary plan](2026-10-13-attested-release-implementation.md) and [contract R-DEPLOY through R-RECOVERY](../specs/2026-10-09-1235-release-attestation-design.md). This is AC-8, a separate integration milestone, not an initial #1235 closure condition. It depends on the primary verifier/retained artifact, package inspector and public qualification; it may be prepared upstream in parallel but cannot ship an official release path without that verifier. +Read the [primary plan](2026-10-13-attested-release-implementation.md) and [contract R-DEPLOY through R-RECOVERY](../specs/2026-10-09-1235-release-attestation-design.md#r-deploy). This is AC-8, a separate integration milestone, not an initial #1235 closure condition. It depends on the primary verifier/retained artifact, package inspector and public qualification; it may be prepared upstream in parallel but cannot ship an official release path without that verifier. Baseline EdgeZero revision is `683202c66948146ec360f490126f1d60f920288b`. Its generic adapter operation returns unit, source deploy can execute manifest shell commands, and Fastly staging builds/autoclones before staging. These are reference mechanisms, not an existing typed prebuilt seam. @@ -22,7 +22,7 @@ Use a separate upstream EdgeZero checkout/worktree; do not edit Cargo's shared c Keep small concrete APIs, with default unsupported behavior for other adapters. Do not add speculative Cloudflare/Spin/Axum payload variants. -The owning upstream design must implement these concrete records (not empty adapter variants): +The owning upstream design MUST implement these concrete records (not empty adapter variants): | Record / interface | Required fields / behavior | | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -30,7 +30,7 @@ The owning upstream design must implement these concrete records (not empty adap | `PrebuiltFastlyRequest` | Strong service ID, exact base version, production/staging mode, prepared payload and resolved context/sharing policy | | `WriteIntent` | Stable operation ID, monotonically increasing sequence, phase (`clone`, `upload`, `relink`, `selector-write`, `stage`, `activate`), exact resource/version where known, request identity digest | | `WriteObservation` | Matching operation/sequence/phase, actual response resource/version, known/unknown result and sanitized evidence references | -| `OperationSink::before_write(&WriteIntent)` | Synchronous concrete fallible result; caller must flush durable intent and failure forbids the following mutation | +| `OperationSink::before_write(&WriteIntent)` | Synchronous concrete fallible result; caller MUST flush durable intent and failure forbids the following mutation | | `OperationSink::after_response(&WriteObservation)` | Synchronous concrete fallible result; failure stops subsequent writes while preserving already attempted effects | | `PrebuiltOutcome` | Exact module/package/service identities, optional exact version, last confirmed phase, mutation-attempted flag and per-phase known/unknown results on success and error; no inferred active-version success | @@ -44,7 +44,7 @@ E1 follows the owning repository's AGENTS.md for concrete error conventions and - [ ] Write fake adapter tests for unsupported adapter/action, payload/context mismatch, mutation attempts before authorization, unit-success ambiguity and failed durability sink, truncated payload, pre-send failure versus sent/no-response uncertainty and missing exact version. - [ ] Run upstream targeted prebuilt tests; expect missing API. - [ ] Define private retained payload identity, exact service/base version, action/mode, logical/physical mappings, staging selector identity and sharing policy. Return structured observed IDs/phase states and error outcomes with known/unknown state, not an active-version lookup. -- [ ] Define explicit phase enum and WriteIntent/WriteObservation with stable operation/sequence IDs; before_write must synchronously succeed before every provider/config mutation. Journal persistence errors halt further writes, even after earlier successful writes. +- [ ] Define explicit phase enum and WriteIntent/WriteObservation with stable operation/sequence IDs; before_write MUST synchronously succeed before every provider/config mutation. Journal persistence errors halt further writes, even after earlier successful writes. - [ ] Approve exact-source Fastly unsupported defaults and review public API/docs/error types. Commit `Add a typed prebuilt Fastly operation interface`. ### Task E2: Prepare byte-preserving packages from retained inputs @@ -114,7 +114,7 @@ E1 follows the owning repository's AGENTS.md for concrete error conventions and - [ ] Execute fake-provider coverage for all writes, crash boundaries, context mismatches, locks and unknown results; verify no provider writes occur on failed official evidence. - [ ] Before enabling integrated production transport, execute end-to-end `ts deploy --release` production qualification on a separately authorized disposable Fastly service using an independently verified production-capable release. Assert canonical module bytes, explicit clone/upload to the exact returned draft, context validation before activation, the exact activated version, durable per-write observations and receipt/restart behavior. The primary manual compute-deploy rehearsal is a different transport and cannot satisfy this gate. -- [ ] First qualify direct upstream prepared-byte staging on a separately authorized disposable Fastly service, using source-built fixtures and no claim of an official staging-capable release. Compare actual module/context and shared/isolated bindings, plus separate health/rollback commands. The initial production-only CLI/release must continue to reject staging before writes. +- [ ] First qualify direct upstream prepared-byte staging on a separately authorized disposable Fastly service, using source-built fixtures and no claim of an official staging-capable release. Compare actual module/context and shared/isolated bindings, plus separate health/rollback commands. The initial production-only CLI/release MUST continue to reject staging before writes. - [ ] After that transport proof, approve the cohort and embedded policy capability change, then publish a separately authorized new staging-enabled release through the primary pipeline. Use the independently trusted verifier to qualify end-to-end `ts deploy --release --staging` against that new release. Test that historical production-only releases still reject staging; no downloaded metadata or test policy override enables it in the production CLI. - [ ] Qualify durable recovery across process restart and uncertain responses without exposing secrets. An irreducibly unknown clone remains a documented manual resolution case, not a passed exactly-once guarantee. - [ ] Record the already reviewed/qualified staging capability and historical producer/consumer compatibility explicitly. Document receipt limits and the actual loaded snapshot boundary for #161; unsigned operator observations are not publisher authority or remote execution proof. diff --git a/docs/superpowers/specs/2026-01-15-attestation-design.md b/docs/superpowers/specs/2026-01-15-attestation-design.md index c333ca8b03..bec21f80b0 100644 --- a/docs/superpowers/specs/2026-01-15-attestation-design.md +++ b/docs/superpowers/specs/2026-01-15-attestation-design.md @@ -563,7 +563,7 @@ Sigstore uses DSSE envelopes carrying in-toto Statements. The runtime attestatio "attested_by": { "identity": "iab-tech-lab", "oidc_issuer": "https://token.actions.githubusercontent.com", - "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" + "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/v1.5.0" } }, "integrations": [ @@ -1164,6 +1164,12 @@ This section provides detailed code examples and implementation patterns for the ### GitHub Artifact Attestations (CI/CD) +This binary workflow example is historical and superseded by the [October +release contract](2026-10-09-1235-release-attestation-design.md#r-sign). Its +current certificate SAN names `release-build.yml`; the caller `release.yml` +does not satisfy that signer identity. Runtime/configuration and publisher +proposals below remain historical #161 material. + Use [GitHub Artifact Attestations](https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) to create SLSA Build Level 2/3 provenance for the WASM binary. ```yaml diff --git a/docs/superpowers/specs/2026-09-25-git-version-header-design.md b/docs/superpowers/specs/2026-09-25-git-version-header-design.md index eca379c014..4213752593 100644 --- a/docs/superpowers/specs/2026-09-25-git-version-header-design.md +++ b/docs/superpowers/specs/2026-09-25-git-version-header-design.md @@ -23,6 +23,8 @@ monitors need to see from a response that production runs a release, and which one. Staging keeps deploying branches and commits, so it needs to be identifiable too. +> **Release-scope update:** Merged #1213's tag/branch/override examples describe development source builds. The [October release contract](2026-10-09-1235-release-attestation-design.md#r-facts) adds strict `TRUSTED_SERVER__RELEASE_BUILD=1`: canonical attested builds require the approved full source commit, reject Git fallback, and return that commit in `x-ts-version`. The external release tag stays in the signed index; header values do not prove attested bytes. Development goals and acceptance examples below remain applicable outside release mode. + ## Goals - Report the deployed git version in `x-ts-version`: the tag when the deployed diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md index c54f780dfc..b762c53026 100644 --- a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -6,9 +6,11 @@ **Status:** Draft for maintainer review. As directed on 2026-10-13, this specification, its implementation plans and implementation share PR #1262 on `spec/1235-release-attestation`; no separate implementation PR is opened. Contract/code approval is required before merge and all selected qualification gates remain required before official release enablement. Requirements below describe proposed behavior, not an existing release pipeline. The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) records measured prototypes, missing implementations and unresolved hosted/provider qualifications; document review alone is not feasibility proof. -**Source baseline:** PR #1262 at `7f6056bf83eb96aa4167efca64d43280299fba73`. The implementation MUST reconcile [#1214](https://github.com/IABTechLab/trusted-server/pull/1214) (isolated JS builds) and [#1213](https://github.com/IABTechLab/trusted-server/pull/1213) (source-version headers) before release enablement; neither is assumed merged at this baseline. +**Producer implementation constraint (2026-10-14):** Introduced release producer tooling MUST use the small domain-specific host-only `crates/trusted-server-release` Rust 2024/1.95.0 workspace member, excluded from default members and independent of core/CLI/JS crates. No introduced Python tooling is permitted. Existing Node JS collection and thin POSIX wrappers remain; native approved-source Rust bootstrap is independently authenticated and supplies no official binary proof for itself. `ts` remains the consumer, and the [migration addendum](2026-10-14-release-tooling-rust-migration.md) preserves every qualification, acquisition and evidence gate. The producer is not a fifth canonical asset or a generic attestation/publisher abstraction. -**Related work:** [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#1179](https://github.com/IABTechLab/trusted-server/pull/1179), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), [#1084](https://github.com/IABTechLab/trusted-server/pull/1084), and [#167](https://github.com/IABTechLab/trusted-server/pull/167). +**Implementation baseline:** `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52` includes #1213 and #1214, both merged on 2026-10-12. Release implementation extends their source-version and isolated JS/prebuilt mechanisms. The feasibility audit used the earlier `7f6056bf83eb96aa4167efca64d43280299fba73` snapshot with then-open PR overlays; those historical probes are not current release qualification. + +**Related work:** [#1213](https://github.com/IABTechLab/trusted-server/pull/1213), [#1214](https://github.com/IABTechLab/trusted-server/pull/1214), [#161](https://github.com/IABTechLab/trusted-server/issues/161), [#1179](https://github.com/IABTechLab/trusted-server/pull/1179), [#799](https://github.com/IABTechLab/trusted-server/pull/799), [#1045](https://github.com/IABTechLab/trusted-server/pull/1045), [#1084](https://github.com/IABTechLab/trusted-server/pull/1084), and [#167](https://github.com/IABTechLab/trusted-server/pull/167). ## Problem @@ -34,6 +36,12 @@ Offline build-evidence verification is documented here, but cannot establish fre MUST, MUST NOT, SHOULD and MAY have the meanings in BCP 14, only when capitalized. Each numbered requirement is normative. Rationale, rollout ordering, informational records and deferred investigations do not add implicit acceptance gates. +Canonical **Build A** is the selected output proposed for publication. **Build B** is a fresh comparison build: complete-byte equality is mandatory for Fastly and the shared JS set, while native CLI B reports a bounded measurement. + +“Retained” specifies its object: an owned byte copy held by the verifier; JS module bytes observed in the final executable; archived reconstruction inputs (deferred); or durable deployment recovery state. These claims are distinct. + +The **declared insensitive envelope** lists recipe inputs qualified to vary without changing output bytes. A **terminal outcome** records `matched`, `mismatched`, `failed` or `unavailable` before the deadline. An **action-scoped verified value** holds verified bytes and approval for one supported operation and context; it grants no unrelated deployment authority. Requirement headings below provide stable R-ID links; T-1 through T-13 identify the test families in the traceability table. + | Owner | Responsibility | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Release tooling | Controlled builds, composition evidence, required comparison gates, signing and immutable publication | @@ -54,21 +62,23 @@ flowchart TD ## 1. Threat model and claim limits -| Threat | Capability | Defending checks | Residual trust or limitation | -| ---------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| TH-1 Asset/mirror tampering | Replace executable, index or companions | R-INDEX, R-EVIDENCE, R-PUBLICATION: signed digests, exact subjects and release membership | Authentic malicious source is still malicious | -| TH-2 Malicious evidence transfer | Substitute roots, policy, approved commit or offline pins | R-POLICY, R-BOOTSTRAP, R-OFFLINE: obtain trust inputs independently of supplied evidence | A compromised trusted transfer channel defeats its pins | -| TH-3 Compromised dependency/build script | Generate altered code or fetch undeclared inputs | R-INPUTS, R-JS, R-SBOM, R-BUILD: locked materials, controlled environment, observed/declarative coverage, no build credentials | Locks authenticate origin, not safety; same malicious input can reproduce | -| TH-4 Rogue tag pusher | Tag unapproved source or bypass checks | R-SOURCE, R-PUBLISH: branch/source authorization, same-commit validation, restricted tag authority | Authorized maintainers and bypass actors remain trusted | -| TH-5 Compromised runner image/compiler | Produce altered bytes and consistent A/B results | R-BUILD, R-SIGN: checked environment identities, hosted runners, separated signing permissions | A/B is project-controlled repeatability, not independent builder trust | -| TH-6 Compromised operator host | Replace gh/ts, retained files or runtime; lie about deployment | R-BOOTSTRAP, R-DEPLOY, R-RECOVERY: trusted bootstrap, retained handles, pre-upload equality, recorded observations | Local checks cannot establish honest remote execution against this host | -| TH-7 GitHub compromise | Misstate workflow identity, source or immutable membership | R-POLICY, R-EVIDENCE, R-PUBLICATION: constrained identities and Sigstore evidence | GitHub/OIDC, applicable roots and release authority are trust roots; this design does not defeat their compromise | +| Threat | Capability | Defending checks | Residual trust or limitation | +| ---------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| TH-1 Asset/mirror tampering | Replace executable, index or companions | R-INDEX, R-EVIDENCE, R-PUBLICATION: signed digests, exact subjects and release membership; negative proof families T-8, T-9 | Authentic malicious source is still malicious | +| TH-2 Malicious evidence transfer | Substitute roots, policy, approved commit or offline pins | R-POLICY, R-BOOTSTRAP, R-OFFLINE: obtain trust inputs independently of supplied evidence; negative proof families T-8, T-9, T-10 | A compromised trusted transfer channel defeats its pins | +| TH-3 Compromised dependency/build script | Generate altered code or fetch undeclared inputs | R-INPUTS, R-JS, R-SBOM, R-BUILD: locked materials, controlled environment, observed/declarative coverage, no build credentials; negative proof families T-2, T-3, T-4, T-5 | Locks authenticate origin, not safety; same malicious input can reproduce | +| TH-4 Rogue tag pusher | Tag unapproved source or bypass checks | R-SOURCE, R-PUBLISH: branch/source authorization, same-commit validation, restricted tag authority; negative proof families T-7 | Authorized maintainers and bypass actors remain trusted | +| TH-5 Compromised runner image/compiler | Produce altered bytes and consistent A/B results | R-BUILD, R-SIGN: checked environment identities, hosted runners, separated signing permissions; negative proof families T-4, T-7 | A/B is project-controlled repeatability, not independent builder trust | +| TH-6 Compromised operator host | Replace gh/ts, retained files or runtime; lie about deployment | R-BOOTSTRAP, R-DEPLOY, R-RECOVERY: trusted bootstrap, retained handles, pre-upload equality, recorded observations; negative proof families T-9, T-11, T-12 | Local checks cannot establish honest remote execution against this host | +| TH-7 GitHub compromise | Misstate workflow identity, source or immutable membership | R-POLICY, R-EVIDENCE, R-PUBLICATION: constrained identities and Sigstore evidence; negative proof families T-7, T-8, T-9 | GitHub/OIDC, applicable roots and release authority are trust roots; this design does not defeat their compromise | Build provenance establishes that the constrained workflow produced a digest. Immutable release evidence establishes publication membership. A/B records measured equality under declared conditions. None proves absence of vulnerabilities, truthful configuration claims, publisher/domain authority or that submitted bytes execute at every edge location. SLSA Build Level 2 is an implementation target; a local reusable workflow alone does not establish Level 3. ## 2. Selected artifacts and assurance profiles -**R-COHORT.** `scripts/release/release-cohort.json` MUST select the complete artifact set and required assurance profile before jobs execute. The first cohort is `phase1-fastly-cli`. Every selected artifact MUST have successful canonical Build A, build provenance, SBOM, composition validation, shared JS input equality, artifact-specific retained-JS coverage and immutable publication. A failure of any selected requirement MUST block publication; the workflow MUST NOT shrink the set or lower a profile after failure. +### R-COHORT + +`scripts/release/release-cohort.json` MUST select the complete artifact set and required assurance profile before jobs execute. The first cohort is `phase1-fastly-cli`. Every selected artifact MUST have successful canonical Build A, build provenance, SBOM, composition validation, shared JS input equality, artifact-specific retained-JS coverage and immutable publication. A failure of any selected requirement MUST block publication; the workflow MUST NOT shrink the set or lower a profile after failure. | Asset | Package / target | Required profile | Compatibility qualification | | ----------------------------- | ------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------ | @@ -77,17 +87,29 @@ Build provenance establishes that the constrained workflow produced a digest. Im | `ts-aarch64-apple-darwin` | `trusted-server-cli` / `aarch64-apple-darwin` | `provenance-sbom` | macOS loader deployment target 13.0; initial release qualification on hosted macOS 15 only | | `ts-x86_64-apple-darwin` | `trusted-server-cli` / `x86_64-apple-darwin` | `provenance-sbom` | Same loader floor and macOS 15 qualification; actual Intel startup fixture required | -**R-PLATFORM.** Recipes MUST use explicit packages/targets and recorded effective features/release profile. A pinned Linux builder with glibc no newer than 2.34 MUST establish the GNU compatibility floor; scheduling it on Ubuntu 24.04 does not establish a 2.39 floor. Native link inspection MUST validate the actual ELF GLIBC symbol ceiling and reject undeclared libraries and runner-only absolute paths. macOS MUST use a recorded matching-host recipe initially; a reviewed ARM-to-Intel cross recipe MAY replace it only after full CLI build and Intel execution qualification. `MACOSX_DEPLOYMENT_TARGET=13.0` MUST be explicit. It is a loader/ABI floor, not a claim that macOS 13/14 execution is qualified. The initial required startup matrix is macOS 15 on ARM64 and Intel. macOS 13 hosted runners have retired and macOS 14 is retiring; neither is a first-release test gate. Broader runtime support requires separately available execution hosts and evidence. Schema `compatibility.minimum_version` records the loader/libc floor; `build-evidence.compatibility_tests` records the actually qualified OS, architecture and outcome. Intel execution remains mandatory on an Intel host. `macos-15-intel` availability MUST NOT be assumed indefinitely: qualify a replacement execution host or review removal of the target before that runner becomes unavailable. Failure to establish these baselines blocks this cohort before its first tag; it does not justify advertising untested compatibility. A static musl distribution is an alternative requiring a reviewed cohort/target change. +### R-PLATFORM + +Recipes MUST use explicit packages/targets and recorded effective features/release profile. A pinned Linux builder with glibc no newer than 2.34 MUST establish the GNU compatibility floor; scheduling it on Ubuntu 24.04 does not establish a 2.39 floor. Native link inspection MUST validate the actual ELF GLIBC symbol ceiling and reject undeclared libraries and runner-only absolute paths. + +macOS MUST use a recorded matching-host recipe initially; a reviewed ARM-to-Intel cross recipe MAY replace it only after full CLI build and Intel execution qualification. `MACOSX_DEPLOYMENT_TARGET=13.0` MUST be explicit. It is a loader/ABI floor, not a claim that macOS 13/14 execution is qualified. -**R-VARIANT.** The first Fastly artifact MUST retain the existing default, single-request production variant, with `reusable-sandbox` disabled. This is a deliberate conservative shipping choice pending deployed reuse qualification, not a claim that reuse is publisher configuration. There is no Fastly `test` feature. A second attested `reusable-sandbox` variant, or promotion to the default after the four runtime bounds and reuse lifecycle pass qualification, requires a reviewed cohort/policy change. Operators requesting reuse MUST receive an explicit unsupported-variant diagnostic rather than be told an unattested custom build is official. +The initial required startup matrix is macOS 15 on ARM64 and Intel. macOS 13 hosted runners have retired and macOS 14 is retiring; neither is a first-release test gate. Broader runtime support requires separately available execution hosts and evidence. Schema `compatibility.minimum_version` records the loader/libc floor; `build-evidence.compatibility_tests` records the actually qualified OS, architecture and outcome. Intel execution remains mandatory on an Intel host. `macos-15-intel` availability MUST NOT be assumed indefinitely: qualify a replacement execution host or review removal of the target before that runner becomes unavailable. Failure to establish these baselines blocks this cohort before its first tag; it does not justify advertising untested compatibility. A static musl distribution is an alternative requiring a reviewed cohort/target change. + +### R-VARIANT + +The first Fastly artifact MUST retain the existing default, single-request production variant, with `reusable-sandbox` disabled. This is a deliberate conservative shipping choice pending deployed reuse qualification, not a claim that reuse is publisher configuration. There is no Fastly `test` feature. A second attested `reusable-sandbox` variant, or promotion to the default after the four runtime bounds and reuse lifecycle pass qualification, requires a reviewed cohort/policy change. Operators requesting reuse MUST receive an explicit unsupported-variant diagnostic rather than be told an unattested custom build is official. Native CLI A/B outcome is reported as `matched`, `mismatched`, `failed` or `unavailable`. Its measurement job MUST reach a bounded terminal outcome, but equality is not required by `provenance-sbom`. A later change can promote an individual target after qualification. No profile in this contract claims separately bootstrapped operator reconstruction. ## 3. Configuration independence and complete build inputs -**R-CONFIG.** No configuration may be compiled into release binaries. Publisher domains, account/service IDs, physical bindings, active settings, secrets, selected integrations, configuration revisions and deployment-variable permission policy MUST enter at runtime. All artifacts MUST use the tracked repository `edgezero.toml` unchanged. Logical store identifiers, product defaults and the fictional CLI scaffold are source-defined product inputs shared by all operators. The scaffold MUST NOT be an active-config fallback. The permission work in #1045/#1084 MUST resolve any conflicting embedded variable-policy design before release enablement; this contract does not choose its distribution protocol. +### R-CONFIG + +Operator configuration MUST NOT be compiled into release binaries. Publisher domains, account/service IDs, physical bindings, active settings, secrets, selected integrations, configuration revisions and deployment-variable permission policy MUST enter at runtime. All artifacts MUST use the tracked repository `edgezero.toml` unchanged. Logical store identifiers, product defaults and the fictional CLI scaffold are source-defined product inputs shared by all operators. The scaffold MUST NOT be an active-config fallback. The permission work in #1045/#1084 MUST resolve any conflicting embedded variable-policy design before release enablement; this contract does not choose its distribution protocol. -**R-INPUTS.** Build evidence MUST record every input class below, with selected identities/digests and roles. Introducing an input requires updating the inventory in the same implementation PR. Generic SLSA provenance is not assumed to enumerate these transitively. +### R-INPUTS + +Build evidence MUST record every input class below, with selected identities/digests and roles. Introducing an input requires updating the inventory in the same implementation PR. Generic SLSA provenance is not assumed to enumerate these transitively. | Input | Scope and control | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -108,29 +130,49 @@ Native CLI A/B outcome is reported as `matched`, `mismatched`, `failed` or `unav `TEMPLATE_BUILD_DIGEST` includes complete raw manifest bytes and ordinary non-editor core source files, including untracked files. It is a template/cache compatibility input, not an executable digest or provenance. Fresh checkouts MUST contain only expected source/materials; generated outputs MUST remain outside source-only directories. Before/after checks MUST detect tracked-input changes and unexpected source-directory files. -**R-ENV.** Release preflight MUST reject the presence of `TSJS_SKIP_BUILD` with any value before sanitization. It MUST remove inherited operator `TRUSTED_SERVER__*` and `EDGEZERO__*`, then inject only validated release build facts (R-FACTS). Compiler/Cargo profile/linker flags, wrappers, Cargo home/configuration layers, native build selectors, `NODE_OPTIONS`, npm/Vite overrides and environment-file loading MUST be selected or rejected by the recipe. No ambient compiler wrapper is allowed. Release Vite mode MUST be explicit. Build jobs MUST have no provider/signing credentials. Wall-clock times, run IDs, publisher identity and final checksum MUST NOT be embedded. `SOURCE_DATE_EPOCH` MUST derive from the approved commit's committer timestamp. +### R-ENV + +Release preflight MUST audit inherited variables before clearing them and reject `TSJS_SKIP_BUILD` with any value. It MUST NOT evade this audit by starting under an empty environment. Hosted/setup exports such as `RUSTFLAGS` or `VCPKG_INSTALLATION_ROOT` MUST be rejected unless their exact values and tool/path identities are explicitly reviewed and qualified. + +After auditing, the recipe MUST remove inherited operator `TRUSTED_SERVER__*` and `EDGEZERO__*`, sanitize the child environment, and reinject validated `TRUSTED_SERVER__RELEASE_BUILD=1` and the approved `TRUSTED_SERVER__GIT_VERSION`. Cargo supplies target/package facts and the owning product derives the typed variant (R-FACTS). Compiler/Cargo profile/linker flags, wrappers, Cargo home/configuration layers, native build selectors, `NODE_OPTIONS`, npm/Vite overrides and environment-file loading MUST be selected or rejected by the recipe. No ambient compiler wrapper is allowed. Release Vite mode MUST be explicit. Build jobs MUST have no provider/signing credentials. Wall-clock times, run IDs, publisher identity and final checksum MUST NOT be embedded. `SOURCE_DATE_EPOCH` MUST derive from the approved commit's committer timestamp. The CLI's compiled developer checkout fallback (`CARGO_MANIFEST_DIR`) MUST be disabled for official releases; explicit/current-checkout discovery can remain for documented source-dependent commands. Rust remapping does not rewrite arbitrary `env!` strings. CLI help, version and `config init` MUST run outside a checkout. ## 4. Shared JS build and artifact repeatability -**R-JS.** Release work MUST extend #1214's private output, expected-module validation, checked test failures and `TSJS_PREBUILT_DIR`; it MUST NOT add a parallel `TSJS_RELEASE_BUILD` fallback mode. One release-wide JS job MUST independently run two fresh locked installations/builds with pinned Node/npm. Both MUST reject any skip flag, missing npm, failed `npm ci`, requested-test failure, missing/empty/extra modules or changed source. Release validation MUST run JS tests even if no local test flag was requested. Each build owns its installation/output directory and includes required build dependencies. +### R-JS -The two JS runs MUST compare final bytes for core plus every sorted tracked integration entry point. Only on equality MAY the job publish its Build A bundles and `tsjs-inventory.json`. That version-1 inventory MUST record sorted module IDs, filename, size, SHA-256, contributing source/package identities and tool/lifecycle identities. Each Rust build MUST consume these current-run bundles through `TSJS_PREBUILT_DIR` and a checked expected inventory, validate the exact set/digests of bytes copied into `OUT_DIR`, and run no npm. The first cohort MUST declare only `fastly-production` for Fastly until the integrated staging milestone is qualified. Adding `fastly-staging` requires a reviewed cohort and consumer-policy update. An artifact capability MUST intersect with an implemented local transport; downloaded metadata cannot enable an unavailable operation. The collector MUST verify identical prepared JS input inventories across all builds that compile the JS crate. Each artifact MUST separately record which module bytes remain in its final file. Claimed retained modules MUST match the shared inventory; a CLI executable MAY retain none after linking. Copies into `OUT_DIR` are build inputs and MUST NOT alone be called final embedded code, nor force unused JS into the CLI to satisfy an equality check. +Release work MUST extend #1214's private output, expected-module validation, checked test failures and `TSJS_PREBUILT_DIR`; it MUST NOT add a parallel `TSJS_RELEASE_BUILD` fallback mode. One release-wide JS job MUST independently run two fresh locked installations/builds with pinned Node/npm. Both MUST reject any skip flag, missing npm, failed `npm ci`, test failure, missing/empty/extra modules or changed source. Both actual A/B Vitest processes MUST unconditionally receive `TRUSTED_SERVER__RELEASE_JS_TESTS=1` and run all registered tests and type checks; a developer opt-out MUST NOT suppress them. Ordinary development skips only the two slow Git collector fixtures by default; ordinary CI opts into the full suite. Each build owns its installation/output directory and includes required build dependencies. + +The two JS runs MUST compare final bytes for core plus every sorted tracked integration entry point. Only on equality MAY the job publish its Build A bundles and `tsjs-inventory.json`. That version-1 inventory MUST record sorted module IDs, filename, size, SHA-256, contributing source/package identities and tool/lifecycle identities. Each Rust build MUST consume these current-run bundles through absolute `TSJS_PREBUILT_DIR` and `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths and a checked expected inventory, validate the exact set/digests of bytes copied into `OUT_DIR`, and run no npm. + +The first cohort MUST declare only `fastly-production` for Fastly until the integrated staging milestone is qualified. Adding `fastly-staging` requires a reviewed cohort and consumer-policy update. An artifact capability MUST intersect with an implemented local transport; downloaded metadata cannot enable an unavailable operation. + +The collector MUST verify identical prepared JS input inventories across all builds that compile the JS crate. Each artifact MUST separately record which module bytes remain in its final file. Claimed retained modules MUST match the shared inventory; a CLI executable MAY retain none after linking. Copies into `OUT_DIR` are build inputs and MUST NOT alone be called final embedded code, nor force unused JS into the CLI to satisfy an equality check. The shared JS set is a declared input to Rust builds. Sharing it means the Rust A/B pair does not independently test npm/Vite; the separate JS A/B gate supplies that evidence. Installed package inventory and permitted lifecycle outputs MUST be recorded separately from lockfile archive integrity. A platform esbuild binary removed from the prepared installation MUST cause the network-denied bundle phase to fail instead of downloading a replacement. -**R-BUILD.** `scripts/release/build.sh` and `scripts/release/release-tools.json` at the approved source commit MUST define the executable recipes and checked tool/environment identities. A downloaded descriptor is informational and MUST NOT supply commands. Rust builds MUST use `--locked` (or `--frozen` after verified source preparation), a fresh target directory and checked dependency material. Compiled Cargo/JS outputs MUST NOT be restored from earlier runs. Verified download caches MAY be reused only after archive checksum/revision validation and fresh extraction. The compile/bundle phases MUST enforce network denial with a qualified OS/container boundary, including build-script/lifecycle downloads; a positive unconfined socket control and denied confined socket probe MUST run for each selected recipe. Cargo/npm offline flags, proxy settings or lockfiles alone do not enforce process network denial. Local macOS `sandbox-exec` socket denial has been demonstrated, but the full hosted native recipe still requires qualification; preparation is a separate network-enabled phase. Required native/system SDK identities and code transformations MUST be checked before compilation. +### R-BUILD + +`scripts/release/build.sh` and `scripts/release/release-tools.json` at the approved source commit MUST define the executable recipes and checked tool/environment identities. A downloaded descriptor is informational and MUST NOT supply commands. Rust builds MUST use `--locked` (or `--frozen` after verified source preparation), a fresh target directory and checked dependency material. Compiled Cargo/JS outputs MUST NOT be restored from earlier runs. Verified download caches MAY be reused only after archive checksum/revision validation and fresh extraction. The compile/bundle phases MUST enforce network denial with a qualified OS/container boundary, including build-script/lifecycle downloads; a positive unconfined socket control and denied confined socket probe MUST run for each selected recipe. Cargo/npm offline flags, proxy settings or lockfiles alone do not enforce process network denial. Historical local macOS `sandbox-exec` prototypes demonstrated socket denial; replacement Rust boundaries and the full hosted native recipe still require qualification; preparation is a separate network-enabled phase. Required native/system SDK identities and code transformations MUST be checked before compilation. + +### R-REPEAT + +Fastly Build A and Build B MUST run in separate fresh hosted jobs with independent source/dependency materialization and Cargo outputs. Each MUST include every final byte transformation from the recipe. Build B MUST differ from A in each qualified insensitive input: checkout directory, Unix user where supported, umask, `TZ` and locale, start time and Cargo job count. `release-tools.json` MUST list the selected A and B values. The rebuild record MUST mark each dimension `varied`, `normalized` when the recipe pins it internally, or `not-varied` when the runner cannot change it. Only `varied` dimensions count as independence evidence. -**R-REPEAT.** Fastly Build A and Build B MUST run in separate fresh hosted jobs with independent source/dependency materialization and Cargo outputs. Each MUST include every final byte transformation from the recipe. B MUST vary external checkout location, user where supported, umask, timezone/locale, start time and concurrency within the recipe's declared insensitive envelope; any normalized internal values MUST be recorded as such rather than advertised as varied. Unsupportable variations MUST be recorded and omitted from independence claims. The collector MUST select outputs by the artifact ID and transfer digest returned by each expected job in the current run, verify artifact REST metadata's current workflow run/repository/source SHA, reject reused A/B IDs, validate source/recipe records, and byte-compare complete final wasm. Producing job/attempt association MUST come from the expected upload-step outputs and trusted workflow dependency topology; the artifact API does not expose an independent job/attempt-origin field. Transfer digest is the Actions archive digest, separately checked from final executable SHA-256; transfer mismatch warnings MUST be promoted to failure. It MUST NOT use artifact names as the sole binding. Missing output, inconsistent checked inputs or unequal bytes MUST block the entire selected release. No comparison-time stripping, normalization or excluded byte regions is allowed. +The collector MUST select outputs by the artifact ID and transfer digest returned by each expected job in the current run, verify artifact REST metadata's current workflow run/repository/source SHA, reject reused A/B IDs, validate source/recipe records, and byte-compare complete final wasm. Producing job/attempt association MUST come from the expected upload-step outputs and trusted workflow dependency topology; the artifact API does not expose an independent job/attempt-origin field. Transfer digest is the Actions archive digest, separately checked from final executable SHA-256; transfer mismatch warnings MUST be promoted to failure. It MUST NOT use artifact names as the sole binding. Missing output, inconsistent checked inputs or unequal bytes MUST block the entire selected release. No comparison-time stripping, normalization or excluded byte regions is allowed. Native CLI comparison MUST use the same fresh-job/result-binding discipline, but a recorded nonmatching or unavailable B result does not block its profile. Build A and its composition/smoke checks remain mandatory. A signed measurement stating failure MUST NOT be relabeled success. -**R-NATIVE.** Native records MUST capture compiler, linker, archiver, SDK/sysroot, native source/libraries and relevant options. Linux SHOULD use a content-derived build ID. A future native repeatability-profile qualification MUST cover canonical rustup paths, OSO debug paths/mtimes, a tested content-derived UUID policy and any required ad-hoc signature. Random/disabled UUIDs or removing signatures only for comparison are not acceptable shortcuts. Developer ID signing, notarization and stapling remain outside scope. These controls support native measurements; they do not imply operator reconstruction is supported. +### R-NATIVE + +Native records MUST capture compiler, linker, archiver, SDK/sysroot, native source/libraries and relevant options. Linux SHOULD use a content-derived build ID. A future native repeatability-profile qualification MUST cover canonical rustup paths, OSO debug paths/mtimes, a tested content-derived UUID policy and any required ad-hoc signature. Random/disabled UUIDs or removing signatures only for comparison are not acceptable shortcuts. Developer ID signing, notarization and stapling remain outside scope. These controls support native measurements; they do not imply operator reconstruction is supported. ## 5. Artifact-specific SPDX composition -**R-SBOM.** A repository-owned generator, `scripts/release/generate-sbom.py`, MUST emit SPDX 2.3 JSON for each canonical executable from selected Cargo JSON build messages, explicit-target Cargo metadata/tree evidence, the JS lockfile and Rollup contribution inventory, plus declared-and-validated native inputs. Syft and a `RUSTC_WRAPPER` are not required. This settles the generator; its fixture proof precedes release enablement. +### R-SBOM + +The repository-owned Rust producer command `trusted-server-release generate-sbom`, implemented in `crates/trusted-server-release/src/spdx.rs`, MUST emit SPDX 2.3 JSON for each canonical executable from selected Cargo JSON build messages, explicit-target Cargo metadata/tree evidence, the JS lockfile and Rollup contribution inventory, plus declared-and-validated native inputs. Syft and a `RUSTC_WRAPPER` are not required. This settles the generator; its fixture proof precedes release enablement. Coverage MUST distinguish observed Rust units, embedded JS contributors, validated native components, external runtime requirements and build materials/tools. With explicit `--target`, Cargo output layout, target kind, package ID and features MUST distinguish host build scripts/proc macros from target units, including a package built in both contexts. Metadata/tree edges MUST be reconciled with actual compiled membership; ambiguous source/unit joins MUST fail. Cargo metadata features are package-level unions, so effective per-unit features MUST come from compiler messages and explicit-target output context. This is a conservative component inventory, not a promise to recover an exact compiler unit dependency DAG from package metadata. Uncompiled dev-only packages MUST NOT be claimed as runtime contributors. Compiled code later eliminated by linking MAY remain in the conservative inventory; exact final function reachability is not claimed. @@ -142,21 +184,33 @@ SPDX root checksum MUST equal the final executable SHA-256. Package IDs MUST inc ## 6. Build facts and #161 handoff -**R-FACTS.** One immutable build-fact mechanism MUST reconcile #1213. The recipe MUST supply `TRUSTED_SERVER__GIT_VERSION` as the approved full source commit after sanitation, and release builds MUST reject missing/malformed values and prohibit ambient `.git` tag/branch fallback. Development fallback from #1213 MAY remain outside release execution and MUST be recorded as a development input. +### R-FACTS + +One immutable build-fact mechanism MUST extend merged #1213. Absence of `TRUSTED_SERVER__RELEASE_BUILD` selects development behavior; any present value other than exactly `1` MUST fail. The trusted recipe MUST set `TRUSTED_SERVER__RELEASE_BUILD=1` after inherited-environment audit and sanitation. -A typed `BuildInfo` MUST derive target from Cargo `TARGET`, package version from `CARGO_PKG_VERSION` and variant/features in the owning adapter/product. Core cannot infer a Fastly feature that is not forwarded to it. Caller-supplied target/variant strings MUST NOT override these derived facts. `TEMPLATE_BUILD_DIGEST` remains separately named. +Release Rust builds MUST receive absolute `TSJS_PREBUILT_DIR` and `TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths, validate the inventory and copied bytes, expose its raw digest as `TRUSTED_SERVER__TSJS_INVENTORY_SHA256`, and MUST NOT run npm. Rust Cargo builds MUST reject any presence of `TSJS_SKIP_BUILD`, including empty, `0` and `1`; Node development build/Vitest entry points preserve its development behavior when release control is absent. -The release tag/version MUST stay external in the release index, not be compiled into bytes. Two tags on one source commit can therefore refer to identical bytes. `ts --version` MUST report package version and source commit; it MUST NOT imply the workspace's current `0.1.0` is the external release version. #1213's `X-TS-Version` represents deterministic source identity in release mode; Fastly service version uses `X-TS-Fastly-Version`. Neither header is a binary checksum or remote execution proof. +The recipe MUST supply `TRUSTED_SERVER__GIT_VERSION` as the approved full source commit after sanitation, and release builds MUST reject missing/malformed values and prohibit ambient `.git` tag/branch fallback. Development fallback from #1213 MAY remain outside release execution and MUST be recorded as a development input. + +A typed `BuildInfo` MUST derive target from Cargo `TARGET` through `TRUSTED_SERVER__BUILD_TARGET`, package version from `CARGO_PKG_VERSION` and variant/features in the owning adapter/product. Core cannot infer a Fastly feature that is not forwarded to it. Caller-supplied target/variant strings MUST NOT override these derived facts. `TEMPLATE_BUILD_DIGEST` remains separately named. + +The release tag/version MUST stay external in the release index, not be compiled into bytes. Two tags on one source commit can therefore refer to identical bytes. `ts --version` MUST label the fields as `ts package= source= target= variant=default`; it MUST NOT imply the workspace's current `0.1.0` is the external release version. `X-TS-Version` is the approved full source commit in release mode. Development retains #1213's override/tag/branch/short-commit display behavior. A development checked inventory validates its own commit syntax and hashes but MUST NOT compare that source to the development display label. Fastly emits owning package/target/variant facts through its existing startup diagnostics; no additional response header or attestation authority is introduced. Fastly service version uses `X-TS-Fastly-Version`. Neither header is a binary checksum or remote execution proof. For the selected default Fastly artifact, settings load per request. Only a future qualified reusable variant retains settings for its sandbox lifetime. #161 MUST distinguish artifact identity, observed service/version and the configuration snapshot actually used; fetching the latest store after a request does not establish that snapshot. Build facts contain no secrets or active configuration. Deployment request-signing keys/JWKS remain unrelated to release signing. ## 7. Source eligibility, workflow and immutable publication -**R-SOURCE.** `.github/workflows/release.yml` MUST accept protected `v*` tag pushes and strict stable/prerelease SemVer. Official tags MUST be lightweight, so the verified tag target is the source commit; annotated tags MUST be rejected, not confused with their peeled commits. Eligible commits MUST be contained in reviewed `main` or a maintainer-approved `rc/YYYYMM` branch recorded in `release-cohort.json`. The `rc/` pattern alone MUST NOT grant authority to a new branch. The tag ruleset and branch policy MUST constrain creation/update/deletion and bypass actors. +### R-SOURCE + +`.github/workflows/release.yml` MUST accept protected `v*` tag pushes and strict stable/prerelease SemVer, including schema-supported build metadata. Official tags MUST be lightweight, so the verified tag target is the source commit; annotated tags MUST be rejected, not confused with their peeled commits. The current reviewed `eligible_branches` is exactly `["main"]`. Eligibility MUST use the actual remote branch tip and commit ancestry, with governance validation of active branch/tag rulesets and bypass actors. Local remote-ref lookalikes MUST NOT supply that authority. + +A future exact `rc/YYYYMM` branch requires maintainer review of its tracked cohort entry and governance inclusion before use, plus actual remote-tip ancestry and ruleset/bypass validation. A regex match or a candidate checkout that lists its own branch MUST NOT approve itself. The tag ruleset and branch policy MUST constrain creation/update/deletion and bypass actors. Eligibility MUST run trusted release validation at the exact tag commit for every required AGENTS.md gate. `test.yml`/`format.yml` currently lack push coverage for RC branches, so successful push-to-main checks MUST NOT be the only path. Implementation MUST expose reviewed `workflow_call` validation jobs or equivalent explicit same-commit release jobs. It MUST enumerate required jobs and reject skipped/cancelled/PR-only/fork results. A tag immediately after merge MUST wait for these release validation jobs with a checked deadline (initially 90 minutes), then fail with a rerun diagnostic on timeout. It MUST NOT publish using an unrelated passing check name. -**R-SIGN.** The local `.github/workflows/release-build.yml` at the caller's source revision MUST own build orchestration, collector validation and file attestations. Build/lifecycle execution MUST occur without OIDC/attestation writes. Signing MUST consume only validated current-run job outputs and MUST NOT compile untrusted source. The authenticated build boundary is the complete workflow invocation, not just the collector. No caller-supplied command, script, arbitrary path/repository or provenance predicate is accepted. All actions MUST be SHA-pinned; tools MUST have exact version/digest pins in `scripts/release/release-tools.json`. +### R-SIGN + +The local `.github/workflows/release-build.yml` at the caller's source revision MUST own build orchestration, collector validation and file attestations. Build/lifecycle execution MUST occur without OIDC/attestation writes. Signing MUST consume only validated current-run job outputs and MUST NOT compile untrusted source. The authenticated build boundary is the complete workflow invocation, not just the collector. No caller-supplied command, script, arbitrary path/repository or provenance predicate is accepted. All actions MUST be SHA-pinned; tools MUST have exact version/digest pins in `scripts/release/release-tools.json`. | Job | Permissions | | ----------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -166,7 +220,9 @@ Eligibility MUST run trusted release validation at the exact tag commit for ever File attestations MUST NOT request `artifact-metadata: write`; no container storage record is being created. Each canonical artifact MUST get two pinned `actions/attest` invocations: build provenance with the executable and metadata subjects, then SPDX mode with the executable subject and published SBOM as predicate. `sbom-path` does not also create separate build provenance. The collector MUST verify all bundles before index signing. -**R-PUBLISH.** Maintainers MUST enable immutable releases and active tag rules before first publication and record approval of release authority. No `RELEASE_SETTINGS_READ_TOKEN` is introduced. A tag-scoped concurrency group MUST serialize publication without cancelling a publishing run. The publication job MUST create/resume a matching draft, upload the complete asset set, re-download and verify it, publish, then read `immutable == true` using `GITHUB_TOKEN` and verify the GitHub release attestation. It MUST NOT report success before that final check. +### R-PUBLISH + +Maintainers MUST enable immutable releases and active tag rules before first publication and record approval of release authority. No `RELEASE_SETTINGS_READ_TOKEN` is introduced. A tag-scoped concurrency group MUST serialize publication without cancelling a publishing run. The publication job MUST create/resume a matching draft, upload the complete asset set, re-download and verify it, publish, then read `immutable == true` using `GITHUB_TOKEN` and verify the GitHub release attestation. It MUST NOT report success before that final check. If a release is confirmed mutable, the job MUST mark publication failed, remove that still-mutable release if authorized by the workflow's release-write role, and raise a release incident with the result. An indeterminate response MUST trigger reconciliation, not blind deletion. This is detection/recovery, not a guarantee that a transient mutable publication was never visible; consumers require immutable signed membership and therefore reject it. Published immutable releases MUST NOT be overwritten or retagged. Defects require an advisory and new version. @@ -174,7 +230,13 @@ A rerun MUST refuse an already published tag. A partial draft MAY resume only wh ## 8. Distribution schema and evidence graph -**R-INDEX.** The normative [release-index v1 JSON Schema](schemas/release-index-v1.schema.json) MUST be evaluated with the JSON Schema Draft 2020-12 dialect and defines closed kinds, roles, targets, variants, profiles and capabilities. Producer and consumer MUST additionally require `recipe_source_commit` and `cohort_source_commit` to equal the independently approved `source_commit`, and enforce exact cohort membership, unique safe asset names, expected package/target/variant/profile combinations and equality of each size/digest with actual bytes. Unknown schema versions, duplicate JSON keys, duplicate/conflicting identities, traversal names, unknown capabilities and mismatched JS inventories MUST fail. No executable asset is an archive in this cohort. +### R-INDEX + +The normative [release-index v1 JSON Schema](schemas/release-index-v1.schema.json) MUST be evaluated with the JSON Schema Draft 2020-12 dialect and defines closed kinds, roles, targets, variants, profiles and capabilities. The governing schema raw bytes MUST remain unchanged absent an explicit reviewed schema amendment. Its retained `$comment` pins Draft 2020-12; Rust `KnownSchema` explicitly selects that dialect with offline evaluation, and committed positive/negative tests exercise the actual governing schema. + +Producer and consumer MUST additionally require `recipe_source_commit` and `cohort_source_commit` to equal the independently approved `source_commit`, and enforce exact cohort membership, unique safe asset names, expected package/target/variant/profile combinations and equality of each size/digest with actual bytes. These repeated v1 source/recipe/cohort equalities intentionally expose the joins; a different revision requires a future reviewed amendment. Structural v1 compatibility may accept `fastly-staging`, but current producer/consumer policy and cohort MUST semantically reject it until reviewed enablement. Tasks 3, 13 and 17 MUST test that rejection. SemVer metadata and schema fields MUST NOT be narrowed without review. + +Unknown schema versions, duplicate JSON keys, duplicate/conflicting identities, traversal names, unknown capabilities and mismatched JS inventories MUST fail. No executable asset is an archive in this cohort. Release-wide assets are `release-index.json`, `release-index.provenance.sigstore.json`, `tsjs-inventory.json` and `SHA256SUMS`. Each executable has `.spdx.json`, `.provenance.sigstore.json`, `.sbom.sigstore.json`, `.build-evidence.json`, `.build-recipe.json` and `.rebuild-check.json`. The index MUST name and digest companions and the JS inventory. It MUST NOT include its own digest/bundle; those identities are external. `SHA256SUMS` MUST cover every other published asset, never itself, and is only a convenience until authenticated by release membership. No `release-inputs.tar.zst` is promised here. @@ -190,7 +252,9 @@ Release-wide assets are `release-index.json`, `release-index.provenance.sigstore These JSON files MUST use UTF-8, reject duplicate keys/nonfinite numbers and use safe relative asset names. Digests bind exact distributed file bytes; verifiers MUST NOT reserialize before hashing. Arrays with set semantics MUST have deterministic order. Producer tooling and the Trusted Server wrapper MUST implement strict parsed SBOM-document equality (object key order ignored, array order significant, duplicate keys rejected); it is not substituted for file-byte digest checks. gh verifies a predicate and returns it; it supplies no SBOM-file comparison operation. Duplicate keys MUST be rejected before signing as well as before consumer comparison. Informational descriptors/measurements remain authenticated companions, but consumers MUST NOT execute them or infer independent trust from their contents. -**R-EVIDENCE.** Every successful verification MUST establish all equalities below: +### R-EVIDENCE + +Every successful verification MUST establish all equalities below: | Edge | Required equality/check | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -207,36 +271,48 @@ The recipe and rebuild records are authenticated for audit, but consumers do not ## 9. Fixed verifier policy, bootstrap and online commands -**R-POLICY.** `crates/trusted-server-cli/release-policy.json` MUST be a tracked, embedded policy with `policy_version` integer and an external SHA-256 of its exact bytes. Only a reviewed CLI release MAY update it. Index `policy_version`/`policy_sha256` identify the producer policy tracked at `source_commit`, not a downloadable replacement for the consumer policy. The consumer MUST match that pair to independently trusted source-policy bytes, then accept either its own policy version and externally computed exact-byte digest, or a differing producer identity in a separately embedded reviewed historical allowlist. The current policy MUST NOT contain its own SHA-256 or be required to list itself; this avoids a self-hash cycle. Its own acceptance constraints always remain in force. New CLI policies MUST explicitly retain or retire historical identities; the index cannot pick its verifier policy by declaring a hash. Downloaded indexes, operator configuration, roots or arbitrary forwarded gh flags MUST NOT redefine it. The policy MUST fix GitHub host/repository numeric IDs, certificate identities/issuer, source/builder/caller semantics, supported artifact/profile/action combinations and resource bounds. Automation MUST also approve a source commit/tag; an index cannot approve its own source. +### R-POLICY + +`crates/trusted-server-cli/release-policy.json` MUST be a tracked, embedded policy with `policy_version` integer and an external SHA-256 of its exact bytes. Only a reviewed CLI release MAY update it. Index `policy_version`/`policy_sha256` identify the producer policy tracked at `source_commit`, not a downloadable replacement for the consumer policy. The consumer MUST match that pair to independently trusted source-policy bytes, then accept either its own policy version and externally computed exact-byte digest, or a differing producer identity in a separately embedded reviewed historical allowlist. The current policy MUST NOT contain its own SHA-256 or be required to list itself; this avoids a self-hash cycle. Its own acceptance constraints always remain in force. New CLI policies MUST explicitly retain or retire historical identities; the index cannot pick its verifier policy by declaring a hash. Downloaded indexes, operator configuration, roots or arbitrary forwarded gh flags MUST NOT redefine it. The policy MUST fix GitHub host/repository numeric IDs, certificate identities/issuer, source/builder/caller semantics, supported artifact/profile/action combinations and resource bounds. Automation MUST also approve a source commit/tag; an index cannot approve its own source. + +Task 17 MUST obtain producer-policy bytes from an independently approved source snapshot or the fixed official GitHub Contents API endpoint `https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref=`, using `Accept: application/vnd.github.raw+json`. The ref MUST come from independent full-commit approval, never the index. Fetching MUST use an explicit read-only credential, the 1 MiB policy bound, and the overall verification deadline. Arbitrary hosts/paths and redirects that change that origin/path or forward credentials MUST be rejected. The producer hash/version MUST be computed from those approved-source bytes before applying the consumer's own compiled constraints. Offline checks require an independently transferred approved source-policy snapshot and establish build proof only, never official deployment. [GitHub Contents API](https://docs.github.com/en/rest/repos/contents#get-repository-content) documents raw media and explicit commit refs. The initial verified tool contract is GitHub CLI **2.97.0** and `actions/attest` **4.2.2** (SHA `1e69f48acb82d1966a394da916b4c1698aa569d6`). Implementation MUST record platform gh binary digests in `release-tools.json` and qualify this exact version. Other versions MUST fail with a compatibility diagnostic until a reviewed policy/tools update qualifies them. This is an exact supported version, not an unspecified minimum. Checking the version string is not proof that an executable is trustworthy. | Authenticated expectation | Required value | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Repository / owner IDs | `950860906` / `82903538`, with `IABTechLab/trusted-server` on `github.com` | | Certificate SAN / provenance builder ID | `https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/` | | Certificate issuer | `https://token.actions.githubusercontent.com` | | Source ref / source digest | Exact `refs/tags/` / independently approved 40-character commit | | Build signer digest | Same source commit for this same-revision local reusable workflow | -| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | | +| Provenance predicate / build type | `https://slsa.dev/provenance/v1` / `https://actions.github.io/buildtypes/workflow/v1` | | Caller / external parameters | Exactly `workflow: {ref: "refs/tags/", repository: "https://github.com/IABTechLab/trusted-server", path: ".github/workflows/release.yml"}`; no arbitrary inputs/commands | -| Source resolved dependency | `git+https://github.com/IABTechLab/trusted-server@refs/tags/` with `gitCommit` equal to the approved commit | +| Source resolved dependency | `git+https://github.com/IABTechLab/trusted-server@refs/tags/` with `gitCommit` equal to approved commit | | Event / runner | `push` / `github-hosted` | -| Invocation | Repository Actions run/attempt identity consistent across the collected evidence; no cross-run substitution | +| Invocation | Repository Actions run/attempt identity consistent across collected evidence; no cross-run substitution | Certificate-backed fields MUST be checked using the pinned gh verifier. Build-type/caller/external-parameter/source-material/subject semantics MUST additionally be checked in successfully verified provenance, not merely inferred from certificate success. Unexpected required structures or missing constraints MUST fail. Policy fixtures MUST use actual output from the pinned action/toolkit; upgrading it requires explicit fixture review. A future separately protected Level 3 builder changes the signer identity: a reviewed policy update MUST define which CLI versions and releases accept it, document the migration and retain verification of supported historical identities. There is no distinct builder/source revision in today's local workflow layout. For both provenance and SPDX proofs, post-success gh JSON certificate checks MUST require `sourceRepositoryIdentifier`, `sourceRepositoryOwnerIdentifier`, `buildConfigURI`, `buildConfigDigest`, `buildTrigger` and `runInvocationURI` to match the numeric repository IDs, exact caller `release.yml@refs/tags/`, approved commit, `push` event and allowed shared run/attempt above. These fields are flattened under `verificationResult.signature.certificate` in the pinned verifier. Provenance `runDetails.metadata.invocationId` MUST equal the certificate run URI. SPDX has no SLSA workflow fields; it uses these certificate constraints plus subject/document checks, not an invented provenance predicate. -**R-BOOTSTRAP.** Consumers MUST obtain gh through a trusted installation channel, use it plus their approved policy to verify the downloaded `ts` executable before first execution, then let verified `ts` drive gh. The gh executable MUST be resolved once from trusted PATH (or an explicitly configured trusted absolute path) and retained as the invocation target. A source-built CLI can run checks but has no official binary/policy assurance until its bytes independently verify. A compromised operator PATH/host remains outside the guarantee. +### R-BOOTSTRAP + +Consumers MUST obtain gh through a trusted installation channel, use it plus their approved policy to verify the downloaded `ts` executable before first execution, then let verified `ts` drive gh. The exact qualified gh 2.97.0 executable MUST be privately selected through an authenticated installation path, resolved once and retained as the invocation target. Ambient `brew upgrade gh` is diagnostic only: an unsupported resulting version MUST fail, and version text alone MUST NOT authenticate it. A future reviewed list of qualified versions is a policy/tools amendment, not current broadening. + +A consumer compiled with policy v1 MUST reject an unsupported producer policy v2. A newer consumer MAY accept an explicitly reviewed historical v1 identity pair, but MUST enforce its own current bounds and constraints throughout verification. A source-built CLI can run checks but has no official binary/policy assurance until its bytes independently verify. A compromised operator PATH/host remains outside the guarantee. Online operations require GitHub authentication even for public artifacts. The integrated verifier MUST accept only an explicitly supplied `GH_TOKEN` or, as fallback, `GITHUB_TOKEN`, for a read-only public-repository request. It MUST pass the selected credential only as `GH_TOKEN` to the child, clear `GH_HOST`, enterprise tokens, inherited `GH_CONFIG_DIR` and other gh overrides, and set isolated private `GH_CONFIG_DIR` and `XDG_STATE_HOME` directories, a reviewed nonempty `HOME` and disabled prompts. Configuration isolation alone does not isolate gh telemetry/device state; an omitted home/state path can write relative to the current directory. Stored `gh auth login` credentials are not silently inherited; the diagnostic MUST explain the explicit-token requirement. Provider credentials MUST NOT reach gh. Normal direct gh commands can use their own trusted login configuration, but integrated behavior follows this narrower contract. The child environment MUST otherwise use reviewed OS/runtime variables and no dynamic-loader injection settings. -**R-BOUNDS.** Each gh child MUST have a 120-second wall-clock deadline, at most 128 MiB of JSON stdout and 1 MiB of stderr; an integrated verification attempt MUST have a 15-minute overall deadline. Before parsing/invoking gh, index/policy/inventory files MUST be at most 1 MiB, each bundle/build companion at most 32 MiB, SPDX SBOM at most the pinned action's 16 MiB limit, and trusted-root snapshot at most 1 MiB, and each executable at most 512 MiB. The integrated verifier MUST verify one retained, membership-checked bundle per invocation and accept exactly one matching attestation result; JSON output includes both bundle and parsed predicate, so its bound is larger than the input bound. Metadata nesting MUST be limited to 64 levels and the cohort to four executables with their declared companions. Timeout, cancellation, oversize input/output, parser ambiguity, failed exit or incomplete JSON MUST fail, terminate/reap children and construct no `VerifiedArtifact`. Bounds can change only through reviewed policy. Diagnostic output MUST exclude credentials and secret context. +### R-BOUNDS + +Each gh child MUST have a 120-second wall-clock deadline, at most 128 MiB of JSON stdout and 1 MiB of stderr; an integrated verification attempt MUST have a 15-minute overall deadline. Before parsing/invoking gh, index/policy/inventory files MUST be at most 1 MiB, each bundle/build companion at most 32 MiB, SPDX SBOM at most the pinned action's 16 MiB limit, and trusted-root snapshot at most 1 MiB, and each executable at most 512 MiB. The integrated verifier MUST verify one retained, membership-checked bundle per invocation and accept exactly one matching attestation result; JSON output includes both bundle and parsed predicate, so its bound is larger than the input bound. Metadata nesting MUST be limited to 64 levels and the cohort to four executables with their declared companions. Timeout, cancellation, oversize input/output, parser ambiguity, failed exit or incomplete JSON MUST fail, terminate/reap children and construct no `VerifiedArtifact`. Bounds can change only through reviewed policy. Diagnostic output MUST exclude credentials and secret context. -**R-PUBLICATION.** Online verification MUST combine pinned gh signature verification with explicit REST and verified-statement policy checks; successful gh exit statuses alone are insufficient. `release verify-asset` matches the file digest even when its local name differs, does not check raw tag type or the API immutable flag, and does not authenticate an asset-size field. The wrapper MUST compare signed subject names/digests, independently approved source/type, REST immutable state and index/actual/API lengths. Online verification MUST verify the GitHub immutable release, confirm its lightweight tag object is a commit, match the signed release target to the approved commit, authenticate index provenance before using its fields, verify R-EVIDENCE, and verify immutable membership for every consumed asset. Successful verification of one predicate MUST NOT stand in for all predicates. Only then, after local action/context authorization, MAY the library construct private `VerifiedArtifact` with retained bytes. No provider write, secret/config creation or build hook is allowed during verification. +### R-PUBLICATION -The following fictional release example shows the primitive commands. They require the approved gh version and authentication. `` comes from trusted consumer approval checked against the signed lightweight release target, not from the downloaded index. Direct `--signer-workflow` remains compatible with #1235's basic evidence check, but its prefix semantics do not meet this contract's exact-identity policy; `--cert-identity` is used instead. These flags are mutually exclusive. +Online verification MUST combine pinned gh signature verification with explicit REST and verified-statement policy checks; successful gh exit statuses alone are insufficient. `release verify-asset` matches the file digest even when its local name differs, does not check raw tag type or the API immutable flag, and does not authenticate an asset-size field. The wrapper MUST compare signed subject names/digests, independently approved source/type, REST immutable state and index/actual/API lengths. Online verification MUST verify the GitHub immutable release, confirm its lightweight tag object is a commit, match the signed release target to the approved commit, authenticate index provenance before using its fields, verify R-EVIDENCE, and verify immutable membership for every consumed asset. Successful verification of one predicate MUST NOT stand in for all predicates. Only then, after local action/context authorization, MAY the library construct private `VerifiedArtifact` with retained bytes. No provider write, secret/config creation or build hook is allowed during verification. + +The following fictional release example shows the primitive commands. They require the approved gh version and authentication. `` comes from trusted consumer approval checked against the signed lightweight release target, not from the downloaded index. The literal #1235 D4 `--signer-workflow .../release.yml` fails because the certificate SAN names `release-build.yml`. Naming `release-build.yml` satisfies only the basic compatible prefix check; its prefix semantics do not meet this contract's exact-identity policy; `--cert-identity` is used instead. These flags are mutually exclusive. ```sh RELEASE_TAG=v1.2.0 @@ -257,7 +333,7 @@ verify_build() { --cert-oidc-issuer https://token.actions.githubusercontent.com \ --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ - --predicate-type https://slsa.dev/provenance/v1 --format json # allow-domain: slsa.dev + --predicate-type https://slsa.dev/provenance/v1 --format json } verify_build release-index.json @@ -269,7 +345,7 @@ gh attestation verify trusted-server-fastly.wasm \ --cert-oidc-issuer https://token.actions.githubusercontent.com \ --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ - --predicate-type https://spdx.dev/Document/v2.3 --format json # allow-domain: spdx.dev + --predicate-type https://spdx.dev/Document/v2.3 --format json gh release verify-asset "$RELEASE_TAG" release-index.json \ -R github.com/IABTechLab/trusted-server @@ -283,7 +359,9 @@ The full R-EVIDENCE checks, JSON Schema validation and membership checks for rem ## 10. Offline evidence limits -**R-OFFLINE.** Offline build verification MUST use securely transported roots, an independently approved source commit and artifact SHA-256, and published provenance/SBOM bundles. Roots or digest pins supplied alongside arbitrary evidence MUST NOT become trust inputs. Preparation MAY obtain a root snapshot with `gh attestation trusted-root --hostname github.com`; artifacts/bundles are downloaded online. A file-mode bundle verification does not need GitHub authentication. Verification MUST work with network access denied and apply the same fixed certificate/source/predicate bounds. +### R-OFFLINE + +Offline build verification MUST use securely transported roots, an independently approved source commit and artifact SHA-256, and published provenance/SBOM bundles. Roots or digest pins supplied alongside arbitrary evidence MUST NOT become trust inputs. Preparation MAY obtain a root snapshot with `gh attestation trusted-root --hostname github.com`; artifacts/bundles are downloaded online. A file-mode bundle verification does not need GitHub authentication. Verification MUST work with network access denied and apply the same fixed certificate/source/predicate bounds. ```sh RELEASE_TAG=v1.2.0 @@ -298,28 +376,44 @@ gh attestation verify trusted-server-fastly.wasm \ --cert-oidc-issuer https://token.actions.githubusercontent.com \ --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" \ --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners \ - --predicate-type https://slsa.dev/provenance/v1 --format json # allow-domain: slsa.dev + --predicate-type https://slsa.dev/provenance/v1 --format json ``` Apply equivalent checks to index and SBOM bundles, use SPDX predicate type for the latter, compare the independent executable pin and all authenticated companion digests, and compare the SPDX document. Offline success establishes build evidence and approved byte identity only. It does not re-establish GitHub release membership, current release state or fresh revocation. `gh release verify-asset` requires the API; `gh attestation verify` is not an offline verifier for GitHub's release-attestation certificate profile. Automated offline official deployment and a `verified-publication.json` protocol are deferred; `ts deploy --release --offline` MUST be rejected in this cohort. ## 11. Fastly verified-byte consumption and recovery -**R-DEPLOY.** Canonical identity is SHA-256 of final `trusted-server-fastly.wasm`, not a publisher-specific tarball. The new `ts deploy --adapter fastly --release ` path MUST verify before side effects, retain the verified bytes in private owned storage and bypass source-build and manifest deploy overrides. Artifact flags without `--release`, unsupported variants/adapters, conflicting package/source arguments, arbitrary passthrough hooks and offline deployment MUST fail. A local `--artifact ` MAY be used only when its retained bytes equal the authenticated official asset and all online checks succeed. Matching source/version strings alone are insufficient. +### R-DEPLOY + +Canonical identity is SHA-256 of final `trusted-server-fastly.wasm`, not a publisher-specific tarball. The new `ts deploy --adapter fastly --release ` path MUST verify before side effects, retain the verified bytes in private owned storage and bypass source-build and manifest deploy overrides. Artifact flags without `--release`, unsupported variants/adapters, conflicting package/source arguments, arbitrary passthrough hooks and offline deployment MUST fail. A local `--artifact ` MAY be used only when its retained bytes equal the authenticated official asset and all online checks succeed. Matching source/version strings alone are insufficient. A delayed EdgeZero API MUST NOT block raw release publication or documented manual verified-byte production deployment. Integrated production/staging is separate completion item AC-8 below, not a prerequisite to build/sign/publish. Source deployment remains available for development/custom distributions, with an explicit diagnostic that canonical identity has not been established. -**R-PACK.** Packing MUST use a validated deployment-only manifest derived from the selected publisher-owned manifest and retained verified wasm in a fresh private directory. The sanitized package name MUST be nonempty and MUST NOT equal `package`; that reserved name collides with the generic archive. With pinned Fastly CLI 15.1.0 and an accepted name, `compute pack --wasm-binary` writes `pkg/.tar.gz` and also an unrelated `pkg/package.tar.gz`; it can sweep pre-existing package contents. The chosen archive MUST have exactly `/`, `/bin/`, `/bin/main.wasm` and `/fastly.toml`. Inspection MUST reject links, traversal, duplicates, extra entries or mismatched manifest/wasm bytes, with bounded extraction. The archive MUST be selected by validated name and contents, not a guessed generic filename. Recheck retained archive/member digests immediately before submission. The deployment-only manifest MUST exclude `[local_server]`, inline fixture settings/secrets, active app-config payloads and build hooks; preserve only reviewed provider envelope/resource-reference fields. The original operator manifest is not silently edited. Fastly pack and `compute validate` can succeed on non-wasm bytes and neither strips local fixtures, so they MUST NOT be used as proof of wasm validity or privacy. Producer validation MUST parse the actual final core module with a pinned validator and execute an application request under pinned Viceroy; `/health` alone bypasses app settings. Live provider/runtime validation remains separate. Tar metadata is nondeterministic: package SHA-256 identifies this operation's envelope, not the canonical release. +### R-PACK + +Packing MUST use a validated deployment-only manifest derived from the selected publisher-owned manifest and retained verified wasm in a fresh private directory. The sanitized package name MUST be nonempty and MUST NOT equal `package`; that reserved name collides with the generic archive. With pinned Fastly CLI 15.1.0 and an accepted name, `compute pack --wasm-binary` writes `pkg/.tar.gz` and also an unrelated `pkg/package.tar.gz`; it can sweep pre-existing package contents. + +The chosen archive MUST have exactly `/`, `/bin/`, `/bin/main.wasm` and `/fastly.toml`. Inspection MUST reject links, traversal, duplicates, extra entries or mismatched manifest/wasm bytes, with bounded extraction. The archive MUST be selected by validated name and contents, not a guessed generic filename. Recheck retained archive/member digests immediately before submission. + +The deployment-only manifest MUST exclude `[local_server]`, inline fixture settings/secrets, active app-config payloads and build hooks; preserve only reviewed provider envelope/resource-reference fields. The original operator manifest is not silently edited. Fastly pack and `compute validate` can succeed on non-wasm bytes and neither strips local fixtures, so they MUST NOT be used as proof of wasm validity or privacy. -**R-CONTEXT.** Before any provider mutation, the integrated route MUST resolve an existing service, selected base version, approved logical config ID, physical resource mapping and runtime selectors, and compare declarations with observed links/selectors on that exact version. Existing-service `[setup]` declarations alone do not provision or update bindings. New-service provisioning MUST be a separate supported operation, otherwise fail read-only. Store/config/secrets push remains separately authorized and MUST NOT be hidden in verification. +Producer validation MUST parse the actual final core module with a pinned validator and execute an application request under pinned Viceroy; `/health` alone bypasses app settings. Live provider/runtime validation remains separate. Tar metadata is nondeterministic: package SHA-256 identifies this operation's envelope, not the canonical release. -After clone/upload, the route MUST validate actual bindings and selectors on the exact returned draft version before staging or activation. Production MUST preserve intended config mappings; staging MUST use a verified isolated config mapping and selectors rather than silently retain inherited production links. Missing logical IDs, absent links, contradictory observed selectors or incomplete isolation MUST fail before activation/stage. Staging isolation here covers app-config mappings/selectors, not automatic isolation of KV/Secret Stores or external side effects. The approved context MUST explicitly declare shared versus isolated resources, and secret validation MUST use references/presence/metadata, not pretend write-only secret plaintext can be read back. Participating operations MUST serialize at service/shared-resource scope; an environment-only lock does not cover shared production/staging resources or other operators. Mutable context must be revalidated at the applicable phase; this is not a transaction or a guarantee against concurrent provider changes. +### R-CONTEXT -**R-EDGEZERO.** The pinned EdgeZero adapter interface is `execute(AdapterAction, &[String]) -> Result<(), String>`; context is string tokens and outcomes are logs. Implementing `deploy_prebuilt` requires a new additive request/result API and reviewed upstream work. Its initial scope MUST be Fastly only. The request MUST contain retained package/path/size/SHA-256, existing service ID, selected base version, production/staging mode and resolved manifest/store context. The result MUST contain service ID, exact version if known, last confirmed phase, whether mutation was attempted and known/unknown outcomes. +Before any provider mutation, the integrated route MUST resolve an existing service, selected base version, approved logical config ID, physical resource mapping and runtime selectors, and compare declarations with observed links/selectors on that exact version. Existing-service `[setup]` declarations alone do not provision or update bindings. New-service provisioning MUST be a separate supported operation, otherwise fail read-only. Store/config/secrets push remains separately authorized and MUST NOT be hidden in verification. + +After clone/upload, the route MUST validate actual bindings and selectors on the exact returned draft version before staging or activation. Production MUST preserve intended config mappings; staging MUST use a verified isolated config mapping and selectors rather than silently retain inherited production links. Missing logical IDs, absent links, contradictory observed selectors or incomplete isolation MUST fail before activation/stage. Staging isolation here covers app-config mappings/selectors, not automatic isolation of KV/Secret Stores or external side effects. The approved context MUST explicitly declare shared versus isolated resources, and secret validation MUST use references/presence/metadata, not pretend write-only secret plaintext can be read back. Participating operations MUST serialize at service/shared-resource scope; an environment-only lock does not cover shared production/staging resources or other operators. Mutable context MUST be revalidated at the applicable phase; this is not a transaction or a guarantee against concurrent provider changes. + +### R-EDGEZERO + +The pinned EdgeZero adapter interface is `execute(AdapterAction, &[String]) -> Result<(), String>`; context is string tokens and outcomes are logs. Implementing `deploy_prebuilt` requires a new additive request/result API and reviewed upstream work. Its initial scope MUST be Fastly only. The request MUST contain retained package/path/size/SHA-256, existing service ID, selected base version, production/staging mode and resolved manifest/store context. The result MUST contain service ID, exact version if known, last confirmed phase, whether mutation was attempted and known/unknown outcomes. EdgeZero MUST own concrete clone/upload/context-validation/stage/activation operations and payload integrity without implementing official release policy. It MUST NOT run `compute build`, `compute publish`, `compute update --autoclone --version=active` as a combined guessed-version path, or infer the uploaded version from whichever version is now active/highest. It MUST inspect the exact selected version and capture the version returned by its own clone/upload operation. `compute deploy` activates internally and cannot expose the required draft-binding validation boundary, so the integrated API cannot be implemented by merely wrapping its exit status. -**R-RECOVERY.** Before first mutation, the integrated route MUST durably persist a restricted operation journal plus retained package/evidence and original resolved context. The journal MUST bind a unique operation ID, policy/source/artifact/package identities, intended service/base version/mode/mappings, previous active version when observed, last confirmed phase and mutation/uncertainty state. It MUST exclude credentials, resolved secrets and active configuration payloads. Immediately before every clone, upload, selector/link write, stage or activation request, the journal MUST atomically persist and flush a pending/attempted marker and explicit uncertainty. A crash between request transmission and response MUST NOT leave a false no-mutation claim. Confirm responses in atomic durable updates through `Prepared → VersionAllocated → Uploaded → ContextBound → Staged/Activated`; allocated version and uploaded package are distinct observations. An abrupt interruption MUST leave enough durable intent/state for inspection. +### R-RECOVERY + +Before first mutation, the integrated route MUST durably persist a restricted operation journal plus retained package/evidence and original resolved context. The journal MUST bind a unique operation ID, policy/source/artifact/package identities, intended service/base version/mode/mappings, previous active version when observed, last confirmed phase and mutation/uncertainty state. It MUST exclude credentials, resolved secrets and active configuration payloads. Immediately before every clone, upload, selector/link write, stage or activation request, the journal MUST atomically persist and flush a pending/attempted marker and explicit uncertainty. A crash between request transmission and response MUST NOT leave a false no-mutation claim. Confirm responses in atomic durable updates through `Prepared → VersionAllocated → Uploaded → ContextBound → Staged/Activated`; allocated version and uploaded package are distinct observations. An abrupt interruption MUST leave enough durable intent/state for inspection. After any attempted write, retained recovery inputs MUST survive temporary-directory cleanup, a failed final receipt write and process termination until explicit reconciliation or abandonment. Before the first write, failed preparation MAY clean up. The consumer MUST serialize operations for the same service and any shared resources across environments; no distributed lock is promised. EdgeZero owns read-only reconciliation of exact observed/allocated versions and uncertain writes. The operator/orchestrator owns the retry, explicit rollback or abandonment decision. Recovery MUST NOT rebuild from today's edited manifest, blindly retry activation/upload or guess a rollback target. If the first clone commits but its response is lost before the allocated version is known, reconciliation MAY remain unresolved: the endpoint exposes no documented caller operation ID/idempotency key. Candidate versions, timestamps or matching packages are not unique ownership proof. Recovery MUST preserve unknown and MUST NOT adopt, activate, delete or roll back a guessed candidate. The local operation ID is not a provider transaction ID. Local receipt output MAY summarize observations but is non-normative for #161 and proves neither publisher authority nor live execution. @@ -337,7 +431,9 @@ ts deploy --adapter fastly --service-id '' --staging \ ### Manual production route during integration -**R-MANUAL.** Operator guidance MUST provide verification, private-directory packing, R-PACK inspection, observed existing-service binding checks and an explicit production submission command before the first release. A lower-level verified-byte transport can use: +### R-MANUAL + +Operator guidance MUST provide verification, private-directory packing, R-PACK inspection, observed existing-service binding checks and an explicit production submission command before the first release. A lower-level verified-byte transport can use: ```sh fastly compute pack --wasm-binary ./trusted-server-fastly.wasm @@ -351,18 +447,20 @@ This manual route preserves inspected wasm bytes but does not supply the integra ## 12. Follow-up ownership and stable adapter invariants -**R-FOLLOWUP.** Additional adapter support MUST preserve configuration outside canonical bytes, external complete-byte SHA-256 identity, verification and action authorization before provider mutation/process start, and artifact-specific provenance/SBOM, reproducibility policy, runtime/binding, readiness and recovery proof. It MUST be selected in a reviewed cohort/policy before execution. One adapter's successful proof MUST NOT qualify another. These invariants do not make additional adapters part of #1235's acceptance or require a cumulative nine-artifact launch. +### R-FOLLOWUP + +Additional adapter support MUST preserve configuration outside canonical bytes, external complete-byte SHA-256 identity, verification and action authorization before provider mutation/process start, and artifact-specific provenance/SBOM, reproducibility policy, runtime/binding, readiness and recovery proof. It MUST be selected in a reviewed cohort/policy before execution. One adapter's successful proof MUST NOT qualify another. These invariants do not make additional adapters part of #1235's acceptance or require a cumulative nine-artifact launch. The following are investigation inputs for separate specs, not approved transports/ABIs in this contract: -| Follow-up | Questions and proof to resolve | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reproducible operator rebuild | Retained authenticated source/dependency/tool inputs, legally obtainable SDKs, separately bootstrapped replay and offline official-release policy. Consider a supported library verification of GitHub-signed release attestations rather than a consumer-authored publication record. Promote native comparison gates individually | -| Cloudflare | Define final executable modules and inventory subjects (prefer raw module assets plus `modules.json` unless an archive is justified). The Worker reads `TRUSTED_SERVER_CONFIG` and caches settings, while current EdgeZero config push writes KV; these channels MUST be reconciled in the adapter spec with explicit config-envelope/secret references and positive/negative publisher-request fixtures. Define prepared version upload and explicit activation identities | -| Cloudflare build/transport | Verify/preseed worker-build 0.8.5 tools: wasm-bindgen matching locked 0.2.126, Binaryen 130 and esbuild 0.28.1 (distinct from the JS lockfile). Account for `WASM_BINDGEN_BIN`, `WASM_OPT_BIN`, `ESBUILD_BIN`, cache/download fallbacks, `CUSTOM_SHIM`, `RUN_TO_COMPLETION`, coredump switches, minification, optimization, panic-unwind and producers metadata. Current `build.sh` installs without `--locked` and loads operator env. For inheritance, investigate `bindings_inherit=strict`. Ordinary script/Upload Version inheritance is latest-uploaded-only; an explicit non-latest `version_id` requires the beta Workers API Versions endpoints and a separately reviewed API contract. Do not prescribe that beta field for the ordinary endpoint; latest uploaded can differ from deployed | -| Spin | `spin-sdk` 6.0.0 locks `wasip3` with WASI `0.3.0-rc-2026-03-15`. A `wasm32-wasip2` compiler target alone does not define a compatible ABI. Pin/test that exact world and runtime, migrate all owning targets/manifests/CI/commands together, and reassess RC support lifetime. Existing secret `default = ""` fields serialize into published locked-app data; define unresolved secret references. Default OCI composition and current-time annotations must not silently transform the canonical component; registry manifest identity is observed separately | -| Axum | Define direct verified local launch and a tested bridge from EdgeZero's local config envelope to runtime selectors/config variables and secret references. The binary uses `PORT` and loopback, not the source launcher's adapter port or `axum.toml`; a living error router is not readiness. Require an application request, child cleanup and signal handling. No generic remote deployment implied | -| #161 | Publisher authority, actual loaded snapshot, claims/freshness, remote execution limits and optional authenticated deployment observations. Fastly default is per-request; reusable Fastly/Axum/Cloudflare/Spin have distinct snapshot lifetimes | +| Follow-up | Questions and proof to resolve | +| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reproducible operator rebuild | Retained authenticated source/dependency/tool inputs, legally obtainable SDKs, separately bootstrapped replay and offline official-release policy. Consider a supported library verification of GitHub-signed release attestations rather than a consumer-authored publication record. Promote native comparison gates individually | +| Cloudflare (existing [KV/Worker-variable mismatch #1140](https://github.com/IABTechLab/trusted-server/issues/1140), deferred here) | Define final executable modules and inventory subjects (prefer raw module assets plus `modules.json` unless an archive is justified). The Worker reads `TRUSTED_SERVER_CONFIG` and caches settings, while current EdgeZero config push writes KV; these channels MUST be reconciled in the adapter spec with explicit config-envelope/secret references and positive/negative publisher-request fixtures. Define prepared version upload and explicit activation identities | +| Cloudflare build/transport | Verify/preseed worker-build 0.8.5 tools: wasm-bindgen matching locked 0.2.126, Binaryen 130 and esbuild 0.28.1 (distinct from the JS lockfile). Account for `WASM_BINDGEN_BIN`, `WASM_OPT_BIN`, `ESBUILD_BIN`, cache/download fallbacks, `CUSTOM_SHIM`, `RUN_TO_COMPLETION`, coredump switches, minification, optimization, panic-unwind and producers metadata. Current `build.sh` installs without `--locked` and loads operator env. For inheritance, investigate `bindings_inherit=strict`. Ordinary script/Upload Version inheritance is latest-uploaded-only; an explicit non-latest `version_id` requires the beta Workers API Versions endpoints and a separately reviewed API contract. Do not prescribe that beta field for the ordinary endpoint; latest uploaded can differ from deployed | +| Spin | `spin-sdk` 6.0.0 locks `wasip3` with WASI `0.3.0-rc-2026-03-15`. A `wasm32-wasip2` compiler target alone does not define a compatible ABI. Pin/test that exact world and runtime, migrate all owning targets/manifests/CI/commands together, and reassess RC support lifetime. Existing secret `default = ""` fields serialize into published locked-app data; define unresolved secret references. Default OCI composition and current-time annotations MUST not silently transform the canonical component; registry manifest identity is observed separately | +| Axum | Define direct verified local launch and a tested bridge from EdgeZero's local config envelope to runtime selectors/config variables and secret references. The binary uses `PORT` and loopback, not the source launcher's adapter port or `axum.toml`; a living error router is not readiness. Require an application request, child cleanup and signal handling. No generic remote deployment implied | +| #161 | Publisher authority, actual loaded snapshot, claims/freshness, remote execution limits and optional authenticated deployment observations. Fastly default is per-request; reusable Fastly/Axum/Cloudflare/Spin have distinct snapshot lifetimes | The Cloudflare configuration mismatch and inaccurate configuration comments predate this spec. Runtime repair is separate work; no issue is automatically opened by this document. @@ -372,44 +470,45 @@ The [feasibility audit](2026-10-12-1235-release-attestation-feasibility.md) cove ## Test plan and requirement traceability -These are implementation acceptance tests, not tests claimed to pass in this specification PR. Every normative requirement maps below to a test family and acceptance item. D1–D8 refer to the eight “Done when” bullets of #1235 in their original order. - -| Requirements | Test family and essential positive/negative cases | Acceptance | #1235 Done when | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------- | -| R-COHORT, R-PLATFORM, R-VARIANT | T-1 exact four assets/profiles; no downgrade on required failure; CLI B mismatch reported; Fastly failure blocks; ELF symbol/link/runtime checks; macOS 15 ARM/Intel startup; old-OS claims remain unqualified; reject unsupported reuse | AC-1, AC-2 | D1, D3 | -| R-CONFIG, R-INPUTS, R-ENV | T-2 build with differing fictional publisher inputs gives identical permitted bytes; operator values absent; unchanged repository manifest; inventory covers embedded roots/generated assets/native inputs; prohibited flags/files/env fail | AC-2 | D2, D5 | -| R-JS | T-3 #1214 private prebuilt path; skip absent/empty/0/1; missing npm, failed install/test, stale/empty/extra module; inventory tampering; two JS builds differ; cross-artifact digests differ; missing esbuild with network denied | AC-3 | D5 | -| R-BUILD, R-REPEAT, R-NATIVE | T-4 separate run/job/ID/digest binding; wrong-run/artifact substitution; fresh outputs; varied declared ambient inputs; final wasm equality/no normalized comparison; native A/B outcomes and signature/path observations; enforced socket-denial controls | AC-3 | D3, D6 | -| R-SBOM | T-5 actual target and host proc-macro/build roles, dual-role package, dev exclusion, real JS contribution, Prebid type-only exclusion, plugin helper, AWS-LC/SQLite/ring native coverage and forbidden system selection, SPDX relationship/root/predicate equality and size limit | AC-4 | D3, D4 | -| R-FACTS | T-6 target/feature ownership; malformed/missing commit fails without git fallback; source header vs service version; two tags on same commit do not change bytes; no final checksum/config/run timestamp embedded | AC-5 | D2 | -| R-SOURCE, R-SIGN, R-PUBLISH | T-7 approved main/RC tag; unauthorized RC and annotated tag rejected; pending/cancelled/skipped/path-filtered/wrong-commit checks; timeout; untrusted caller; permission separation; matching/conflicting draft reruns; immutable/mutable/unknown publish state | AC-6 | D3, D7 | -| R-INDEX, R-EVIDENCE, R-POLICY | T-8 schema positive fixture and unknown enums/version/extra field/duplicate keys/names/traversal; tampered subjects/companions; policy cannot self-select; wrong caller/build type/material/source/builder/prefix lookalike, trailing control characters, actual size limits; no extra unapproved subjects | AC-4, AC-7 | D3, D4 | -| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | T-9 verified CLI first-use fixture; absent token; isolated gh config/host; unsupported tool; hangs/cancel/oversize/nesting/partial JSON; unpublished or mutable artifact; source approval mismatch; all fail before provider mutation | AC-7 | D4, D6 | -| R-OFFLINE | T-10 pinned roots/source/digest with networking denied; artifact/predicate tampering; hostile transferred roots; offline build success cannot construct official-deploy capability; offline deployment rejected | AC-7 | D4 (specified offline limits) | -| R-DEPLOY, R-PACK, R-CONTEXT, R-EDGEZERO, R-RECOVERY | T-11 retained-file substitution/package/member mutation; all four Fastly pack members plus extra manifest-only archive; observed links disagree; exact draft mismatch prevents activation; staging isolation; no source hooks; exact/unknown outcomes; interrupted selector writes, first-clone/send-before-response crashes including indistinguishable candidate allocations, secret/reference privacy, shared-resource isolation boundaries and final receipt failure; read-only reconciliation | AC-8 | D6 (integrated enhancement) | -| R-MANUAL | T-12 provider-free pack/member equality; documented native production limits including skipped upload and manifest precedence; authorized disposable-service production test with submitted wasm equality | AC-9 | D6 | -| R-FOLLOWUP | T-13 docs/policy reject unsupported adapters/actions; #1235 criteria contain no adapter migration, operator replay or offline deploy gate; January supersession link | AC-10 | D1, D8 | - -Implementation changes MUST run appropriate target-matched tests/lints after each behavioral change and the complete AGENTS.md CI gates before PR handoff, plus upstream EdgeZero API/lifecycle tests. No real customer/domain/secret fixture data is permitted. A specification-only PR requires document/schema formatting, schema positive/negative fixture validation, reference/traceability checks and design review; unchanged runtime tests do not demonstrate future behavior. +These are implementation acceptance tests, not tests claimed to pass in this specification and implementation PR. Every normative requirement maps below to a test family and acceptance item. D1–D8 refer to the eight “Done when” bullets of #1235 in their original order. + +| Requirements | Test family and essential positive/negative cases | Acceptance | #1235 Done when | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------- | +| R-COHORT, R-PLATFORM, R-VARIANT | T-1 exact four assets/profiles; no downgrade on required failure; CLI B mismatch reported; Fastly failure blocks; ELF symbol/link/runtime checks; macOS 15 ARM/Intel startup; old-OS claims remain unqualified; reject unsupported reuse | AC-1, AC-2 | D1, D3 | +| R-CONFIG, R-INPUTS, R-ENV | T-2 build with differing fictional publisher inputs gives identical permitted bytes; operator values absent; unchanged repository manifest; inventory covers embedded roots/generated assets/native inputs; prohibited flags/files/env fail | AC-2 | D2, D5 | +| R-JS | T-3 #1214 private prebuilt path; skip absent/empty/0/1; missing npm, failed install/test, stale/empty/extra module; inventory tampering; two JS builds differ; cross-artifact digests differ; missing esbuild with network denied | AC-3 | D5 | +| R-BUILD, R-REPEAT, R-NATIVE | T-4 separate run/job/ID/digest binding; wrong-run/artifact substitution; fresh outputs; varied declared ambient inputs; final wasm equality/no normalized comparison; native A/B outcomes and signature/path observations; enforced socket-denial controls | AC-3 | D3, D6 | +| R-SBOM | T-5 actual target and host proc-macro/build roles, dual-role package, dev exclusion, real JS contribution, Prebid type-only exclusion, plugin helper, AWS-LC/SQLite/ring native coverage and forbidden system selection, SPDX relationship/root/predicate equality and size limit | AC-4 | D3, D4 | +| R-FACTS | T-6 target/feature ownership; malformed/missing commit fails without git fallback; source header vs service version; two tags on same commit do not change bytes; no final checksum/config/run timestamp embedded | AC-5 | D2 | +| R-SOURCE, R-SIGN, R-PUBLISH | T-7 approved main/RC tag; unauthorized RC and annotated tag rejected; pending/cancelled/skipped/path-filtered/wrong-commit checks; timeout; untrusted caller; permission separation; matching/conflicting draft reruns; immutable/mutable/unknown publish state | AC-6 | D3, D7 | +| R-INDEX, R-EVIDENCE, R-POLICY | T-8 schema positive fixture and unknown enums/version/extra field/duplicate keys/names/traversal; tampered subjects/companions; policy cannot self-select; wrong caller/build type/material/source/builder/prefix lookalike, trailing control characters, actual size limits; no extra unapproved subjects | AC-4, AC-7 | D3, D4 | +| R-BOOTSTRAP, R-BOUNDS, R-PUBLICATION | T-9 verified CLI first-use fixture; absent token; isolated gh config/host; unsupported tool; hangs/cancel/oversize/nesting/partial JSON; unpublished or mutable artifact; source approval mismatch; all fail before provider mutation | AC-7 | D4, D6 | +| R-OFFLINE | T-10 pinned roots/source/digest with networking denied; artifact/predicate tampering; hostile transferred roots; offline build success cannot construct official-deploy capability; offline deployment rejected | AC-7 | D4 (specified offline limits) | +| R-DEPLOY/R-PACK integrated portion, R-CONTEXT, R-EDGEZERO, R-RECOVERY | T-11 retained-file substitution/package/member mutation; all four Fastly pack members plus extra manifest-only archive; observed links disagree; exact draft mismatch prevents activation; staging isolation; no source hooks; exact/unknown outcomes; interrupted selector writes, first-clone/send-before-response crashes including indistinguishable candidate allocations, secret/reference privacy, shared-resource isolation boundaries and final receipt failure; read-only reconciliation | AC-8 | D6 (integrated enhancement) | +| R-DEPLOY/R-PACK manual portion, R-MANUAL | T-12 provider-free pack/member equality; documented native production limits including skipped upload and manifest precedence; authorized disposable-service production test with submitted wasm equality | AC-9 | D6 | +| R-FOLLOWUP | T-13 docs/policy reject unsupported adapters/actions; #1235 criteria contain no adapter migration, operator replay or offline deploy gate; January supersession link | AC-10 | D1 | +| Operator/CLI/changelog guidance | T-12 manual-route instructions and T-13 unsupported-action diagnostics; document source-header identity and bootstrap prerequisites | AC-9 | D8 | + +Implementation changes MUST run appropriate target-matched tests/lints after each behavioral change and the complete AGENTS.md CI gates before PR handoff. The deferred AC-8 milestone additionally requires upstream EdgeZero API/lifecycle tests; those are not a prerequisite for the AC-9 manual route. No real customer/domain/secret fixture data is permitted. PR #1262 contains implementation as well as documents/schema/plans. Document/schema formatting, committed positive/negative fixtures, reference/traceability checks and automated design review accompany implementation tests; these do not establish pending hosted qualification or maintainer approval. ## Rollout -1. Review this contract and its plans, then implement on the same `spec/1235-release-attestation` branch and PR #1262. Approve the contract/code together before merge; this explicitly replaces the separate-spec-PR delivery procedure for this work, without waiving qualification or release authorization. Reconcile #1214/#1213 and permission-config separation. +1. Review this contract and its plans, then implement on the same `spec/1235-release-attestation` branch and PR #1262. Approve the contract/code together before merge; this explicitly replaces the separate-spec-PR delivery procedure for this work, without waiving qualification or release authorization. Extend merged #1214/#1213 and reconcile permission-config separation. 2. Prototype Fastly A/B equality, shared JS output, SBOM native/host/JS fixtures and CLI compatibility baselines with throwaway recipes. Record feasibility and review any cohort change before production work; this step does not promise replay of nonexistent retained inputs. 3. Implement reviewed release recipes/tools/cohort, JS inventory, composition generator, facts, validation, measurements, schema/policy, signing and immutable publication. Remove obsolete claims that configuration is baked into wasm from CI workflows, the integration-test setup action, adapter comments/tests and integration helpers, not just `test.yml`. 4. Prepare governance/settings/tag rules, trusted gh/CLI bootstrap and documentation. Exercise publication and verification in a disposable public repository using policy fixtures for that repository; no production-policy override is added. Document/validate the manual verified-byte Fastly production route. Publish the first eligible four-artifact release after all its selected requirements pass. 5. Implement the separate Fastly-only EdgeZero prebuilt request/result and recovery lifecycle, review/pin that upstream revision, then adopt integrated online production/staging in `ts`. Upstream delay does not block step 4, and MUST NOT cause the artifact path to rebuild. -6. Update `docs/guide/cli.md`, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document platform/tool/token prerequisites, policy identities, manual/integrated distinctions, offline limits and optional comparison results. Historical source builds MUST NOT be relabeled attested. +6. Update `docs/guide/getting-started.md`, `docs/guide/edgezero.md`, `docs/guide/error-reference.md` and `docs/guide/cli.md`, annotate #1213's historical header goals, add `docs/guide/release-verification.md` and navigation, and update `CHANGELOG.md`. Document platform/tool/token prerequisites, policy identities, manual/integrated distinctions, offline limits and optional comparison results. Historical source builds MUST NOT be relabeled attested. 7. Follow-up specs independently qualify operator reconstruction, offline deployment, each other adapter and stronger builder isolation. #161 continues publisher/config/runtime work; none gates #1235 closure. ## Acceptance criteria - [ ] **AC-1:** Spec, plans and implementation reviewed/approved together in PR #1262; implementation records the selected cohort/profiles before release execution. - [ ] **AC-2:** Configuration independence, complete input inventory, selected variant and qualified CLI compatibility hold for every selected Build A. -- [ ] **AC-3:** Fresh locked shared JS passes byte equality; Fastly A/B matches complete bytes; native measurements have honest terminal outcomes and cannot waive required failures. +- [ ] **AC-3:** The two fresh JS builds produce byte-identical bundles. Fastly Build A and Build B produce byte-identical `trusted-server-fastly.wasm`. Each native CLI Build B records `matched`, `mismatched`, `failed` or `unavailable` before its deadline; its result MUST NOT change whether that CLI's mandatory A, SBOM or provenance checks pass. - [ ] **AC-4:** Per-artifact provenance/SPDX/bundles/index/checksums and composition coverage pass their exact evidence and schema checks. - [ ] **AC-5:** Source facts reconcile #1213, derive target/variant correctly and keep release tags, service versions and final digests distinct. -- [ ] **AC-6:** Approved main/RC lightweight `v*` tags run same-commit gates and publish the exact immutable verified distribution with least-privilege authority. +- [ ] **AC-6:** Approved main (or future explicitly reviewed exact RC) lightweight `v*` tags run same-commit gates and publish the exact immutable verified distribution with least-privilege authority. - [ ] **AC-7:** Trusted bootstrap, complete online verification, bounded subprocesses and offline build-evidence checks pass; unsupported offline official deployment fails before writes. - [ ] **AC-8 (integration milestone):** New reviewed/pinned EdgeZero Fastly API supports byte-preserving online production/staging, observed context validation and durable recovery. This item tracks the subsequent integrated enhancement and MUST NOT keep #1235 open when AC-9 satisfies its deployment choice. - [ ] **AC-9:** Operator verification/CLI/changelog guidance and the manual verified-byte production route are implemented and validated; ordinary source builds cannot be mistaken for canonical bytes. @@ -421,40 +520,40 @@ Implementation changes MUST run appropriate target-matched tests/lints after eac This table accounts for all 32 inline comments in [review 5484754857](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857). It records proposed spec resolution; GitHub conversations are not automatically marked resolved and maintainer acceptance is still required. -| Review concern | Resolution / requirement | -| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Reusable sandbox (4243031371) | R-VARIANT retains current production default and plans qualified attested reuse; R-FACTS corrects snapshot lifetime; removes nonexistent test feature | -| All-artifact reproducibility gate (4243031376) | R-COHORT/R-REPEAT select Fastly equality, native observation; reconstruction/offline deploy deferred | -| Repository-root scaffold (4243031380) | R-INPUTS names the external-to-CLI-crate repository-root input | -| Ambient variation and job output binding (4243031389) | R-REPEAT varies declared-insensitive inputs and binds artifact IDs/digests to job outputs | -| Distinct assurance dimensions (4243031391) | Profiles chosen before execution; claim/threat tables retain independent limits | -| CLI host baselines (4243031395) | R-PLATFORM keeps glibc 2.34 as a qualification target not yet demonstrated; macOS loader floor 13 with initial hosted-15 qualification, tested cross-build only and Intel lifecycle plan | -| Index/schema and informational records (4243031397) | R-INDEX links closed JSON Schema and field contracts; recipe/rebuild consumer semantics informational; receipts private observations | -| #1214 fit (4243031404) | R-JS adds release delta to private prebuilt interface, no competing strict mode | -| One identical release-wide tsjs set (4243031409) | R-JS twice-builds one shared set, checks prepared inputs and separately checks modules actually retained after linking | -| Compiler wrapper/Syft cost (4243031418) | R-SBOM uses Cargo/lock/Rollup evidence and a repository-owned SPDX generator | -| Native and esbuild coverage (4243031423) | R-SBOM declares/validates actual native selection; R-JS tests denied fallback download | -| Release branches and in-progress CI (4243031426) | R-SOURCE approves exact main/RC policy and runs bounded same-commit release validation | -| Local signer/source revision (4243031429) | R-POLICY pins equal signer/source revisions and defines future identity migration | -| Attestation permission (4243031433) | R-SIGN removes `artifact-metadata: write` for file subjects | -| Settings token (4243031442) | R-PUBLISH uses governance plus actual immutable release check/recovery; no settings-read secret | -| gh prerequisites and bootstrap (4243031449) | R-BOOTSTRAP specifies token/env/path/trust chain; exact qualified gh version | -| Normative evidence graph (4243031456) | R-EVIDENCE/R-POLICY fix all digest/source/builder/caller/action constraints | -| Annotated tag commit confusion (4243031459) | R-SOURCE requires lightweight tags and explicit commit-object validation | -| Prefix signer match (4243031464) | R-POLICY/commands use exact SAN `--cert-identity` | -| Resource bounds (4243031467) | R-BOUNDS supplies concrete limits/deadlines, failure and child cleanup | -| Offline observation protocol cost (4243031470) | R-OFFLINE retains bundle/pin checks; offline official deployment/record deferred | -| Actual EdgeZero surface (4243031476) | R-EDGEZERO sizes new Fastly-only API and bypasses existing build/log/version fallbacks | -| Observed binding validation (4243031479) | R-CONTEXT checks existing base version and exact resulting draft before stage/activation | -| Pack archive facts (4243031481) | R-PACK specifies four prefixed entries, fresh directory and separate manifest-only archive | -| Durable recovery ownership (4243031485) | R-RECOVERY persists before mutation, survives failures/crashes and assigns reconciliation/retry ownership | -| Native deploy limits (4243031488) | R-MANUAL documents production activation, skip, domain creation and manifest precedence | -| Derived facts / #1213 (4243031494) | R-FACTS uses established name, source-only override, Cargo/adapter derivation, external release tag | -| Cloudflare config mismatch (4243031498) | §12 preserves concrete binding-channel investigation and fixture prerequisites for separate adapter spec | -| Spin ABI and OCI facts (4243031502) | §12 records exact WASI 0.3 RC dependency, secret defaults and composition/annotation caveats; no premature migration gate | -| Rollout dependencies (4243031506) | Feasibility precedes recipes; publication/manual route precedes independent upstream integration | -| Stale config assertions beyond CI (4243031509) | Rollout step 3 explicitly covers workflows/action/adapter comments/tests/helpers | -| Phase 2 closure scope (4243031515) | R-FOLLOWUP/acceptance remove cumulative adapter gates from #1235 | +| Review concern | Resolution / requirement | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reusable sandbox (4243031371) | R-VARIANT retains current production default and plans qualified attested reuse; R-FACTS corrects snapshot lifetime; removes nonexistent test feature | +| All-artifact reproducibility gate (4243031376) | R-COHORT/R-REPEAT select Fastly equality, native observation; reconstruction/offline deploy deferred | +| Repository-root scaffold (4243031380) | R-INPUTS names the external-to-CLI-crate repository-root input | +| Ambient variation and job output binding (4243031389) | R-REPEAT varies declared-insensitive inputs and binds artifact IDs/digests to job outputs | +| Distinct assurance dimensions (4243031391) | Profiles chosen before execution; claim/threat tables retain independent limits | +| CLI host baselines (4243031395) | R-PLATFORM keeps glibc 2.34 as a qualification target not yet demonstrated; macOS loader floor 13 with initial hosted-15 qualification, matching-host recipes first; cross-build requires full qualification and actual Intel execution | +| Index/schema and informational records (4243031397) | R-INDEX links closed JSON Schema and field contracts; recipe/rebuild consumer semantics informational; receipts private observations | +| #1214 fit (4243031404) | R-JS adds release delta to private prebuilt interface, no competing strict mode | +| One identical release-wide tsjs set (4243031409) | R-JS twice-builds one shared set, checks prepared inputs and separately checks modules actually retained after linking | +| Compiler wrapper/Syft cost (4243031418) | R-SBOM uses Cargo/lock/Rollup evidence and a repository-owned SPDX generator | +| Native and esbuild coverage (4243031423) | R-SBOM declares/validates actual native selection; R-JS tests denied fallback download | +| Release branches and in-progress CI (4243031426) | R-SOURCE approves exact main/RC policy and runs bounded same-commit release validation | +| Local signer/source revision (4243031429) | R-POLICY pins equal signer/source revisions and defines future identity migration | +| Attestation permission (4243031433) | R-SIGN removes `artifact-metadata: write` for file subjects | +| Settings token (4243031442) | R-PUBLISH uses governance plus actual immutable release check/recovery; no settings-read secret | +| gh prerequisites and bootstrap (4243031449) | R-BOOTSTRAP specifies token/env/path/trust chain; exact qualified gh version | +| Normative evidence graph (4243031456) | R-EVIDENCE/R-POLICY fix all digest/source/builder/caller/action constraints | +| Annotated tag commit confusion (4243031459) | R-SOURCE requires lightweight tags and explicit commit-object validation | +| Prefix signer match (4243031464) | R-POLICY/commands use exact SAN `--cert-identity` | +| Resource bounds (4243031467) | R-BOUNDS supplies concrete limits/deadlines, failure and child cleanup | +| Offline observation protocol cost (4243031470) | R-OFFLINE retains bundle/pin checks; offline official deployment/record deferred | +| Actual EdgeZero surface (4243031476) | R-EDGEZERO sizes new Fastly-only API and bypasses existing build/log/version fallbacks | +| Observed binding validation (4243031479) | R-CONTEXT checks existing base version and exact resulting draft before stage/activation | +| Pack archive facts (4243031481) | R-PACK specifies four prefixed entries, fresh directory and separate manifest-only archive | +| Durable recovery ownership (4243031485) | R-RECOVERY persists before mutation, survives failures/crashes and assigns reconciliation/retry ownership | +| Native deploy limits (4243031488) | R-MANUAL documents production activation, skip, domain creation and manifest precedence | +| Derived facts / #1213 (4243031494) | R-FACTS uses established name, source-only override, Cargo/adapter derivation, external release tag | +| Cloudflare config mismatch (4243031498) | §12 preserves concrete binding-channel investigation and fixture prerequisites for separate adapter spec | +| Spin ABI and OCI facts (4243031502) | §12 records exact WASI 0.3 RC dependency, secret defaults and composition/annotation caveats; no premature migration gate | +| Rollout dependencies (4243031506) | Feasibility precedes recipes; publication/manual route precedes independent upstream integration | +| Stale config assertions beyond CI (4243031509) | Rollout step 3 explicitly covers workflows/action/adapter comments/tests/helpers | +| Phase 2 closure scope (4243031515) | R-FOLLOWUP/acceptance remove cumulative adapter gates from #1235 | Body-level findings are addressed by the threat model, BCP 14 requirements, requirement→test→acceptance→issue mapping, named producer-policy paths and the January supersession banner. Deferred Cloudflare notes are retained in §12 rather than lost during scope reduction. @@ -469,7 +568,7 @@ This draft supersedes the binary-attestation direction in [the January proposal] - gh 2.97.0 [exact/prefix identity policy](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/pkg/cmd/attestation/verify/policy.go) and [tag-target lookup](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/pkg/cmd/release/shared/fetch.go); [verifier manual](https://cli.github.com/manual/gh_attestation_verify), [bundle download](https://cli.github.com/manual/gh_attestation_download), [trusted roots](https://cli.github.com/manual/gh_attestation_trusted-root). - Pinned [attest 4.2.2 dependency lock](https://github.com/actions/attest/blob/1e69f48acb82d1966a394da916b4c1698aa569d6/package-lock.json) and matching [toolkit provenance semantics](https://github.com/actions/toolkit/blob/7987771a2b727fe14b36351f6c877fa3a56b9c36/packages/attest/src/provenance.ts). - Pinned [Sigstore certificate summary fields](https://github.com/sigstore/sigstore-go/blob/v1.2.2/pkg/fulcio/certificate/summarize.go). -- [SLSA build requirements](https://slsa.dev/spec/v1.2/build-requirements), [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/), [BCP 14 keyword clarification](https://www.rfc-editor.org/rfc/rfc8174.html). +- [SLSA build requirements](https://slsa.dev/spec/v1.2/build-requirements), [SPDX relationships](https://spdx.github.io/spdx-spec/v2.3.1/relationships-between-SPDX-elements/), [BCP 14 keyword clarification](https://www.rfc-editor.org/rfc/rfc8174.html). - Cargo [build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html), [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html), [source remapping](https://doc.rust-lang.org/rustc/remap-source-paths.html) and [Apple targets](https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html); [Rollup output hooks](https://rollupjs.org/plugin-development/), [npm ci](https://docs.npmjs.com/cli/v11/commands/npm-ci/). - Fastly 15.1.0 [packing](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go), [deployment](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/deploy.go), [build annotation](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/build.go). - Pinned EdgeZero [adapter surface](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter/src/registry.rs), [Fastly lifecycle](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter-fastly/src/cli.rs), [CLI dispatch](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-cli/src/lib.rs). diff --git a/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md b/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md index b36a546059..df928fa01a 100644 --- a/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md +++ b/docs/superpowers/specs/2026-10-12-1235-release-attestation-feasibility.md @@ -8,20 +8,22 @@ Document/schema checks and independent design review establish consistency. They do not establish that a new workflow can publish, that every native target runs, or that provider operations preserve an intended runtime context. +**2026-10-14 implementation correction:** Audit prototype scripts and their Python paths/counts are historical measurements, not active implementation instructions or Rust migration proof. Introduced Python release tooling has been superseded by the independent host-only Rust producer in the [migration addendum](2026-10-14-release-tooling-rust-migration.md). Existing Node composition remains. The original native acquisition/SDK, actual hosted-platform, full SPDX, same-run transfer, signing/publication and live-provider gaps remain mandatory; local macOS 26.6.2 does not establish macOS 15 execution. + ## Evidence scope -The baseline is `7f6056bf83eb96aa4167efca64d43280299fba73`. Read-only PR checks identified #1214 head `e150780b4a922be0cd0dfbbe1167c72aa076d3e8` and #1213 head `959e503b9e35b0aecd42439edc85ad744bbb92f2`. Both were open during the audit. Isolated JS/interface probes overlaid #1214 files onto the baseline; its old `build-all.mjs` diff requires reconciliation with the current refactor. These synthetic snapshots are not an approved release commit. +The historical audit baseline was `7f6056bf83eb96aa4167efca64d43280299fba73`. Read-only PR checks identified #1214 head `e150780b4a922be0cd0dfbbe1167c72aa076d3e8` and #1213 head `959e503b9e35b0aecd42439edc85ad744bbb92f2`. Both were open during that audit; both merged on 2026-10-12 and are ancestors of the current implementation planning baseline `44c5b5620bfb1f82e5b959ab5ba8ec69c7091e52`. Open-PR overlays are historical probe conditions, not current prerequisites. Isolated JS/interface probes overlaid #1214 files onto the baseline; its old `build-all.mjs` diff requires reconciliation with the current refactor. These synthetic snapshots are not an approved release commit. Local tools were Rust 1.95.0, Node 24.12.0/npm 11.6.2, macOS 26.6.2 ARM64 with SDK 15.5, Fastly CLI 15.1.0, Viceroy 0.17.0 and downloaded GitHub CLI 2.97.0. The Fastly CLI separately reports a bundled Viceroy 0.21.1; that was not substituted for the pinned runtime probe. Compilation outputs were fresh; Rust source caches/compiler were shared. Prototype epoch input was fixed rather than produced by the final commit-derived recipe. Source-fact #1213 changes and CLI checkout-fallback removal were not applied to the full artifact builds. Build outputs and application fixtures stayed in temporary directories. A gh probe with omitted `HOME`/`XDG_STATE_HOME` briefly created anonymous device state relative to the repository; the behavior was reproduced, the exact generated file and empty directories were removed, and R-BOOTSTRAP now requires private home/state handling. Provider operations, repositories, workflows, releases and attestations were not created or modified. Online probes read existing public GitHub release evidence. Runtime requests used localhost fixtures with fictional configuration and secrets. -## What was executed +## Historical local measurements | Probe | Measured result | What it proves | What it does not prove | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Fresh locked JS A/B | All 13 final bundles byte-identical; each test run passed 50 files and 1,208 tests | Current bundler can produce stable shared input under varied local paths/locale/timezone | Separate hosted workers, all package-origin joins or independent toolchain trust | -| #1214 prebuilt interface | Exact copied module set/hashes; succeeded with npm absent; 7 bundle-set tests passed | Rust can consume one prepared JS set without rerunning npm | Trusted expected-inventory input and a release presence-reject guard; upstream currently accepts skip alongside prebuilt | +| Fresh locked JS A/B | All 13 final bundles byte-identical; each test run passed 50 files and 1,208 tests | The historical bundler could produce stable shared input under varied local paths/locale/timezone | Separate hosted workers, all package-origin joins or independent toolchain trust | +| #1214 prebuilt interface | Exact copied module set/hashes; succeeded with npm absent; 7 bundle-set tests passed | Rust can consume one prepared JS set without rerunning npm | Trusted expected-inventory input and a release presence-reject guard; historical upstream accepted skip alongside prebuilt | | Missing esbuild binary | Network-denied bundle step exited 1 without fetching replacement | Selected Darwin bundler does not silently succeed without its prepared tool | Every installation-time/platform download fallback | | Network confinement | Unconfined localhost socket succeeded; confined socket failed with `EPERM`; full Rust/JS builds ran inside that profile | Local macOS process-level network denial is available | Qualification of each actual hosted recipe or hostile-runner resistance | | Full Fastly release A/B | Both complete files were 69,647,265 bytes and matched SHA-256 `2a740c43e93c47b31237861118e1bd75c5bd2940eb3daf1bb382d1104547c658`; no normalization | Fresh Cargo outputs can repeat across source/output paths, umask, locale/timezone and 4-versus-2 build jobs; all 13 JS strings remained | Final Linux hosted recipe, fresh independent dependency materialization, final source facts, publication or operator reconstruction | @@ -59,45 +61,45 @@ These are measurements from this audit, not claims about a published release. Th “Demonstrated” means only the stated local probe. “Supported” means pinned source/docs expose the needed primitive. “New code” is an implementable contract absent from the existing application. “Unqualified” means a mandatory shipping proof is missing. A composite row can have more than one status because a working primitive does not supply the full policy. -| Requirement | Status and evidence | Implementation or qualification still needed | -| ------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| R-COHORT | New code; fixed profiles are structurally expressible | Source-controlled cohort/collector; all four canonical Build A qualification gates | -| R-PLATFORM | ARM build/run and loader floor demonstrated; GNU/Intel/hosted 15 unqualified | Actual glibc 2.34 builder and ELF symbols/UBI/Ubuntu/Debian tests; hosted ARM/Intel execution; review any baseline change before enablement | -| R-VARIANT | Supported by current Cargo/runtime source; default artifact ran locally | Release variant allowlist/diagnostic; reuse remains separate qualification | -| R-CONFIG | Source-supported separation; runtime fixture loaded independently of wasm | Whole-release differing-publisher-input negative fixture; permission-spec compatibility | -| R-INPUTS | Important inputs and native/script env evidence observed | Complete tracked source/dependency/sysroot/tool/native identities; new generated input coverage | -| R-ENV | Denied socket and selected fresh environments demonstrated | Checked release env whitelist, private gh home/state, all skip values, Cargo/config layers, Vite env suppression and source mutation guards | -| R-JS | Fresh A/B, tests, prebuilt handoff, Rollup/missing-tool probes demonstrated | Current merge reconciliation, trusted inventory binding, final retained classification, hosted transfer | -| R-BUILD | Full frozen network-denied local builds demonstrated | Digest-pinned hosted recipes; verified fresh material extraction; every selected confinement profile and native bootstrap | -| R-REPEAT | Complete Fastly equality demonstrated locally | Separate hosted Linux jobs, final facts/source, explicit upload outputs/transfer failure gate; optional-native terminal outcomes | -| R-NATIVE | Whole ARM CLI and static native evidence demonstrated | Effective tool/SDK pins, platform linker/signature policy; GNU/Intel measurements; no native equality shipping claim | -| R-SBOM | Limited selected-unit/native serialization prototype demonstrated | Full real artifact SPDX coverage, source/license/roles/sysroot/native/JS joins and negative fixtures; not a trivial 86-line production script | -| R-FACTS | Cargo inputs and exact #1213 helper semantics supported/probed | Strict full-commit release validation, no git fallback, owning adapter variant, typed facts, CLI source version and literal fallback removal | -| R-SOURCE | Existing branch/CI shape inspected; APIs supported | Protected main/approved-RC policy, trusted same-commit workflow-call validation and pending-check deadline; annotated-tag rejection | -| R-SIGN | Action inputs/toolkit predicate source-supported and shape executed | Real hosted exact-version provenance+SPDX certificates, caller permission propagation, least-privilege jobs and collector | -| R-PUBLISH | Existing public immutable release consumer path demonstrated | Authorized disposable publication rehearsal, repository settings/rules/bypass audit and RC workflow-difference/minimal-token test | -| R-INDEX | Schema and negative structural checks demonstrated | Producer and strict consumer parser; all semantic source/name/policy/digest/size joins | -| R-EVIDENCE | Existing signed bundle/release graphs observed | Full new index/executable/companion/SPDX relationship checker and actual project fixtures | -| R-POLICY | Exact tool flags/certificate JSON demonstrated | Embedded current/historical policy identity, action/caller/run checks, closed profile/action permissions | -| R-BOOTSTRAP | Auth requirement and isolated no-auth offline primitive demonstrated | Trusted gh installation, external first-use CLI verification procedure, explicit token/env/path handling | -| R-BOUNDS | Actual fixture outputs measured; limits expressible | Concurrent bounded-pipe reader, timeouts/cancel/kill/reap, strict JSON/depth/duplicate checks and negative tests | -| R-PUBLICATION | Primitive signatures work; missing name/type/immutable checks confirmed | REST plus verified-statement wrapper and source approval; cannot use command success alone | -| R-OFFLINE | Exact build proof with denied network and rejection cases demonstrated | Document transport trust/pins; official offline deployment remains intentionally unsupported | -| R-DEPLOY | Canonical byte check/source-supported transport | Shared verifier/retained handles/action validation; integrated upstream API is separate milestone | -| R-PACK | Byte-preserving pack and adversarial local envelope cases demonstrated | Allowlisted deployment-only manifest, strict bounded archive inspector and final real-module/runtime validation | -| R-CONTEXT | Exact links/selectors endpoints source-supported | Preflight/resulting-draft readbacks and explicit sharing policy; live propagation/readiness proof; secret plaintext cannot be read | -| R-EDGEZERO | Clone → exact draft update → context → stage/activate APIs supported | New typed upstream API and structured outcomes, no source/manifest hooks; current adapter does not implement it | -| R-RECOVERY | Filesystem journal design implementable; provider uncertainty boundary identified | Flush/atomic records before each write, exact-version inspection where known, unresolved clone handling/crash fixtures; no exactly-once guarantee | -| R-MANUAL | Pack/flags verified; actual wasm ran only locally | Complete verified policy/inspection instructions and separately authorized disposable Fastly production submission | -| R-FOLLOWUP | Existing adapter facts inspected with endpoint correction | Independent Cloudflare/Spin/Axum/reconstruction specs; no additional issue-closure gate here | +| Requirement | Status and evidence | Implementation or qualification still needed | +| ------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R-COHORT | New code; fixed profiles are structurally expressible | Source-controlled cohort/collector; all four canonical Build A qualification gates | +| R-PLATFORM | ARM build/run and loader floor demonstrated; GNU/Intel/hosted 15 unqualified | Actual glibc 2.34 builder and ELF symbols/UBI/Ubuntu/Debian tests; hosted ARM/Intel execution; review any baseline change before enablement | +| R-VARIANT | Supported by current Cargo/runtime source; default artifact ran locally | Release variant allowlist/diagnostic; reuse remains separate qualification | +| R-CONFIG | Source-supported separation; runtime fixture loaded independently of wasm | Whole-release differing-publisher-input negative fixture; permission-spec compatibility | +| R-INPUTS | Important inputs and native/script env evidence observed | Complete tracked source/dependency/sysroot/tool/native identities; new generated input coverage | +| R-ENV | Denied socket and selected fresh environments demonstrated | Checked release env whitelist, private gh home/state, all skip values, Cargo/config layers, Vite env suppression and source mutation guards | +| R-JS | Fresh A/B, tests, prebuilt handoff, Rollup/missing-tool probes demonstrated | Current Rust recipe proof, trusted inventory binding, final retained classification, hosted transfer | +| R-BUILD | Full frozen network-denied local builds demonstrated | Digest-pinned hosted recipes; verified fresh material extraction; every selected confinement profile and native bootstrap | +| R-REPEAT | Complete Fastly equality demonstrated locally | Separate hosted Linux jobs, final facts/source, explicit upload outputs/transfer failure gate; optional-native terminal outcomes | +| R-NATIVE | Whole ARM CLI and static native evidence demonstrated | Effective tool/SDK pins, platform linker/signature policy; GNU/Intel measurements; no native equality shipping claim | +| R-SBOM | Limited selected-unit/native serialization prototype demonstrated | Full real artifact SPDX coverage, source/license/roles/sysroot/native/JS joins and negative fixtures; real selected-artifact validation beyond prototype serialization | +| R-FACTS | Cargo inputs and exact #1213 helper semantics supported/probed | Strict full-commit release validation, no git fallback, owning adapter variant, typed facts, CLI source version and literal fallback removal | +| R-SOURCE | Existing branch/CI shape inspected; APIs supported | Protected main/approved-RC policy, trusted same-commit workflow-call validation and pending-check deadline; annotated-tag rejection | +| R-SIGN | Action inputs/toolkit predicate source-supported and shape executed | Real hosted exact-version provenance+SPDX certificates, caller permission propagation, least-privilege jobs and collector | +| R-PUBLISH | Existing public immutable release consumer path demonstrated | Authorized disposable publication rehearsal, repository settings/rules/bypass audit and RC workflow-difference/minimal-token test | +| R-INDEX | Schema and negative structural checks demonstrated | Producer and strict consumer parser; all semantic source/name/policy/digest/size joins | +| R-EVIDENCE | Existing signed bundle/release graphs observed | Full new index/executable/companion/SPDX relationship checker and actual project fixtures | +| R-POLICY | Exact tool flags/certificate JSON demonstrated | Embedded current/historical policy identity, action/caller/run checks, closed profile/action permissions | +| R-BOOTSTRAP | Auth requirement and isolated no-auth offline primitive demonstrated | Trusted gh installation, external first-use CLI verification procedure, explicit token/env/path handling | +| R-BOUNDS | Actual fixture outputs measured; limits expressible | Concurrent bounded-pipe reader, timeouts/cancel/kill/reap, strict JSON/depth/duplicate checks and negative tests | +| R-PUBLICATION | Primitive signatures work; missing name/type/immutable checks confirmed | REST plus verified-statement wrapper and source approval; cannot use command success alone | +| R-OFFLINE | Exact build proof with denied network and rejection cases demonstrated | Document transport trust/pins; official offline deployment remains intentionally unsupported | +| R-DEPLOY | Canonical byte check/source-supported transport | Shared verifier/retained handles/action validation; integrated upstream API is separate milestone | +| R-PACK | Byte-preserving pack and adversarial local envelope cases demonstrated | Allowlisted deployment-only manifest, strict bounded archive inspector and final real-module/runtime validation | +| R-CONTEXT | Exact links/selectors endpoints source-supported | Preflight/resulting-draft readbacks and explicit sharing policy; live propagation/readiness proof; secret plaintext cannot be read | +| R-EDGEZERO | Clone → exact draft update → context → stage/activate APIs supported | New typed upstream API and structured outcomes, no source/manifest hooks; current adapter does not implement it | +| R-RECOVERY | Filesystem journal design implementable; provider uncertainty boundary identified | Flush/atomic records before each write, exact-version inspection where known, unresolved clone handling/crash fixtures; no exactly-once guarantee | +| R-MANUAL | Pack/flags verified; actual wasm ran only locally | Complete verified policy/inspection instructions and separately authorized disposable Fastly production submission | +| R-FOLLOWUP | Existing adapter facts inspected with endpoint correction | Independent Cloudflare/Spin/Axum/reconstruction specs; no additional issue-closure gate here | ## Executable implementation order -The implementation must produce these concrete interfaces and evidence; this is a dependency analysis, not a claim that the named scripts already exist. +The remaining implementation MUST produce these concrete interfaces and evidence; this is a dependency analysis, not a claim that the named scripts already exist. -1. **Reconcile inputs and facts.** Merge/reconcile #1214 and #1213; add exact expected JS inventory validation, strict release fact/skip/env handling and disable the compiled CLI checkout fallback. Preserve ordinary development behavior separately. Exercise these guards before adding signing. -2. **Qualify actual platform recipes.** `scripts/release/build.sh` owns source/material preparation, effective settings, confinement and final artifacts. Linux can use a digest-pinned OCI environment with no compile network. macOS needs a tested confinement/SDK/native profile on the selected hosted machines. Material preparation and network-denied compilation are separate. Establish GNU 2.34 and macOS 15 ARM/Intel startup before approving the fixed first cohort; a failed baseline requires a reviewed change, not an after-failure downgrade. -3. **Generate actual evidence and SPDX.** Collector inputs are Cargo JSON, selected metadata/tree, checked material identities, Rollup inventory, native output/link declarations, sysroot/tools and final files. Produce prepared and retained-module lists separately. Validate real Fastly and each CLI SBOM, not only a toy fixture. Then pin the tool/generator schema and negative coverage tests. +1. **Reconcile inputs and facts.** Extend merged #1214 and #1213; add exact expected JS inventory validation, strict release fact/skip/env handling and disable the compiled CLI checkout fallback. Preserve ordinary development behavior separately. Exercise these guards before adding signing. +2. **Qualify actual platform recipes.** The approved-source native Rust producer, reached through thin `scripts/release/build.sh`/`bootstrap.sh` adapters, owns source/material preparation, effective settings, confinement and final artifacts; CLI/core/JS dependencies cannot enter the producer bootstrap. Linux can use a digest-pinned OCI environment with no compile network. macOS needs a tested confinement/SDK/native profile on the selected hosted machines. Material preparation and network-denied compilation are separate. Establish GNU 2.34 and macOS 15 ARM/Intel startup before approving the fixed first cohort; a failed baseline requires a reviewed change, not an after-failure downgrade. +3. **Generate actual evidence and SPDX.** Collector inputs are Cargo JSON, selected metadata/tree, checked material identities, Rollup inventory, native output/link declarations, sysroot/tools and final files. Produce prepared and retained-module lists separately. Validate real Fastly and each CLI SBOM, not only a toy fixture. Then qualify the Rust schema evaluator with explicit offline Draft 2020-12 and Draft 7 semantics, retain the authoritative schema raw-byte identity, and prove negative/actual coverage tests. 4. **Implement same-commit validation and transfers.** Refactor current CI into reviewed callable validation or equivalent explicit release jobs. Run required validations at the tag SHA, shared JS A/B, Fastly A/B and required native Build A with bounded optional comparisons. Upload selected raw outputs; pass artifact ID/archive hash through explicit job outputs. Fetch exact current-run ZIP bytes, fail transfer mismatch and inspect/extract only declared bounded members. Do not restore compiled state across the supposedly independent builds. 5. **Sign and publish through a tested boundary.** A separate signing/collector job authenticates source/input/output/evidence relationships before invoking the pinned action twice per artifact and signing the index. An existing lightweight tag avoids relying on automatic tag creation. Caller job permissions must allow the called signing jobs while build jobs explicitly remain read-only. The publication job handles draft/resume/upload/redownload/publish/reconcile with contents-write. Rehearse main and RC workflow divergence, partial drafts, reruns, optional failures and mutable/unknown publication before production enablement. [Release API permission behavior](https://docs.github.com/en/rest/releases/releases#create-a-release). 6. **Implement the online consumer.** A shared library performs strict schema/policy joins, invokes bounded pinned gh on retained bundles, checks all certificate/provenance/SPDX fields and verifies release API/statement semantics. Its private verified result retains selected bytes. `ts release verify` is read-only; manual packing uses that result plus a deployment-only envelope and archive inspector. Exact identity flags alone do not implement the evidence graph. @@ -112,19 +114,8 @@ The local machine had no usable Docker daemon, old-macOS or Intel execution host These gaps do not establish impossibility of the narrowed architecture; their APIs and locally measured primitives support implementation. They do prevent claiming that all required release behavior is already feasible as a tested composition. Release enablement remains blocked until those selected qualifications and the new code pass. The known unavailable-host gate and overstated existing-tool/final-JS/recovery claims have been removed or corrected rather than passed to an implementation plan as assumptions. -## Reproduction record - -Audit-session sources, scripts, signed public fixtures, logs and results are retained under the following temporary paths. They are supporting evidence for this session, not retained-input distribution promises; source hashes and limitations above identify what was tested. +## Primary implementation references -| Surface | Audit-session evidence | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Build investigation | `/tmp/pr1262-build-feasibility.md`; `/tmp/pr1262-build-js-a` and `-b`; `/tmp/pr1262-build-unit-fixture`; `/tmp/pr1262-build-native-fixture` | -| Full wasm | `/tmp/pr1262-fastly-build-probe.py`; `/tmp/pr1262-fastly-A-result.json`; `/tmp/pr1262-fastly-B-result.json`; `/tmp/pr1262-fastly-comparison.json`; `/tmp/pr1262-fastly-byte-membership.json` | -| App runtime | `/tmp/pr1262-fastly-runtime-probe.py`; `/tmp/pr1262-fastly-runtime-result.json`; generated test-only runtime fixture and logs | -| Full CLI | `/tmp/pr1262-cli-build-probe.py`; `/tmp/pr1262-cli-build-result.json`; `/tmp/pr1262-cli-byte-runtime-result.json`; `/tmp/pr1262-cli-native-composition.json`; `/tmp/pr1262-cli-init-result.json`; `/tmp/pr1262-cli-fallback-result.json` | -| Verifier/publication | `/tmp/pr1262-verifier-feasibility.md`; `/tmp/pr1262-verifier` pinned tool/source and offline/online/predicate/ambiguity probes | -| Provider interface/packing | `/tmp/pr1262-deploy-feasibility.md`; `/tmp/pr1262-fastly-source`; fresh pack negative fixtures referenced there | -| Confinement | `/tmp/pr1262-network-isolation-probe.py`; `/tmp/pr1262-network-isolation-result.json` | -| Structural validation | `/tmp/test-release-schema.py`; `/tmp/pr1262-valid-index.json`; temporary pinned JSON Schema validator | +Historical temporary probe scripts/logs are not retained or reviewer-reproducible current proof. The measurements above preserve their historical scope. Current committed Rust document/schema tests supply local utility evidence; actual selected native boundaries, complete recipes and hosted release/provider proofs remain pending. Pinned implementation references: [gh 2.97.0](https://github.com/cli/cli/tree/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55), [attest 4.2.2](https://github.com/actions/attest/tree/1e69f48acb82d1966a394da916b4c1698aa569d6), [toolkit provenance](https://github.com/actions/toolkit/blob/7987771a2b727fe14b36351f6c877fa3a56b9c36/packages/attest/src/provenance.ts), [Fastly 15.1.0 pack](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/pack.go), [validate](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/compute/validate.go), [clone](https://github.com/fastly/cli/blob/v15.1.0/pkg/commands/service/version/clone.go), [EdgeZero adapter](https://github.com/stackpop/edgezero/blob/683202c66948146ec360f490126f1d60f920288b/crates/edgezero-adapter/src/registry.rs). Cargo [metadata](https://doc.rust-lang.org/cargo/commands/cargo-metadata.html) and [build messages](https://doc.rust-lang.org/cargo/reference/external-tools.html), [SPDX 2.3 schema](https://github.com/spdx/spdx-spec/blob/v2.3/schemas/spdx-schema.json). diff --git a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md new file mode 100644 index 0000000000..c48358a52a --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md @@ -0,0 +1,55 @@ +# Release tooling Rust migration addendum + +**Status:** Draft implementation revision for PR #1262 on `spec/1235-release-attestation`. The user authorized implementation on this branch and directed removal of introduced Python tooling on 2026-10-14. This addendum implements that language constraint; maintainer contract/code approval, independently approved source and every selected release qualification remain outstanding. No new branch, worktree or PR is required. + +**Authority:** The [governing contract](2026-10-09-1235-release-attestation-design.md), [feasibility register](2026-10-12-1235-release-attestation-feasibility.md) and [22-task implementation plan](../plans/2026-10-13-attested-release-implementation.md) retain their security requirements. This migration does not amend the index schema, cohort, capabilities or vendor-source acquisition rules. + +## Decision and alternatives + +Use a small domain-specific host-only `crates/trusted-server-release` binary. It follows the existing host-only codegen workspace-member precedent, uses Rust 2024 and pinned Rust 1.95.0, and is a workspace member outside `default-members`. It has no dependency on core, CLI or JS crates and is not a fifth canonical release asset. Existing `collect-js.mjs` and the Node composition observer remain responsible for actual bundling/contribution capture; POSIX wrappers remain thin fixed adapters. + +Moving the producer into `ts` would reuse command dispatch but introduces CLI → core → JS build dependencies before JS preparation. That bootstrap cycle prevents the CLI from producing its own prerequisite JS evidence. Tasks 17–20 therefore keep `ts` as the read-only consumer. Expanding Node to own all native release security would reuse an installed runtime but duplicate native process/archive/platform enforcement and still require separately authenticated Node acquisition. A generic attestation crate adds an unsupported abstraction and unnecessary public APIs. The independent Rust tool is the smallest safe boundary for the existing producer requirements. + +## Modules and data flow + +Create only modules needed by the current migration stage. `documents`, `policy`, `preflight` and `source` establish bounded parsing and independently approved fixed inputs. `materials`, `process`, `boundary` and `preparation` authenticate original acquisitions and execute fresh work under the selected native denial boundary. `javascript` invokes retained Node tooling, records actual lifecycle observations, completes two independent offline installations/tests/builds and compares all final bundles. `qualification` checks reviewed platform evidence. `recipe`, `inspect` and `composition` drive actual Cargo compilation, final-byte/runtime acceptance and observed Rust/native/sysroot/JS evidence. `spdx`, `collection`, `distribution`, `publication` and `package` supply the remaining existing producer tasks without changing signing/publication ownership. + +The flow is independently approved source → authenticated acquisitions → current native installed-tool rehash → actual confined execution → private typed completion → checked artifact/composition/transfer records → isolated signer → sole immutable publisher. Constructors for source/acquisition approval and JS completion remain private to their successful verification/execution paths. Serialized booleans, source labels, an arbitrary inventory or a pathname plus checksum cannot construct authority. Retained bytes are rehashed at handoff/use. All-cohort remotely reviewed qualification receipts are separate from evidence about tools actually installed in the current process environment; neither substitutes for the other. + +Unit tests remain private alongside their modules or in registered child `tests.rs` files. Public command-process tests live in `tests/commands.rs`; data fixtures live in `tests/fixtures/`. Register tests before filtered runs and require a nonzero selection with the plan's POSIX `awk` guard. Do not expose internal APIs for integration tests. + +Reuse existing workspace serde/serde_json, error-stack, derive_more, clap, SHA, log, tempfile, toml, gix, libc and flate2 only where needed, with minimal dependency features. Existing bounded TAR/ZIP, Draft 2020-12/Draft 7 schema and ELF/Mach-O/core-Wasm requirements justify maintained format parsers. Exact replacement versions require primary-source research, lockfile review, acquisition identity and qualification; no library selection itself is a qualified tool pin. The researched [jsonschema 0.58.6 options](https://docs.rs/jsonschema/0.58.6/jsonschema/struct.ValidationOptions.html) are a candidate for explicit-dialect, offline-only validation with default network/file features disabled. No optional language binding, arbitrary public schema path or automatic reference retrieval is selected. + +Strict JSON parsing rejects duplicate keys through a recursive Serde visitor before insertion, limits container depth to 64, preserves original bytes for SHA-256 and enforces every role size bound before allocation/download. Before `deserialize_any`, a string-aware lexical scanner validates every numeric token across the whole document, including opaque fields and top-level values: negative integers fit `i64`, nonnegative integers fit `u64`, and decimal/exponent tokens parse as finite `f64`. Quoted number-like text and escaped string contents remain text. The visitor's finite-float check alone cannot reject integer overflow that Serde converts to a finite float; the plan's example is an ambiguity parser requiring this strict prevalidation. The existing JS build inventory scanner supplies a behavioral reference without a JS-crate dependency. Tests cover opaque/top-level integer overflow, decimal/exponent overflow and quoted-text controls; closed role checks additionally enforce safe-integer limits and negative/noninteger-length rejection. Schema checks accompany semantic joins and actual coverage. Governing schemas use Draft 2020-12; the official SPDX 2.3 schema uses Draft 7. The retained [official schema source](https://github.com/spdx/spdx-spec/blob/aadf3b0b8dbbabdb4d880b0fc714255fea436ff7/schemas/spdx-schema.json) is 45,312 bytes, SHA-256 `239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b`. This measured candidate identity is not schema-engine or real-SBOM qualification. + +## Bootstrap and unchanged release gates + +POSIX bootstrap first establishes independently approved source and an authenticated OS-native Rust source-build chain, then builds/selects the host tool and records its complete executable source/dependency/tool closure. A colocated self-authored SHA manifest does not authenticate that chain. Developer `cargo run/test` commands establish local behavior only; the source-built tool has no official release-binary proof. The producer executable is not distributed as a canonical asset. Pending approval, acquisition roots or measured pins fail closed; downloaded evidence cannot open a candidate/production selector. + +The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. + +The four canonical assets remain default Fastly Wasm plus three `ts` executables. Complete final Fastly A/B equality, both final-byte application checks and all canonical A/SPDX proofs are mandatory. Native B remains a bounded optional measurement. GNU remains glibc ceiling 2.34 with three clean compatibility runtimes; macOS requires actual native 15 ARM/Intel execution with loader floor 13.0. macOS 26.6.2 local diagnostics cannot satisfy those gates. + +Real positive/denied native socket controls and actual Cargo/npm missing-tool/download attempts use the same boundary as production execution. Source/object stores and acquired shared inputs are read-only; private profile/install/output writes remain bounded. Children and descendants are killed/reaped on timeout, cancellation or pipe limits, and temporary profiles/resources plus Docker containers are cleaned up with bounded stop/remove behavior. Rust does not waive acquisition/SDK/native archive identity, Cargo effective unit features, final retained JS classification or conservative reachability limits. Original Fastly vendor archive authentication remains a separate unsatisfied gate until independently reviewed vendor-source evidence is available. + +One `release.yml` owns publication and same-source `release-build.yml` owns signing. Exact action 4.2.2/full SHA and gh 2.97.0 contracts, SLSA Build Level 2 target, exact current-run artifact/attempt/topology bindings, strict SPDX/graph joins and immutable publication remain unchanged. `ts release verify` remains read-only. Manual Fastly consumption inspects the approved four-member archive with verified exact Wasm bytes; integrated EdgeZero deployment/recovery and future packaging retain their separate approval/proof boundaries. + +## Migration order and evidence + +The active plan keeps Task IDs 1–22 and prepends migration checkpoints: M0 verify removal/fail-closed wrappers; M1 Rust documents/preflight/source; M2 original materials/process/OS boundaries; M3 real two-installation JS completion; M4 actual canonical recipes/inspection/composition; then finish Tasks 10–22. Full AGENTS CI and independent review remain required before handoff. No resource creation/dispatch, signing, publication or provider operation follows from this language correction. + +Introduced Python producer tooling and requirements are removed. The host-only crate now implements strict document/schema utilities; Rust build-fact and checked-inventory guards and Node composition are implemented. Deleting prototype bugs does not port or fix their behavior. Task 3 source/preflight and full helper closure, M2–M4 actual execution, independently approved source/acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. + +## Mandatory replacement regressions + +Tasks 3 and M2/Tasks 7–9 MUST carry the original failure cases into real Rust tests before qualification: + +- Audit the true Git common directory for linked-worktree alternates/grafts/shallow/replacements/config/environment channels; reject unsupported linked indirections instead of partially checking them. Preserve bounded detailed Git errors. Reject untracked extensionless `rust-toolchain` before toolchain selection. Join approved source and original inventory commits. +- Audit inherited hosted/setup variables before clearing them. Exact reviewed/qualified values and path identities are required for accepted `RUSTFLAGS`, `VCPKG_INSTALLATION_ROOT` and other selectors; unreviewed values fail. Production selection MUST reject all pending/null pins by default. Named audit diagnostics cannot construct authorization state. +- Docker MUST use its default private PID namespace, not invalid `--pid=private`. Both host-owned seccomp profiles and empty Docker configuration MUST be outside container-writable roots; scan before creation and rehash both before every host command. Select authenticated client/config/Unix daemon, no TTY/plugins/context/environment overrides, read-only anchors, and reject escaping/dangling aliases in the container namespace. Actual positive/denial/whole-recipe execution is required, not argv checks alone. +- Every immutable macOS source/object regular file MUST have `nlink == 1` before execution. Actual hard-link write-through/restoration negatives are required. Signals MUST be restricted to `same-sandbox`, with own-child success and outside same-user sibling/orchestrator denial. +- Observations MUST join the approved-source catalog, selection/version/authentication identities, SDK/raw receipts and the complete compiled helper closure, including all path-dependency manifests such as `crates/trusted-server-openrtb/Cargo.toml`, tool-version files and selected executable digests. Source changes invalidate the closure. Labels, boolean claims and checksums cannot self-qualify. +- Container CPU/memory limits MUST fit actual host capacity; Cargo A jobs 4/B jobs 2 does not require four container CPUs on a two-vCPU host. Record actual errno instead of inventing `ETIMEDOUT`, hash the actual shell instead of assuming `/bin/sh --version`, and use host-independent environment/tool-path fixtures without ambient shim or interpreter trust. +- Both actual JS A/B test processes MUST unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` and execute the full registered suite/type checks. Lifecycle/Cargo/compiler/proc-macro/native children MUST inherit OS-enforced denial. Timeout/cancellation/pipe limits MUST kill and reap descendants; token/provider/injection environment is cleared. + +The [primary plan](../plans/2026-10-13-attested-release-implementation.md#threat-to-negative-proof-trace) maps these pending proofs to TH IDs and existing T-1–T-13 families. Ordinary native CI checks utility behavior and nonzero test selection; it does not qualify native bootstrap or recipes. Manual verified-byte consumption/documentation is AC-9/D6/D8; integrated context, staging, preactivation and recovery remain AC-8 and do not block the manual route. diff --git a/scripts/release/bootstrap.sh b/scripts/release/bootstrap.sh new file mode 100644 index 0000000000..9f52210efb --- /dev/null +++ b/scripts/release/bootstrap.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Rust release tooling must be source-approved before this launcher can execute it. +# Keep release execution closed while the replacement is implemented and reviewed. +set -eu +printf '%s\n' 'Release bootstrap is pending the reviewed Rust tooling replacement' >&2 +exit 1 diff --git a/scripts/release/build-js.sh b/scripts/release/build-js.sh index 9f348c5e8b..46f37e7981 100755 --- a/scripts/release/build-js.sh +++ b/scripts/release/build-js.sh @@ -1,10 +1,4 @@ #!/bin/sh -# An isolated interpreter prevents caller Python import/configuration injection. +# Use the same independently approved bootstrap boundary as the Rust recipe. set -eu -exec /usr/bin/python3 -I -B -c ' -import runpy, sys -if sys.version_info < (3, 11): - sys.exit("release qualification pending: authenticated Python 3.11+ required") -sys.argv = sys.argv[1:] -runpy.run_path(sys.argv[0], run_name="__main__") -' "${0%/*}/prepare.py" "$@" +exec /bin/sh "$(dirname "$0")/bootstrap.sh" js "$@" diff --git a/scripts/release/build.sh b/scripts/release/build.sh index 5d643a4396..e5e5dfd118 100755 --- a/scripts/release/build.sh +++ b/scripts/release/build.sh @@ -1,10 +1,4 @@ #!/bin/sh -# The bootstrap is fail closed until independently authenticated Python exists. +# Independent native/bootstrap approval precedes release-tool execution. set -eu -exec /usr/bin/python3 -I -B -c ' -import runpy, sys -if sys.version_info < (3, 11): - sys.exit("release qualification pending: authenticated Python 3.11+ required") -sys.argv = sys.argv[1:] -runpy.run_path(sys.argv[0], run_name="__main__") -' "${0%/*}/rust_recipe.py" "$@" +exec /bin/sh "$(dirname "$0")/bootstrap.sh" rust "$@" diff --git a/scripts/release/collect-js.mjs b/scripts/release/collect-js.mjs index ccfff467ff..3baffe8bef 100644 --- a/scripts/release/collect-js.mjs +++ b/scripts/release/collect-js.mjs @@ -2,14 +2,15 @@ * Produce conservative post-Rollup contributions and final post-esbuild hashes. * Later minification can eliminate recorded code; rendered_length is not final * byte attribution. This producer does not prove authenticated acquisition, - * lifecycle execution, host qualification or A/B equality. Task 7 supplies - * observed preparation records; Task 8 qualifies the execution boundary. + * lifecycle execution, host qualification or A/B equality. Preparation + * observations and execution boundary qualification remain separate requirements. */ -import fs from "node:fs"; -import path from "node:path"; -import { execFileSync } from "node:child_process"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { buildAll } from "../../crates/trusted-server-js/lib/build-all.mjs"; +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { buildAll } from '../../crates/trusted-server-js/lib/build-all.mjs' import { actualToolFiles, installedOrigins, @@ -19,57 +20,57 @@ import { readBounded, strictJson, sha256, -} from "../../crates/trusted-server-js/lib/release-composition.mjs"; +} from '../../crates/trusted-server-js/lib/release-composition.mjs' -const here = path.dirname(fileURLToPath(import.meta.url)); -const defaultRoot = path.resolve(here, "../.."); -const MAX_BUNDLE = 32 * 1024 * 1024; +const here = path.dirname(fileURLToPath(import.meta.url)) +const defaultRoot = path.resolve(here, '../..') +const MAX_BUNDLE = 32 * 1024 * 1024 /** Discover modules from the selected source identity. */ export function expectedModuleIds(repoRoot, sourceCommit) { - if (!/^[a-f0-9]{40}$/.test(sourceCommit ?? "")) - throw new Error("Expected full source commit"); + if (!/^[a-f0-9]{40}$/.test(sourceCommit ?? '')) + throw new Error('Expected full source commit') const gitEnvironment = { PATH: process.env.PATH, - TMPDIR: process.env.TMPDIR ?? "/private/tmp", - LANG: "C", - LC_ALL: "C", - GIT_CONFIG_NOSYSTEM: "1", - GIT_CONFIG_GLOBAL: "/dev/null", - GIT_NO_REPLACE_OBJECTS: "1", - GIT_OPTIONAL_LOCKS: "0", - }; + TMPDIR: os.tmpdir(), + LANG: 'C', + LC_ALL: 'C', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_NO_REPLACE_OBJECTS: '1', + GIT_OPTIONAL_LOCKS: '0', + } const raw = execFileSync( - "git", - ["ls-tree", "-rz", "--name-only", sourceCommit], + 'git', + ['ls-tree', '-rz', '--name-only', sourceCommit], { cwd: repoRoot, env: gitEnvironment, maxBuffer: 1024 * 1024, - }, - ); - const tracked = new TextDecoder("utf-8", { fatal: true }) + } + ) + const tracked = new TextDecoder('utf-8', { fatal: true }) .decode(raw) - .split("\0") + .split('\0') .filter( (file) => - file === "crates/trusted-server-js/lib/src/core/index.ts" || + file === 'crates/trusted-server-js/lib/src/core/index.ts' || /^crates\/trusted-server-js\/lib\/src\/integrations\/[^/]+\/index\.ts$/.test( - file, - ), - ); + file + ) + ) const expected = tracked .map((file) => - file.endsWith("/core/index.ts") ? "core" : file.split("/").at(-2), + file.endsWith('/core/index.ts') ? 'core' : file.split('/').at(-2) ) - .sort(); + .sort() if ( - !expected.includes("core") || + !expected.includes('core') || expected.some((id) => !/^[a-z][a-z0-9_-]*$/.test(id)) || new Set(expected).size !== expected.length ) - throw new Error("Invalid tracked module identities"); - return expected; + throw new Error('Invalid tracked module identities') + return expected } /** Build release bundles using checked observations from independent preparation. */ @@ -80,32 +81,32 @@ export async function collectJs({ preparationPath, }) { if (!preparationPath) - throw new Error("Checked preparation records are required"); + throw new Error('Checked preparation records are required') if (!releaseBuild()) - throw new Error("Collector requires the existing release build control"); - repoRoot = fs.realpathSync(repoRoot); + throw new Error('Collector requires the existing release build control') + repoRoot = fs.realpathSync(repoRoot) if (repoRoot !== fs.realpathSync(defaultRoot)) - throw new Error("Collector must execute from its own checkout"); - const sourceCommit = process.env.TRUSTED_SERVER__GIT_VERSION; - const libDir = path.join(repoRoot, "crates/trusted-server-js/lib"); - const lockFile = path.join(libDir, "package-lock.json"); - const owners = installedOrigins({ repoRoot, lockFile }); - const packages = installedMaterials(owners); - const strictPreparation = strictJson(readBounded(preparationPath)); + throw new Error('Collector must execute from its own checkout') + const sourceCommit = process.env.TRUSTED_SERVER__GIT_VERSION + const libDir = path.join(repoRoot, 'crates/trusted-server-js/lib') + const lockFile = path.join(libDir, 'package-lock.json') + const owners = installedOrigins({ repoRoot, lockFile }) + const packages = installedMaterials(owners) + const strictPreparation = strictJson(readBounded(preparationPath)) const prepared = validatePreparation(strictPreparation, { installRoot: libDir, packages, - }); + }) for (const actual of actualToolFiles({ installRoot: libDir })) { const tool = prepared.tools.find( - (record) => record.name === actual.name && record.role === actual.role, - ); + (record) => record.name === actual.name && record.role === actual.role + ) const material = prepared.materials.find( - (record) => record.id === tool?.material_id, - ); + (record) => record.id === tool?.material_id + ) const distribution = strictPreparation.distributions.find( - (record) => record.material.id === tool?.material_id, - ); + (record) => record.material.id === tool?.material_id + ) if ( !tool || tool.version !== actual.version || @@ -113,102 +114,108 @@ export async function collectJs({ fs.realpathSync(distribution.path) !== fs.realpathSync(actual.path) ) throw new Error( - `Actual build tool digest or identity mismatch: ${actual.name}`, - ); + `Actual build tool digest or identity mismatch: ${actual.name}` + ) } - fs.mkdirSync(path.resolve(outDir), { recursive: true }); - outDir = fs.realpathSync(path.resolve(outDir)); + fs.mkdirSync(path.resolve(outDir), { recursive: true }) + outDir = fs.realpathSync(path.resolve(outDir)) inventoryPath = path.join( fs.realpathSync(path.dirname(path.resolve(inventoryPath))), - path.basename(inventoryPath), - ); + path.basename(inventoryPath) + ) if ( inventoryPath === outDir || inventoryPath.startsWith(`${outDir}${path.sep}`) ) - throw new Error("Inventory must be outside the bundle directory"); - const expected = expectedModuleIds(repoRoot, sourceCommit); + throw new Error('Inventory must be outside the bundle directory') + const expected = expectedModuleIds(repoRoot, sourceCommit) const records = await buildAll({ outDir, - sourceDir: path.join(libDir, "src"), - composition: { repoRoot, lockFile }, - }); + sourceDir: path.join(libDir, 'src'), + composition: { repoRoot, lockFile, owners }, + }) if ( JSON.stringify(records.map((record) => record.id)) !== JSON.stringify(expected) ) - throw new Error("Discovered module set differs from tracked source"); - const actualFiles = fs.readdirSync(outDir).sort(); + throw new Error('Discovered module set differs from tracked source') + const actualFiles = fs.readdirSync(outDir).sort() if ( JSON.stringify(actualFiles) !== JSON.stringify(expected.map((id) => `tsjs-${id}.js`).sort()) ) - throw new Error("Unexpected bundle files"); + throw new Error('Unexpected bundle files') const modules = records.map((record) => { - const bytes = readBounded(path.join(outDir, record.filename), MAX_BUNDLE); - if (!bytes.length) throw new Error("Empty release bundle"); - new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const bytes = readBounded(path.join(outDir, record.filename), MAX_BUNDLE) + if (!bytes.length) throw new Error('Empty release bundle') + new TextDecoder('utf-8', { fatal: true }).decode(bytes) return { id: record.id, filename: record.filename, size: bytes.length, sha256: sha256(bytes), contributions: record.contributions, - }; - }); + } + }) const sources = [ - "package.json", - "package-lock.json", - "build-all.mjs", - "build-module-options.mjs", - "release-composition.mjs", - "vitest.config.ts", - ].map((file) => `crates/trusted-server-js/lib/${file}`); - sources.push("scripts/release/collect-js.mjs"); - sources.push("scripts/release/build-js.sh", "scripts/release/prepare.py"); + 'package.json', + 'package-lock.json', + 'build-all.mjs', + 'build-module-options.mjs', + 'release-composition.mjs', + 'vitest.config.ts', + 'tsconfig.json', + ].map((file) => `crates/trusted-server-js/lib/${file}`) + sources.push('scripts/release/collect-js.mjs') + sources.push( + 'scripts/release/build-js.sh', + 'scripts/release/bootstrap.sh', + 'crates/trusted-server-release/Cargo.toml', + 'crates/trusted-server-release/src/main.rs' + ) const materials = [ ...prepared.materials, ...sources.map((file) => ({ - kind: "source", + kind: 'source', id: `source:${file}`, path: file, - sha256: sha256(readBounded(path.join(repoRoot, file), MAX_BUNDLE)), + sha256: sha256(readBounded(path.join(repoRoot, file))), })), - ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) if ( new Set(materials.map((material) => material.id)).size !== materials.length ) - throw new Error("Duplicate combined material identity"); + throw new Error('Duplicate combined material identity') const inventory = { schema_version: 1, source_commit: sourceCommit, modules, tools: prepared.tools, materials, - }; - const bytes = Buffer.from(`${JSON.stringify(inventory)}\n`); - if (bytes.length > 1024 * 1024) throw new Error("Inventory exceeds 1 MiB"); - fs.writeFileSync(inventoryPath, bytes, { flag: "wx", mode: 0o600 }); - return inventory; + } + const bytes = Buffer.from(`${JSON.stringify(inventory)}\n`) + if (bytes.length > 1024 * 1024) throw new Error('Inventory exceeds 1 MiB') + fs.writeFileSync(inventoryPath, bytes, { flag: 'wx', mode: 0o600 }) + return inventory } if ( process.argv[1] && - import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href ) { - const args = process.argv.slice(2); + const args = process.argv.slice(2) if ( args.length !== 6 || - args[0] !== "--out-dir" || - args[2] !== "--inventory" || - args[4] !== "--preparation" + args[0] !== '--out-dir' || + args[2] !== '--inventory' || + args[4] !== '--preparation' ) throw new Error( - "Usage: collect-js.mjs --out-dir --inventory --preparation ", - ); + 'Usage: collect-js.mjs --out-dir --inventory --preparation ' + ) await collectJs({ outDir: args[1], inventoryPath: args[3], preparationPath: args[5], - }); + }) } diff --git a/scripts/release/common.py b/scripts/release/common.py deleted file mode 100644 index 123b47580e..0000000000 --- a/scripts/release/common.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Strict byte-oriented utilities shared by release producers and verifiers.""" - -import hashlib -import json -import math -import os -import re -import tempfile -from pathlib import Path - - -def _unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - raise ValueError(f"duplicate JSON key: {key!r}") - result[key] = value - return result - - -def load_json_bytes(data, *, max_bytes, max_depth=64): - """Decode a bounded UTF-8 JSON document without duplicate object keys. - - The caller selects the byte cap. Container depth includes the root and is - checked before JSON decoding; string content does not add depth. Raise ValueError - for invalid limits, malformed UTF-8 or JSON, nonfinite numbers, unpaired - surrogate escapes, or an exceeded bound. - """ - require_nonnegative_integer(max_bytes) - require_nonnegative_integer(max_depth) - if not isinstance(data, bytes): - raise ValueError("JSON input must be bytes") - if len(data) > max_bytes: - raise ValueError(f"JSON byte size exceeds {max_bytes}") - text = data.decode("utf-8") - _check_depth(text, max_depth) - document = json.loads( - text, - object_pairs_hook=_unique_object, - parse_constant=_reject_constant, - parse_float=_finite_float, - ) - _check_unicode(document) - return document - - -def require_nonnegative_integer(value): - """Return a nonnegative integer, rejecting booleans and other types.""" - if type(value) is not int or value < 0: - raise ValueError("value must be a nonnegative integer") - return value - - -def read_bounded(stream, *, max_bytes): - """Read a binary stream to EOF, consuming at most the limit plus one byte. - - Short reads are supported; file metadata is never trusted as a size bound. - Raise ValueError for an invalid limit, non-binary stream, or excess data. - """ - require_nonnegative_integer(max_bytes) - data = bytearray() - while True: - chunk = stream.read(min(65536, max_bytes + 1 - len(data))) - if not isinstance(chunk, bytes): - raise ValueError("stream must return binary bytes") - if not chunk: - return bytes(data) - if len(data) + len(chunk) > max_bytes: - raise ValueError(f"stream byte size exceeds {max_bytes}") - data.extend(chunk) - - -def read_json_file(path, *, max_bytes, max_depth=64): - """Read a file as bounded bytes and decode it as strict UTF-8 JSON. - - Raise ValueError for invalid limits or documents and OSError for read errors. - Callers that also need the original digest should retain read_bounded bytes - and pass the same bytes to load_json_bytes and sha256_bytes. - """ - with Path(path).open("rb") as stream: - data = read_bounded(stream, max_bytes=max_bytes) - return load_json_bytes(data, max_bytes=max_bytes, max_depth=max_depth) - - -def sha256_bytes(data): - """Return the lowercase SHA-256 digest of the exact supplied bytes.""" - if not isinstance(data, bytes): - raise ValueError("digest input must be bytes") - return hashlib.sha256(data).hexdigest() - - -def require_safe_asset_name(name): - """Return a flat ASCII asset name matching the release document contract. - - Names have at most 160 characters, start with a letter or digit, and use - letters, digits, underscores, or hyphens in nonempty dot-separated parts. - Raise ValueError for an unsafe name. - """ - if ( - not isinstance(name, str) - or len(name) > 160 - or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*", name) - ): - raise ValueError("asset name must be a safe flat ASCII filename") - return name - - -def require_unique_identities(records, fields): - """Reject repeated identity tuples selected by the caller's field names. - - Identity values must be strings or integers; missing fields and compound - values raise ValueError rather than participating in ambiguous matches. - """ - fields = tuple(fields) - seen = set() - for record in records: - if not isinstance(record, dict) or any(field not in record for field in fields): - raise ValueError("identity fields must be present in an object") - identity = tuple(record[field] for field in fields) - if any(type(value) not in (str, int) for value in identity): - raise ValueError("identity fields must be strings or integers") - if identity in seen: - raise ValueError(f"duplicate identity: {identity!r}") - seen.add(identity) - - -def deterministic_json_bytes(document): - """Serialize sorted, compact UTF-8 JSON bytes followed by one newline. - - This is a producer encoding, not a canonicalization step for verification. - Hash signed or downloaded documents using their original bytes instead. - Raise ValueError for unsupported values, nonfinite numbers, surrogate strings, - or invalid keys. - """ - try: - text = json.dumps( - document, - ensure_ascii=False, - allow_nan=False, - sort_keys=True, - separators=(",", ":"), - ) - except TypeError as error: - raise ValueError(f"invalid JSON serialization: {error}") from error - _check_unicode(document) - return (text + "\n").encode("utf-8") - - -def atomic_write(path, data): - """Replace a destination with exact bytes using a private sibling file. - - Flush and sync the temporary file before atomic replacement. On failure, - remove the temporary file and preserve the destination's existing bytes. - Raise ValueError for non-byte data and OSError for filesystem failures. - """ - if not isinstance(data, bytes): - raise ValueError("atomic write input must be bytes") - destination = Path(path) - temporary_path = None - try: - with tempfile.NamedTemporaryFile( - dir=destination.parent, prefix=f".{destination.name}.", delete=False - ) as temporary: - temporary_path = Path(temporary.name) - temporary.write(data) - temporary.flush() - os.fsync(temporary.fileno()) - os.replace(temporary_path, destination) - finally: - if temporary_path is not None: - temporary_path.unlink(missing_ok=True) - - -def _check_depth(text, max_depth): - depth = 0 - in_string = False - escaped = False - for character in text: - if in_string: - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == '"': - in_string = False - elif character == '"': - in_string = True - elif character in "[{": - depth += 1 - if depth > max_depth: - raise ValueError(f"JSON container depth exceeds {max_depth}") - elif character in "]}": - depth -= 1 - - -def _check_unicode(document): - pending = [document] - while pending: - value = pending.pop() - if isinstance(value, str): - value.encode("utf-8") - elif isinstance(value, dict): - if any(not isinstance(key, str) for key in value): - raise ValueError("JSON object keys must be strings") - pending.extend(value.keys()) - pending.extend(value.values()) - elif isinstance(value, (list, tuple)): - pending.extend(value) - - -def _reject_constant(value): - raise ValueError(f"JSON number must be finite: {value}") - - -def _finite_float(value): - number = float(value) - if not math.isfinite(number): - raise ValueError(f"JSON number must be finite: {value}") - return number diff --git a/scripts/release/inspect-native.py b/scripts/release/inspect-native.py deleted file mode 100755 index c33988020c..0000000000 --- a/scripts/release/inspect-native.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -"""Inspect bounded final CLI bytes without selecting an ambient external tool. - -This structural gate is not startup qualification. Actual clean selected runtime -startup must independently measure these same final bytes outside the checkout. -""" -import argparse -import logging -import re -import struct -import sys -from pathlib import Path - -sys.dont_write_bytecode = True -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from common import deterministic_json_bytes, sha256_bytes -from prepare import _regular_bytes - -MAX_EXECUTABLE = 536870912 -MAX_VERSION_RECORDS = 16384 - -LINUX_LIBRARIES = frozenset({"libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", - "librt.so.1", "libgcc_s.so.1", "ld-linux-x86-64.so.2"}) -MAC_LIBRARIES = frozenset({"/usr/lib/libSystem.B.dylib", "/usr/lib/libobjc.A.dylib", - "/System/Library/Frameworks/Security.framework/Versions/A/Security", - "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", - "/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration"}) - - -def _span(data, offset, size): - if type(offset) is not int or type(size) is not int or offset < 0 or size < 0 or offset + size > len(data): - raise ValueError("truncated native executable") - return data[offset:offset + size] - - -def _unpack(data, offset, format): - return struct.unpack(format, _span(data, offset, struct.calcsize(format))) - - -def _cstring(data, offset): - if offset < 0 or offset >= len(data): - raise ValueError("native string offset outside table") - end = data.find(b"\0", offset) - if end < 0 or end - offset > 4096: - raise ValueError("native string unterminated or too long") - try: - return data[offset:end].decode("utf-8") - except UnicodeError as error: - raise ValueError("native loader string must be UTF-8") from error - - -def _elf(data): - if _span(data, 0, 7) != b"\x7fELF\x02\x01\x01": - raise ValueError("ELF64 little-endian executable required") - kind, machine, version, _, phoff, _, _, ehsize, phsize, phnum, _, _, _ = _unpack(data, 16, " memsz: - raise ValueError("ELF segment file size exceeds memory size") - if tag == 1: - loads.append((offset, address, filesz)) - elif tag == 2: - dynamic.append((offset, filesz)) - elif tag == 3: - interpreters.append(_cstring(_span(data, offset, filesz), 0)) - if len(dynamic) != 1 or interpreters != ["/lib64/ld-linux-x86-64.so.2"]: - raise ValueError("ELF dynamic interpreter selection mismatch") - - def mapped(address, size): - matching = [offset + address - base for offset, base, length in loads if base <= address and address + size <= base + length] - if len(matching) != 1: - raise ValueError("ELF dynamic address is not uniquely file-backed") - return matching[0] - - offset, size = dynamic[0] - if size % 16 or size > 65536: - raise ValueError("ELF dynamic table bound exceeded") - entries = [] - for index in range(size // 16): - entry = _unpack(data, offset + 16 * index, " MAX_VERSION_RECORDS: - raise ValueError("ELF version traversal bound exceeded") - if auxiliary in seen: - raise ValueError("ELF version auxiliary cycle") - seen.add(auxiliary) - _, _, _, name, next_aux = _unpack(data, mapped(auxiliary, 16), " (2, 34, 0): - raise ValueError("GLIBC symbol ceiling exceeds 2.34") - versions.append(match[1]) - if bool(next_aux) != (member < number - 1): - raise ValueError("ELF version auxiliary membership mismatch") - auxiliary += next_aux - if bool(following) != (index < count - 1): - raise ValueError("ELF version need membership mismatch") - address += following - return {"format": "elf", "architecture": "x86_64", "interpreter": interpreters[0], - "libraries": sorted(libraries), "rpaths": paths, - "glibc_versions": sorted(set(versions), key=lambda value: tuple(map(int, value.split("."))))} - - -def _version(value): - return f"{value >> 16}.{value >> 8 & 255}.{value & 255}" - - -def _macho(data, target): - magic, cpu, _, kind, count, size, _, _ = _unpack(data, 0, " 1048576: - raise ValueError("Mach-O load command bound exceeded") - offset, libraries, rpaths, minimum, sdk, interpreters, entrypoints = 0, [], [], [], [], [], [] - for _ in range(count): - command, length = _unpack(commands, offset, "", - "builder_id": repository + "/.github/workflows/release-build.yml@refs/tags/", - "caller_path": ".github/workflows/release.yml", - "caller_repository": repository, - "source_ref": "refs/tags/", - "source_dependency": "git+" + repository + "@refs/tags/", - "source_commit": "independently-approved-40-lowercase-hex", - "signer_commit": "same-approved-source-commit", - "caller_commit": "same-approved-source-commit", - "event": "push", - "runner": "github-hosted", - "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance", - "external_parameters": "exact-caller-workflow-only-no-inputs", - "certificate_fields": [ - "buildConfigDigest", "buildConfigURI", "buildTrigger", "runInvocationURI", - "sourceRepositoryIdentifier", "sourceRepositoryOwnerIdentifier", - ], - }, - "predicates": { - "provenance": "https://slsa.dev/provenance/v1", - "build_type": "https://actions.github.io/buildtypes/workflow/v1", - "sbom": "https://spdx.dev/Document/v2.3", - }, - "tool_versions": { - "gh": "2.97.0", - "actions_attest": "4.2.2", - "actions_attest_revision": ATTEST_REVISION, - }, - "historical_producer_policies": [], - "public_actions": ["readonly-verify"], - "artifacts": [dict(artifact, actions=["readonly-verify"]) - for artifact in fixed_artifacts()], - "bounds": { - "child_seconds": 120, - "attempt_seconds": 900, - "stdout_bytes": 134217728, - "stderr_bytes": 1048576, - "metadata_bytes": 1048576, - "index_bytes": 1048576, - "policy_bytes": 1048576, - "inventory_bytes": 1048576, - "trusted_roots_bytes": 1048576, - "companion_bytes": 33554432, - "bundle_bytes": 33554432, - "spdx_bytes": 16777216, - "executable_bytes": 536870912, - "json_depth": 64, - "cohort_executables": 4, - "matching_attestations_per_bundle": 1, - }, - } - - -def _same_contract(actual, expected, label): - # Type comparison prevents Python's True == 1 from weakening JSON types. - if type(actual) is not type(expected): - raise ValueError(f"{label}: unexpected type") - if isinstance(expected, dict): - if actual.keys() != expected.keys(): - raise ValueError(f"{label}: missing or unknown fields") - for key, value in expected.items(): - _same_contract(actual[key], value, f"{label}.{key}") - elif isinstance(expected, list): - if len(actual) != len(expected): - raise ValueError(f"{label}: unexpected membership") - for index, (value, wanted) in enumerate(zip(actual, expected)): - _same_contract(value, wanted, f"{label}[{index}]") - elif actual != expected: - raise ValueError(f"{label}: unreviewed selection") - - -def validate_cohort(cohort): - """Reject unknown artifacts, selections and branch patterns with ValueError.""" - if not isinstance(cohort, dict) or set(cohort) != { - "schema_version", "cohort", "eligible_branches", "artifacts" - }: - raise ValueError("cohort: missing or unknown fields") - branches = cohort["eligible_branches"] - if (not isinstance(branches, list) or not branches - or any(not isinstance(branch, str) for branch in branches) - or branches != sorted(set(branches)) or "main" not in branches - or any(branch != "main" and not re.fullmatch( - r"rc/[0-9]{4}(?:0[1-9]|1[0-2])", branch - ) for branch in branches)): - raise ValueError("cohort: branches must be exact reviewed main/rc entries") - _same_contract(cohort, { - "schema_version": 1, - "cohort": "phase1-fastly-cli", - "eligible_branches": branches, - "artifacts": fixed_artifacts(), - }, "cohort") - - -def validate_tools(tools, *, require_qualified=False): - """Check selections, refusing release use until Task 8 qualification exists.""" - expected = fixed_tools() - # Digest strings alone do not confer qualification. Task 8 owns evidence. - for section, digest_fields in ( - ("tools", ("sha256",)), - ("environments", ("oci_index_sha256", "oci_manifest_sha256", "sdk_sha256")), - ("recipes", ("sha256",)), - ): - records = tools.get(section) if isinstance(tools, dict) else None - if not isinstance(records, list) or len(records) != len(expected[section]): - raise ValueError(f"tools.{section}: unexpected membership") - for record, wanted in zip(records, expected[section]): - if not isinstance(record, dict): - raise ValueError(f"tools.{section}: expected record") - for field in digest_fields: - if field not in wanted: - continue - digest = record.get(field) - if digest is not None and ( - not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) - ): - raise ValueError("tools: invalid measured digest") - wanted[field] = digest - _same_contract(tools, expected, "tools") - if require_qualified: - raise ValueError("release qualification pending: authenticated acquisition and Task 8 evidence required") - - -def validate_policy(policy): - """Reject consumer policy weakening or self-selected trust with ValueError.""" - _same_contract(policy, fixed_policy(), "policy") - - -def load_contracts(cohort_path, tools_path, policy_path, *, require_qualified=False): - """Load bounded strict contracts and reject incomplete release qualification.""" - cohort = read_json_file(cohort_path, max_bytes=LIMIT) - tools = read_json_file(tools_path, max_bytes=LIMIT) - policy = read_json_file(policy_path, max_bytes=LIMIT) - validate_cohort(cohort) - validate_tools(tools, require_qualified=require_qualified) - validate_policy(policy) - return cohort, tools, policy - - -def audit_environment(inherited): - """Reject unreviewed build overrides before clearing inherited values. - - Diagnostics contain variable names only. Publisher and provider values are - removed by the fresh child environment rather than interpreted as build facts. - """ - for name in inherited: - upper = name.upper() - if name == "TRUSTED_SERVER__RELEASE_BUILD" and inherited[name] != "1": - raise ValueError("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1 when present") - if upper.startswith(("TRUSTED_SERVER__", "EDGEZERO__")): - continue - if upper == "TSJS_SKIP_BUILD": - raise ValueError("TSJS_SKIP_BUILD is forbidden with every value") - forbidden_prefix = upper.startswith(( - "AWS_LC_SYS_", "CARGO", "RUST", "NODE_", "NPM_", "VITE_", - "LIBSQLITE3_", "SQLITE", "SQLCIPHER", "PKG_CONFIG", "VCPKG", - "GIT_", "PYTHON", "LD_", "DYLD_", "BINDGEN_", "CLANG_", "TSJS_", - )) - compiler = re.search( - r"(?:^|_)(?:CC|CXX|CPP|AR|AS|RANLIB|CFLAGS|CXXFLAGS|CPPFLAGS|LDFLAGS|" - r"OPENSSL|SYSROOT)(?:_|$)", upper - ) - if forbidden_prefix or compiler or upper in { - "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "CDPATH", "IFS", - "SDKROOT", "DEVELOPER_DIR", "MACOSX_DEPLOYMENT_TARGET", - "EFFECTIVE_TARGET", "TARGET", "HOST", "PROFILE", "OPT_LEVEL", - "DEBUG", "OUT_DIR", "SOURCE_DATE_EPOCH", "MAKEFLAGS", "MFLAGS", - "CMAKE_TOOLCHAIN_FILE", "CMAKE_PREFIX_PATH", "CPATH", "LIBRARY_PATH", - "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "COMPILER_PATH", "GCC_EXEC_PREFIX", - }: - raise ValueError(f"unreviewed inherited override: {name}") - - -def _commit_id(commit): - if not isinstance(commit, str) or not re.fullmatch(r"[0-9a-f]{40}", commit): - raise ValueError("source commit must be independently approved full lowercase hex") - return commit - - -def build_environment(inherited, commit, epoch, target, paths): - """Construct fresh children from recipe-owned absolute paths and facts. - - Paths must come from authenticated recipe selection, never ambient PATH or - downloaded descriptors. This structural helper does not qualify those paths. - """ - audit_environment(inherited) - _commit_id(commit) - require_nonnegative_integer(epoch) - if target not in (*PLATFORMS, "wasm32-wasip1"): - raise ValueError("unknown release target") - if not isinstance(paths, dict) or set(paths) != { - "home", "cargo_home", "rustup_home", "tmpdir", "tool_path", "sysroot" - }: - raise ValueError("explicit authenticated recipe paths required") - selected = list(paths.values()) - tool_path = paths["tool_path"] - if not isinstance(tool_path, list) or not tool_path: - raise ValueError("explicit tool path directories required") - selected.remove(tool_path) - selected.extend(tool_path) - if any(not isinstance(path, str) or not Path(path).is_absolute() - or not Path(path).is_dir() for path in selected): - raise ValueError("recipe paths must be existing absolute directories") - for filename in ("config", "config.toml"): - if (Path(paths["cargo_home"]) / filename).exists(): - raise ValueError("private Cargo home must not supply unreviewed configuration") - environment = { - "HOME": paths["home"], - "CARGO_HOME": paths["cargo_home"], - "RUSTUP_HOME": paths["rustup_home"], - "TMPDIR": paths["tmpdir"], - "PATH": os.pathsep.join(tool_path), - "LANG": "C", - "LC_ALL": "C", - "TZ": "UTC", - "SOURCE_DATE_EPOCH": str(epoch), - "TRUSTED_SERVER__GIT_VERSION": commit, - "TRUSTED_SERVER__RELEASE_BUILD": "1", - "AWS_LC_SYS_USE_SYSTEM": "0", - f"AWS_LC_SYS_USE_SYSTEM_{target.replace('-', '_')}": "0", - "LIBSQLITE3_SYS_USE_PKG_CONFIG": "0", - "NODE_ENV": "production", - "PYTHONDONTWRITEBYTECODE": "1", - } - if target.endswith("apple-darwin"): - environment["SDKROOT"] = paths["sysroot"] - environment["MACOSX_DEPLOYMENT_TARGET"] = "13.0" - return environment - - -def _git(repo, arguments, data=None): - # Never inherit replacement objects, config injection, loaders or credentials. - environment = { - "PATH": "/usr/bin:/bin", - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_CONFIG_GLOBAL": os.devnull, - "GIT_NO_REPLACE_OBJECTS": "1", - "LC_ALL": "C", - } - result = subprocess.run( - ["/usr/bin/git", "--no-replace-objects", "-C", str(repo), *arguments], - input=data, env=environment, capture_output=True, timeout=120, check=False, - ) - if result.returncode: - raise ValueError("Git source inspection failed") - return result.stdout - - -def read_git_objects(repo, objects, *, max_object=GIT_OBJECT_LIMIT, - max_total=GIT_TOTAL_LIMIT, max_members=GIT_MEMBER_LIMIT): - """Check pinned object sizes before capturing any selected Git bodies. - - Use the sanitized trusted Git executable and immutable object store, never - replacement/alternate channels. At most 256 validated full OIDs are queried - per metadata request; Git's fixed SHA-1/type/64-bit-size format bounds each - response. Reject oversized objects or aggregate bodies before requesting - them, then rejoin body headers to the measured OID, type and size. This is - metadata prechecking, not race-proof streaming against a mutable/corrupt - object store. Raise ValueError for invalid pins, metadata, limits or bodies. - """ - for bound in (max_object, max_total, max_members): - require_nonnegative_integer(bound) - if not isinstance(objects, dict) or len(objects) > max_members: - raise ValueError("source object member bound exceeded") - for oid, kind in objects.items(): - if (not isinstance(oid, str) or not re.fullmatch(r"[0-9a-f]{40}", oid) - or not isinstance(kind, str) or kind not in {"blob", "tree", "commit"}): - raise ValueError("invalid pinned source object identity") - selected = list(objects) - sizes = {} - total = 0 - for start in range(0, len(selected), GIT_METADATA_BATCH): - batch = selected[start:start + GIT_METADATA_BATCH] - metadata = _git(repo, ["cat-file", "--batch-check"], - "".join(oid + "\n" for oid in batch).encode("ascii")) - if len(metadata) > 80 * len(batch): - raise ValueError("source object metadata bound exceeded") - lines = metadata.splitlines() - if len(lines) != len(batch): - raise ValueError("source object metadata membership mismatch") - for oid, line in zip(batch, lines): - fields = line.decode("ascii").split() - if (len(fields) != 3 or fields[:2] != [oid, objects[oid]] - or not re.fullmatch(r"[0-9]+", fields[2])): - raise ValueError("source object metadata identity/type/size mismatch") - size = int(fields[2]) - total += size - if size > max_object or total > max_total: - raise ValueError("source object size bound exceeded") - sizes[oid] = size - if not selected: - return {} - bodies = io.BytesIO(_git(repo, ["cat-file", "--batch"], - "".join(oid + "\n" for oid in selected).encode("ascii"))) - result = {} - for oid, size in sizes.items(): - header = bodies.readline().decode("ascii").strip().split() - if header != [oid, objects[oid], str(size)]: - raise ValueError("source object body disagrees with checked metadata") - content = bodies.read(size) - if len(content) != size or bodies.read(1) != b"\n": - raise ValueError("incomplete source object body") - result[oid] = content - if bodies.read(1): - raise ValueError("extra source object bodies") - return result - - -def _audit_configuration(repo, inherited): - configuration = _git(repo, ["config", "--local", "--no-includes", "--name-only", "--list"]) - for key in configuration.decode("utf-8").splitlines(): - if key.startswith(("include.", "includeif.", "filter.", "extensions.")) or key in { - "core.worktree", "core.fsmonitor", "core.attributesfile", "core.sshcommand", - }: - raise ValueError("unreviewed Git configuration channel") - if _git(repo, ["for-each-ref", "--format=%(refname)", "refs/replace/"]).strip(): - raise ValueError("Git replacement references are forbidden") - git_directory = Path(_git(repo, ["rev-parse", "--absolute-git-dir"]).decode().strip()) - for relative in ("objects/info/alternates", "info/grafts"): - if (git_directory / relative).exists(): - raise ValueError("Git alternative object or graft channel is forbidden") - locations = list(repo.parents) - home = inherited.get("HOME") - if home: - locations.append(Path(home)) - for location in locations: - if location == repo: - continue - for filename in ("config", "config.toml"): - if os.path.lexists(location / ".cargo" / filename): - raise ValueError("unreviewed ancestor/home Cargo configuration") - crate_locations = [repo / "crates", repo / "crates/trusted-server-js/lib"] - crate_locations.extend(path for path in repo.glob("crates/*") if path.is_dir()) - for location in crate_locations: - configuration_directory = location / ".cargo" - if configuration_directory.is_symlink(): - raise ValueError("unsupported nested Cargo configuration symlink") - for filename in ("config", "config.toml"): - if os.path.lexists(configuration_directory / filename): - raise ValueError("unsupported nested workspace Cargo configuration layer") - if os.path.lexists(repo / ".cargo/config"): - raise ValueError("unreviewed legacy Cargo configuration layer") - selected = repo / ".cargo/config.toml" - if selected.exists(): - configuration = tomllib.loads(selected.read_text(encoding="utf-8")) - if set(configuration) - {"alias", "target"}: - raise ValueError("Cargo release configuration may select only reviewed aliases and test runners") - aliases = configuration.get("alias", {}) - targets = configuration.get("target", {}) - if not isinstance(aliases, dict) or any(not isinstance(value, str) for value in aliases.values()): - raise ValueError("Cargo aliases must be strings") - if not isinstance(targets, dict) or any( - name != 'cfg(all(target_arch = "wasm32"))' - or not isinstance(settings, dict) or set(settings) != {"runner"} - or settings["runner"] != "viceroy run -C ../../fastly.toml -- " - for name, settings in targets.items() - ): - raise ValueError("unreviewed Cargo target compiler/linker settings") - - -def _source_roots(repo, entries): - roots = {".cargo", "scripts/release", "crates/trusted-server-js/lib"} - for name in entries: - parts = Path(name).parts - if parts[0] == "crates": - for directory in SOURCE_DIRECTORIES: - if directory in parts: - roots.add(str(Path(*parts[:parts.index(directory) + 1]))) - crates = repo / "crates" - if crates.exists(): - if crates.is_symlink(): - raise ValueError("symlinked source parent") - for crate in crates.iterdir(): - if crate.is_symlink(): - raise ValueError("symlinked source crate") - if crate.is_dir(): - for directory in SOURCE_DIRECTORIES: - if os.path.lexists(crate / directory): - roots.add(str((crate / directory).relative_to(repo))) - return roots - - -def _check_source_parents(repo, path): - for parent in path.parents: - if parent == repo: - break - if parent.is_symlink(): - raise ValueError("symlinked source parent") - - -def _unexpected_sources(repo, entries): - """Audit all JS validation sources, separating direct npm build materials. - - Prepared node_modules contents require a separate material audit. Cargo's - exact empty npm-ci lock is coordination state; other lib files are sources. - """ - library = repo / "crates/trusted-server-js/lib" - for relative in sorted(_source_roots(repo, entries)): - root = repo / relative - _check_source_parents(repo, root) - if root.is_symlink(): - raise ValueError("symlinked source directory") - if not root.exists(): - continue - for directory, children, filenames in os.walk(root, followlinks=False): - for name in children + filenames: - path = Path(directory) / name - relative_path = str(path.relative_to(repo)) - if path == library / "node_modules": - if not stat.S_ISDIR(path.lstat().st_mode): - raise ValueError("direct node_modules must be a regular directory") - children.remove(name) - continue - if path == library / ".tsjs-npm-ci.lock": - information = path.lstat() - if (not stat.S_ISREG(information.st_mode) - or information.st_size != 0 or information.st_nlink != 1): - raise ValueError(".tsjs-npm-ci.lock must be an empty single-link regular file") - continue - if path.is_symlink() and relative_path not in entries: - raise ValueError("unexpected source symlink") - if not path.is_dir() and relative_path not in entries: - raise ValueError(f"unexpected source-only file: {relative_path}") - # Root build scripts and JS discovery/configuration scripts are compile inputs. - for directory in [repo, *repo.glob("crates/*"), repo / "crates/trusted-server-js/lib"]: - if not directory.is_dir(): - continue - for path in directory.iterdir(): - if path.suffix in {".rs", ".mjs", ".cjs", ".js", ".ts"} or path.name in { - "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "package.json", "package-lock.json", - ".npmrc", - }: - if str(path.relative_to(repo)) not in entries: - raise ValueError("unexpected build source/configuration file") - - -def source_snapshot(repo, commit, inherited): - """Compare bytes, modes and links to the committed tree, ignoring index flags. - - Raise ValueError for mutations, unreviewed source additions, Git/Cargo input - channels or a revision that is not an actual approved full commit object. - """ - audit_environment(inherited) - _commit_id(commit) - repo = Path(repo).absolute() - if repo.is_symlink(): - raise ValueError("symlinked source checkout") - # OS aliases such as /var -> /private/var are outside the source boundary. - repo = repo.resolve() - _audit_configuration(repo, inherited) - if _git(repo, ["cat-file", "-t", commit]).strip() != b"commit": - raise ValueError("source revision must be an actual commit object") - entries = {} - for record in _git(repo, ["ls-tree", "-rz", "--full-tree", commit]).split(b"\0"): - if not record: - continue - identity, filename = record.split(b"\t", 1) - mode, kind, object_id = identity.decode("ascii").split() - name = filename.decode("utf-8") - if kind != "blob" or mode not in {"100644", "100755", "120000"}: - raise ValueError("unsupported source tree type or mode") - if Path(name).is_absolute() or ".." in Path(name).parts: - raise ValueError("unsafe source tree path") - entries[name] = (mode, object_id) - _unexpected_sources(repo, entries) - objects = {commit: "commit"} - objects.update({oid: "blob" for _, oid in entries.values()}) - blobs = read_git_objects(repo, objects) - # Read the selected object directly; tracked snapshots need no parent history. - header = blobs[commit].split(b"\n\n", 1)[0] - committers = [line for line in header.splitlines() if line.startswith(b"committer ")] - if len(committers) != 1 or not re.search(rb" [0-9]+ [+-][0-9]{4}$", committers[0]): - raise ValueError("invalid committed source timestamp") - epoch = int(committers[0].rsplit(b" ", 2)[1]) - require_nonnegative_integer(epoch) - files = {} - for name, (mode, object_id) in entries.items(): - expected = blobs[object_id] - path = repo / name - _check_source_parents(repo, path) - try: - information = path.lstat() - except FileNotFoundError as error: - raise ValueError("missing committed source file") from error - if mode == "120000": - if not stat.S_ISLNK(information.st_mode): - raise ValueError("source link mode changed") - actual = os.fsencode(os.readlink(path)) - if not path.resolve().is_relative_to(repo): - raise ValueError("source link escapes checkout") - else: - if not stat.S_ISREG(information.st_mode): - raise ValueError("source file mode changed") - expected_execute = mode == "100755" - if bool(information.st_mode & stat.S_IXUSR) != expected_execute: - raise ValueError("source executable mode changed") - actual = path.read_bytes() - if actual != expected: - raise ValueError(f"committed source bytes changed: {name}") - files[name] = { - "mode": mode, - "permissions": stat.S_IMODE(information.st_mode), - "sha256": sha256_bytes(actual), - } - return {"source_commit": commit, "source_date_epoch": epoch, "files": files} - - -def assert_source_unchanged(repo, commit, before, inherited): - """Repeat the committed-tree check after execution and reject any mutation.""" - if source_snapshot(repo, commit, inherited) != before: - raise ValueError("source changed during release execution") - - -def _validate_build_inputs(repo): - workspace = tomllib.loads((repo / "Cargo.toml").read_text(encoding="utf-8")) - release = workspace.get("profile", {}).get("release", {}) - if type(release.get("debug")) is not int or release["debug"] != 1: - raise ValueError("source release profile must retain debug=1") - toolchain = tomllib.loads((repo / "rust-toolchain.toml").read_text(encoding="utf-8")) - if toolchain.get("toolchain", {}).get("channel") != VERSIONS["rust"]: - raise ValueError("source Rust toolchain pin disagrees with release contract") - versions = {} - for line in (repo / ".tool-versions").read_text(encoding="utf-8").splitlines(): - if not line.strip() or line.lstrip().startswith("#"): - continue - fields = line.split() - if len(fields) != 2 or fields[0] in versions: - raise ValueError("ambiguous source tool version pin") - versions[fields[0]] = fields[1] - for name, key in (("rust", "rust"), ("node", "nodejs"), ("fastly", "fastly"), - ("viceroy", "viceroy"), ("wasmtime", "wasmtime")): - if versions.get(key) != VERSIONS[name]: - raise ValueError("source tool pin disagrees with release contract") - for artifact in fixed_artifacts(): - manifest = tomllib.loads((repo / "crates" / artifact["package"] / "Cargo.toml").read_text(encoding="utf-8")) - if manifest.get("package", {}).get("name") != artifact["package"]: - raise ValueError("source package identity disagrees with release contract") - binaries = manifest.get("bin", []) - if binaries and not any(binary.get("name") == artifact["bin"] for binary in binaries): - raise ValueError("source binary identity disagrees with release contract") - if manifest.get("features", {}).get("default", []): - raise ValueError("source default features require release contract review") - - -def preflight(repo, commit, *, inherited=None, cohort_path=None, require_qualified=False): - """Validate source-owned inputs and report exact contract byte digests. - - Structural success is explicitly unqualified. Release callers must require - qualification; this gate fails before any preparation or signing is possible. - """ - inherited = dict(os.environ) if inherited is None else inherited - audit_environment(inherited) - _commit_id(commit) - repo = Path(repo).resolve() - selected_cohort = repo / "scripts/release/release-cohort.json" - if cohort_path is not None and Path(cohort_path).resolve() != selected_cohort: - raise ValueError("cohort must be the fixed source-owned contract path") - before = source_snapshot(repo, commit, inherited) - _validate_build_inputs(repo) - paths = ( - selected_cohort, - repo / "scripts/release/release-tools.json", - repo / "crates/trusted-server-cli/release-policy.json", - ) - for path in paths: - if str(path.relative_to(repo)) not in before["files"] or path.is_symlink(): - raise ValueError("release contracts must be tracked regular source files") - cohort, _, _ = load_contracts(*paths, require_qualified=require_qualified) - digests = {str(path.relative_to(repo)): sha256_bytes(path.read_bytes()) for path in paths} - assert_source_unchanged(repo, commit, before, inherited) - return { - "source_commit": commit, - "source_date_epoch": before["source_date_epoch"], - "cohort": cohort["cohort"], - "contract_sha256": digests, - "qualified": False, - "qualification": "pending-authenticated-acquisition-and-hosted-recipes", - } - - -def main(): - """Run the read-only preflight command, returning nonzero on any failure.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo", type=Path, default=Path(__file__).resolve().parents[2]) - parser.add_argument("--cohort", type=Path, required=True) - parser.add_argument("--source-commit", required=True) - parser.add_argument("--require-qualified", action="store_true") - arguments = parser.parse_args() - try: - result = preflight( - arguments.repo, arguments.source_commit, cohort_path=arguments.cohort, - require_qualified=arguments.require_qualified, - ) - except (ValueError, OSError, subprocess.SubprocessError) as error: - logging.error("Release preflight fails: %s", error) - return 1 - sys.stdout.buffer.write(deterministic_json_bytes(result)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/release/prepare.py b/scripts/release/prepare.py deleted file mode 100644 index 8154cfa012..0000000000 --- a/scripts/release/prepare.py +++ /dev/null @@ -1,808 +0,0 @@ -"""Authenticate materials and compare independently prepared JS installations. - -The official CLI remains closed while qualification is pending. LocalMacBoundary -and local_js_pair are explicitly local fixture APIs: they provide actual denial -and execution observations, never authenticated host/tool acquisition evidence. -""" - -import argparse -import base64 -import hashlib -import gzip -import io -import logging -import os -import re -import shutil -import stat -import subprocess -import sys -import tarfile -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from urllib.parse import urlsplit - -sys.dont_write_bytecode = True -# -I excludes both caller Python paths and this script directory. -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from common import (deterministic_json_bytes, load_json_bytes, read_bounded, - read_json_file, require_nonnegative_integer, sha256_bytes) -import preflight - -MAX_JSON = 1048576 -MAX_FILE = 33554432 -MAX_ARCHIVE = 134217728 -MAX_EXPANDED = 536870912 -MAX_MEMBERS = 100000 -LIB = Path("crates/trusted-server-js/lib") -HOOKS = ("preinstall", "install", "postinstall") -ROOT_HOOKS = (*HOOKS, "prepublish", "preprepare", "prepare", "postprepare") -REVIEWED_HOOKS = { - ("esbuild", "0.27.3", "postinstall"): "node install.js", - ("core-js", "3.48.0", "postinstall"): 'node -e "try{require(\'./postinstall\')}catch(e){}"', -} -# These exact authenticated npm archives contain one identical redundant file. -# The two raw paths are package/./dist/index.js and package/dist/index.js. -# Unknown archives, unequal bytes/modes/types and additional duplicates fail. -NPM_ALIAS_ARCHIVES = { - "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==": ("agent-base", "7.1.4"), - "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==": ("http-proxy-agent", "7.0.2"), - "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==": ("https-proxy-agent", "7.0.6"), -} - - -@dataclass(frozen=True) -class VerifiedArchive: - """Authenticated original archive identity plus its checked member catalog.""" - - path: Path - integrity: str - algorithm: str - root: str - paths: tuple - manifest: dict | None - - -def _regular_bytes(path, maximum): - path = Path(path) - try: - descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) - except OSError as error: - raise ValueError("material must be a regular file without a symlink") from error - with os.fdopen(descriptor, "rb") as stream: - before = os.fstat(stream.fileno()) - identity = lambda value: (value.st_dev, value.st_ino, value.st_mode, value.st_nlink, - value.st_size, value.st_mtime_ns, value.st_ctime_ns) - if not stat.S_ISREG(before.st_mode) or identity(path.lstat()) != identity(before): - raise ValueError("material must be a regular file without a symlink") - data = read_bounded(stream, max_bytes=maximum) - after = os.fstat(stream.fileno()) - if identity(before) != identity(after) or identity(path.lstat()) != identity(after): - raise ValueError("material changed during measurement") - return data - - -def _authenticated_bytes(path, integrity, algorithm, max_bytes): - data = _regular_bytes(path, max_bytes) - if algorithm == "sha512": - if not isinstance(integrity, str) or not re.fullmatch(r"sha512-[A-Za-z0-9+/]{86}==", integrity): - raise ValueError("archive integrity must be locked SHA-512") - expected = base64.b64decode(integrity[7:], validate=True) - actual = hashlib.sha512(data).digest() - elif algorithm == "sha256": - if not isinstance(integrity, str) or not re.fullmatch(r"[0-9a-f]{64}", integrity): - raise ValueError("archive integrity must be locked SHA-256") - expected = bytes.fromhex(integrity) - actual = hashlib.sha256(data).digest() - else: - raise ValueError("unsupported archive integrity algorithm") - if actual != expected: - raise ValueError("archive integrity mismatch") - return data - - -def _approved_alias(integrity, first, second, first_bytes, second_bytes): - return (integrity in NPM_ALIAS_ARCHIVES and first.isfile() and second.isfile() - and {first.name, second.name} == {"package/./dist/index.js", "package/dist/index.js"} - and (first.size, first.mode, first.type) == (second.size, second.mode, second.type) - and len(first_bytes) == len(second_bytes) == first.size and first_bytes == second_bytes) - - -def _archive_catalog(data, max_expanded, max_members, integrity=None): - seen = {} - roots = set() - total = 0 - member_count = 0 - merged_alias = False - # Bound the entire decompressed tar, including PAX/GNU metadata, before parsing. - with gzip.GzipFile(fileobj=io.BytesIO(data)) as compressed: - expanded = read_bounded(compressed, max_bytes=max_expanded) - with tarfile.open(fileobj=io.BytesIO(expanded), mode="r:") as stream: - for member in stream: - name = member.name.rstrip("/") if member.isdir() else member.name - parts = name.split("/") - if (not name or "\\" in name or "\0" in name or name.startswith("/") - or any(part in {"", ".."} for part in parts)): - raise ValueError("unsafe archive member path") - parts = [part for part in parts if part != "."] - if not parts: - raise ValueError("unsafe archive member path") - name = "/".join(parts) - raw_name = member.name.rstrip("/") if member.isdir() else member.name - if raw_name != name and not ( - integrity in NPM_ALIAS_ARCHIVES and member.name == "package/./dist/index.js"): - raise ValueError("unknown archive path alias") - member_count += 1 - total += member.size - if total > max_expanded or member_count > max_members: - raise ValueError("archive expanded size or member bound exceeded") - if not (member.isdir() or member.isfile()): - raise ValueError("unsupported archive member") - if member.mode & 0o7000: - raise ValueError("privileged archive mode is forbidden") - if name in seen: - previous = seen[name] - if (merged_alias or not previous.isfile() or not member.isfile() or not _approved_alias( - integrity, previous, member, - read_bounded(stream.extractfile(previous), max_bytes=MAX_FILE), - read_bounded(stream.extractfile(member), max_bytes=MAX_FILE))): - raise ValueError("duplicate archive member") - merged_alias = True - continue - seen[name] = member - roots.add(parts[0]) - for name, member in seen.items(): - for parent in PurePosixPath(name).parents: - if str(parent) in seen and not seen[str(parent)].isdir(): - raise ValueError("archive file/directory collision") - if len(roots) != 1: - raise ValueError("archive must have one root") - root = next(iter(roots)) - if root in seen and not seen[root].isdir(): - raise ValueError("archive root must be a directory") - manifest_member = seen.get(root + "/package.json") - manifest = None - if manifest_member is not None: - if not manifest_member.isfile(): - raise ValueError("archive manifest must be a regular file") - manifest = load_json_bytes(read_bounded(stream.extractfile(manifest_member), max_bytes=MAX_JSON), - max_bytes=MAX_JSON) - if not isinstance(manifest, dict): - raise ValueError("archive manifest must be an object") - if integrity in NPM_ALIAS_ARCHIVES and (not merged_alias or manifest is None or - (manifest.get("name"), manifest.get("version")) != NPM_ALIAS_ARCHIVES[integrity]): - raise ValueError("pinned npm archive alias identity mismatch") - return root, seen, manifest - - -def verify_archive(path, integrity, *, algorithm="sha512", expected_root=None, - max_bytes=MAX_ARCHIVE, max_expanded=MAX_EXPANDED, max_members=MAX_MEMBERS): - """Authenticate raw registry bytes before bounded safe tar inspection. - - SHA-256 and an exact name-version root also support Cargo registry materials. - No extraction, trust in restored trees, or checksum rewriting occurs here. - Raise ValueError for invalid identities, unsafe members or exceeded bounds. - """ - data = _authenticated_bytes(path, integrity, algorithm, max_bytes) - root, members, manifest = _archive_catalog(data, max_expanded, max_members, integrity) - if expected_root is not None and root != expected_root: - raise ValueError("archive root mismatch") - return VerifiedArchive(Path(path).absolute(), integrity, algorithm, root, - tuple(sorted(name[len(root) + 1:] for name in members if name != root)), manifest) - - -def materialize_archive(checked, destination): - """Extract authenticated bytes into a new tree, rejecting stale destinations.""" - destination = Path(destination) - if os.path.lexists(destination): - raise ValueError("archive destination must be fresh") - data = _authenticated_bytes(checked.path, checked.integrity, checked.algorithm, MAX_ARCHIVE) - root, catalog, _ = _archive_catalog(data, MAX_EXPANDED, MAX_MEMBERS, checked.integrity) - if root != checked.root: - raise ValueError("archive root changed") - destination.mkdir(parents=True) - with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as stream: - for name, member in sorted(catalog.items()): - if name == root: - continue - path = destination / name[len(root) + 1:] - if member.isdir(): - path.mkdir(parents=True, exist_ok=True) - else: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("xb") as output: - shutil.copyfileobj(stream.extractfile(member), output, 65536) - path.chmod(0o755 if member.mode & 0o111 else 0o644) - - -def verify_git_revision(repo, revision, expected_tree=None): - """Validate exact Git revision/tree with no replacement/configuration channels.""" - preflight._commit_id(revision) - repo = Path(repo).resolve() - configuration = preflight._git(repo, ["config", "--local", "--no-includes", "--name-only", "--list"]) - for key in configuration.decode("utf-8").splitlines(): - if key.startswith(("include.", "includeif.", "filter.", "extensions.", "credential.")) or key in { - "core.worktree", "core.fsmonitor", "core.attributesfile", "core.sshcommand", "core.hookspath", - }: - raise ValueError("unreviewed Git configuration channel") - if preflight._git(repo, ["for-each-ref", "--format=%(refname)", "refs/replace/"]).strip(): - raise ValueError("Git replacement references are forbidden") - directory = Path(preflight._git(repo, ["rev-parse", "--absolute-git-dir"]).decode().strip()) - for name in ("objects/info/alternates", "info/grafts", "shallow"): - if os.path.lexists(directory / name): - raise ValueError("Git alternates, grafts and shallow revisions are forbidden") - if preflight._git(repo, ["cat-file", "-t", revision]).strip() != b"commit": - raise ValueError("Git revision must be an actual commit") - tree = preflight._git(repo, ["rev-parse", revision + "^{tree}"]).decode().strip() - if expected_tree is not None and expected_tree != tree: - raise ValueError("Git revision tree mismatch") - return tree - - -def materialize_source(repo, revision, destination): - """Export tracked object bytes/modes, with a sanitized private Git object store. - - Ignore archive attributes, index flags, ambient files and ambient Git metadata. - Only the fixed CLAUDE.md -> AGENTS.md link is allowed. Raise ValueError for - unsupported modes, unsafe paths, stale destinations or object disagreement. - """ - destination = Path(destination) - if os.path.lexists(destination): - raise ValueError("source destination must be fresh") - tree = verify_git_revision(repo, revision) - entries = [] - for raw in preflight._git(repo, ["ls-tree", "-rtz", "--full-tree", revision]).split(b"\0"): - if not raw: - continue - identity, filename = raw.split(b"\t", 1) - mode, kind, oid = identity.decode("ascii").split() - name = filename.decode("utf-8") - if (kind not in {"blob", "tree"} or mode not in {"040000", "100644", "100755", "120000"} - or name.startswith("/") or "\\" in name or any(part in {"", ".", "..", ".git"} - for part in name.split("/"))): - raise ValueError("unsupported source tree entry") - entries.append((mode, kind, oid, name)) - objects = {revision: "commit", tree: "tree"} - objects.update({oid: kind for _, kind, oid, _ in entries}) - blobs = preflight.read_git_objects(repo, objects) - for mode, _, oid, name in entries: - if mode == "120000" and (name != "CLAUDE.md" or blobs[oid] != b"AGENTS.md" - or not any(entry[3] == "AGENTS.md" and entry[1] == "blob" for entry in entries)): - raise ValueError("unsupported source link") - destination.mkdir(parents=True) - preflight._git(destination, ["init", "--template="]) - for oid, kind in objects.items(): - actual = preflight._git(destination, ["hash-object", "-w", "-t", kind, "--stdin"], blobs[oid]) - if actual.strip().decode("ascii") != oid: - raise ValueError("private Git object disagreement") - for mode, kind, oid, name in entries: - if kind == "tree": - continue - path = destination / name - path.parent.mkdir(parents=True, exist_ok=True) - if mode == "120000": - path.symlink_to("AGENTS.md") - else: - path.write_bytes(blobs[oid]) - path.chmod(0o755 if mode == "100755" else 0o644) - preflight._git(destination, ["update-ref", "HEAD", revision]) - return source_check(destination, revision) - - -def source_check(repo, revision, before=None): - """Check all tracked sources plus unexpected tests, data and configuration.""" - snapshot = preflight.source_snapshot(Path(repo), revision, {}) - expected = set(snapshot["files"]) - for directory, children, files in os.walk(repo, followlinks=False): - directory = Path(directory) - if directory == Path(repo): - children[:] = [name for name in children if name != ".git"] - if directory == Path(repo) / LIB: - children[:] = [name for name in children if name != "node_modules"] - for name in [*children, *files]: - path = directory / name - if path.is_file() or path.is_symlink(): - if str(path.relative_to(repo)) not in expected: - raise ValueError("unexpected source file") - if before is not None and snapshot != before: - raise ValueError("source changed during release execution") - return snapshot - - -def review_lifecycle(manifest, *, root=False, paths=()): - """Reject unreviewed commands and implicit native installs before npm runs.""" - scripts = manifest.get("scripts", {}) - if not isinstance(scripts, dict): - raise ValueError("lifecycle scripts require review") - if "binding.gyp" in paths and not scripts.get("install") and not scripts.get("preinstall"): - raise ValueError("unsupported implicit native lifecycle") - reviewed = {} - for event in ROOT_HOOKS if root else HOOKS: - command = scripts.get(event) - if command is None: - continue - if (root or not isinstance(command, str) or not command or - REVIEWED_HOOKS.get((manifest.get("name"), manifest.get("version"), event)) != command): - raise ValueError("lifecycle command requires explicit review") - reviewed[event] = command - return reviewed - - -def required_npm_materials(lock, platform, architecture): - """Select current-platform optional packages with mandatory native tools.""" - if platform not in {"darwin", "linux"} or architecture not in {"arm64", "x64"}: - raise ValueError("unsupported native platform") - if lock.get("lockfileVersion") != 3 or not isinstance(lock.get("packages"), dict): - raise ValueError("expected npm lock v3") - native = {f"node_modules/@esbuild/{platform}-{architecture}", - f"node_modules/@rollup/rollup-{platform}-{architecture}" + ("-gnu" if platform == "linux" else "")} - if not native.issubset(lock["packages"]): - raise ValueError("required native tools missing from lock") - selected = {} - for location, record in lock["packages"].items(): - if not location: - continue - if not re.fullmatch(r"node_modules/(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9._-]+" - r"(?:/node_modules/(?:@[A-Za-z0-9._-]+/)?[A-Za-z0-9._-]+)*", location): - raise ValueError("unsupported npm lock path") - url = urlsplit(record.get("resolved", "")) - if (record.get("link") is not None or url.scheme != "https" or url.netloc != "registry.npmjs.org" - or url.query or url.fragment or not url.path.endswith(".tgz")): - raise ValueError("unsupported npm registry source") - for selector, wanted in (("os", platform), ("cpu", architecture), ("libc", "glibc")): - values = record.get(selector, []) - if not isinstance(values, list) or any(not isinstance(value, str) for value in values): - raise ValueError("unsupported npm platform selector") - positive = [value for value in values if not value.startswith("!")] - if "!" + wanted in values or (positive and wanted not in positive): - if not record.get("optional"): - raise ValueError("required npm package has incompatible platform") - break - else: - selected[location] = record - if not native.issubset(selected): - raise ValueError("required native archives cannot be omitted") - return selected - - -class LocalMacBoundary: - """Actual local macOS denial, explicitly unqualified for hosted releases.""" - - def __init__(self, private_root, readonly): - if sys.platform != "darwin": - raise ValueError("local fixture boundary requires macOS; hosted qualification pending") - self.private_root = Path(private_root).resolve(strict=True) - self.readonly = [Path(path).resolve(strict=True) for path in readonly] - if any(self.private_root.is_relative_to(path) or path.is_relative_to(self.private_root) - for path in self.readonly): - raise ValueError("readonly tool paths must be disjoint from private workspace") - - def command(self, command): - quote = lambda path: deterministic_json_bytes(str(path)).decode().strip() - profile = ["(version 1)", "(deny default)", "(allow process*)", "(allow signal)", - "(allow sysctl-read)", - f"(allow file-read* file-write* (subpath {quote(self.private_root)}))", - '(allow file-read* (literal "/"))', - '(allow file-read* (literal "/private/var/select/sh"))', - '(allow file-read* file-write* (literal "/dev/null"))', - '(allow file-read* (literal "/dev/urandom") (literal "/dev/random"))'] - profile.extend(f"(allow file-read* (subpath {quote(path)}))" for path in self.readonly) - ancestors = {parent for path in [self.private_root, *self.readonly] for parent in path.parents} - profile.extend(f"(allow file-read-metadata (literal {quote(path)}))" for path in sorted(ancestors)) - # Network, other private workspaces and shared writes remain denied. - return ["/usr/bin/sandbox-exec", "-p", "".join(profile), *map(str, command)] - - -def checked_stage(repo, revision, command, environment, log, *, boundary=None, cwd=None): - """Run a real child and source checks before/after, preserving private logs.""" - before = source_check(repo, revision) - with Path(log).open("xb") as output: - result = subprocess.run(boundary.command(command) if boundary else command, - env=environment, cwd=cwd or repo, stdout=output, - stderr=subprocess.STDOUT, timeout=900, check=False) - source_check(repo, revision, before) - if result.returncode: - raise ValueError(f"release stage failed ({result.returncode}); inspect private log {log}") - - -def js_commands(workspace, node): - """Return mandatory local collector and Vitest commands, without npx fallback.""" - workspace = Path(workspace) - repo = workspace / "source" - return [ - [str(node), str(repo / "scripts/release/collect-js.mjs"), "--out-dir", str(workspace / "bundles"), - "--inventory", str(workspace / "inventory.json"), "--preparation", str(workspace / "preparation.json")], - [str(node), str(repo / LIB / "node_modules/vitest/vitest.mjs"), "run"], - ] - - -def local_environment(workspace, node, revision): - """Construct fresh local fixture environment; no qualified acquisition claim.""" - workspace = Path(workspace).resolve() - node = Path(node).resolve(strict=True) - preflight._commit_id(revision) - for name in ("home", "tmp"): - (workspace / name).mkdir(exist_ok=True) - developer_git = [directory for directory in (Path("/Applications/Xcode.app/Contents/Developer/usr/bin"), - Path("/Library/Developer/CommandLineTools/usr/bin")) - if (directory / "git").is_file()] - tool_paths = [str(node.parent), *(str(path) for path in developer_git), "/usr/bin", "/bin"] - return { - "HOME": str(workspace / "home"), "TMPDIR": str(workspace / "tmp"), - "PATH": os.pathsep.join(tool_paths), - "LANG": "C", "LC_ALL": "C", "TZ": "UTC", "CI": "true", - "NODE_ENV": "production", "TRUSTED_SERVER__RELEASE_BUILD": "1", - "TRUSTED_SERVER__GIT_VERSION": revision, - "SOURCE_DATE_EPOCH": str(source_check(workspace / "source", revision)["source_date_epoch"]), - "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, - "GIT_NO_REPLACE_OBJECTS": "1", "GIT_OPTIONAL_LOCKS": "0", - } - - -def _npm_command(node): - return [str(node), str(Path(node).parent.parent / "lib/node_modules/npm/bin/npm-cli.js")] - - -def _check_local_versions(node, environment): - for command, expected in (([str(node), "--version"], "v24.12.0"), - (_npm_command(node) + ["--version"], "11.6.2")): - result = subprocess.run(command, env=environment, capture_output=True, timeout=30, check=False) - if result.returncode or result.stdout.decode("utf-8").strip() != expected: - raise ValueError("actual Node/npm version differs from pinned recipe") - - -def _owned_files(package, excluded): - files = {} - for directory, children, names in os.walk(package, followlinks=False): - directory = Path(directory) - children[:] = [name for name in children if directory / name not in excluded] - for name in children + names: - path = directory / name - if path.is_symlink(): - raise ValueError("unsupported lifecycle symlink") - if path.is_file(): - data = _regular_bytes(path, MAX_FILE) - files[str(path.relative_to(package))] = { - "size": len(data), "sha256": sha256_bytes(data), - "executable": bool(path.stat().st_mode & 0o111), - } - return files - - -def lifecycle_shell(arguments, node): - """Recipe-owned npm script shell observes actual invocation and completion. - - Npm's foreground option serializes dependency hooks. Failed/incomplete hooks - are retained and rejected even when npm swallows an optional package failure. - """ - if len(arguments) != 2 or arguments[0] != "-c": - raise ValueError("unexpected npm lifecycle shell arguments") - command = arguments[1] - package = Path.cwd().resolve() - # The generated script shell supplies only this fixed private workspace path. - workspace = Path(os.environ["TRUSTED_RELEASE_PRIVATE_WORKSPACE"]).resolve() - install_root = workspace / "source" / LIB - review = read_json_file(workspace / "reviewed-lifecycle.json", max_bytes=MAX_JSON) - location = str(package.relative_to(install_root)) - event = os.environ.get("npm_lifecycle_event") - if (location not in review or event not in HOOKS or review[location].get(event) != command - or os.environ.get("npm_lifecycle_script") != command): - raise ValueError("unreviewed actual lifecycle invocation") - manifest = read_json_file(package / "package.json", max_bytes=MAX_JSON) - if review_lifecycle(manifest).get(event) != command: - raise ValueError("lifecycle source script changed") - lock = read_json_file(install_root / "package-lock.json", max_bytes=MAX_JSON) - excluded = {install_root / name for name in lock["packages"] if name and name != location} - before = _owned_files(package, excluded) - identity = f"lifecycle:{location}:{event}" - record_path = workspace / "lifecycle" / (sha256_bytes(identity.encode()) + ".json") - record = {"id": identity, "lock_path": location, "event": event, - "script_sha256": sha256_bytes(command.encode()), "state": "started"} - with record_path.open("xb") as output: - output.write(deterministic_json_bytes(record)) - # npm prepends dependency .bin directories; remove tool-shadowing search paths. - child_environment = {**os.environ, "PATH": os.pathsep.join((str(Path(node).parent), "/usr/bin", "/bin"))} - result = subprocess.run(["/bin/sh", "-c", command], env=child_environment, check=False, timeout=120) - after = _owned_files(package, excluded) - if before.keys() - after.keys(): - raise ValueError("lifecycle deletion requires reviewed output representation") - record.update({"state": "completed", "returncode": result.returncode, - "outputs": [{"path": location + "/" + name, **facts} - for name, facts in sorted(after.items()) if before.get(name) != facts]}) - record_path.write_bytes(deterministic_json_bytes(record)) - return result.returncode - - -def observed_lifecycle(workspace): - """Convert successful actual execution observations to the Task 6 joins.""" - workspace = Path(workspace) - events, outputs = [], [] - for path in sorted((workspace / "lifecycle").glob("*.json")): - record = read_json_file(path, max_bytes=MAX_JSON) - if record.get("state") != "completed" or record.get("returncode") != 0: - raise ValueError("lifecycle execution failed or is incomplete") - event = {"kind": "lifecycle-event", "id": record["id"], - "package_material_id": "npm:" + record["lock_path"], "event": record["event"], - "script_sha256": record["script_sha256"]} - events.append(event) - for facts in record["outputs"]: - outputs.append({"kind": "lifecycle-output", "id": record["id"] + ":" + facts["path"], - "package_material_id": event["package_material_id"], "event": event["event"], - "script_sha256": event["script_sha256"], "path": facts["path"], - "size": facts["size"], "sha256": facts["sha256"]}) - return events, outputs - - -def install_npm(workspace, revision, archives, node, environment, *, boundary=None): - """Seed a fresh private cache through npm, then execute reviewed offline ci. - - No restored installation/cache is accepted. Each selected archive is checked - again and every lifecycle declaration is reviewed before any installation. - The caller must supply the selected execution boundary for release-shaped - local fixtures; the official CLI cannot qualify this helper by itself. - """ - workspace = Path(workspace).resolve() - repo = workspace / "source" - library = repo / LIB - node = Path(node).resolve(strict=True) - if any(os.path.lexists(path) for path in (library / "node_modules", workspace / "cache", - workspace / "lifecycle", workspace / "script-shell")): - raise ValueError("npm installation and cache must be fresh") - _check_local_versions(node, environment) - review_lifecycle(read_json_file(library / "package.json", max_bytes=MAX_JSON), root=True) - if os.path.lexists(library / ".npmrc"): - raise ValueError("unreviewed source npm configuration") - lock = read_json_file(library / "package-lock.json", max_bytes=MAX_JSON) - review = {} - for location, checked in archives.items(): - verified = verify_archive(checked.path, checked.integrity) - record = lock["packages"].get(location) - name = location.rsplit("node_modules/", 1)[-1] - if (not record or record.get("integrity") != verified.integrity or verified.manifest is None - or verified.manifest.get("name") != name or verified.manifest.get("version") != record.get("version")): - raise ValueError("authenticated npm archive/lock identity mismatch") - review[location] = review_lifecycle(verified.manifest, paths=verified.paths) - (workspace / "reviewed-lifecycle.json").write_bytes(deterministic_json_bytes(review)) - (workspace / "cache").mkdir() - (workspace / "lifecycle").mkdir() - for name in ("npm-user.conf", "npm-global.conf"): - (workspace / name).write_bytes(b"") - wrapper = (f"#!{sys.executable} -I\n" - "import sys\n" - "sys.dont_write_bytecode = True\n" - f"sys.path.insert(0, {str(repo / 'scripts/release')!r})\n" - "import prepare\n" - f"sys.exit(prepare.lifecycle_shell(sys.argv[1:], {str(node)!r}))\n").encode() - (workspace / "script-shell").write_bytes(wrapper) - (workspace / "script-shell").chmod(0o755) - environment = {**environment, "TRUSTED_RELEASE_PRIVATE_WORKSPACE": str(workspace)} - base = _npm_command(node) + ["--cache", str(workspace / "cache"), "--userconfig", - str(workspace / "npm-user.conf"), "--globalconfig", - str(workspace / "npm-global.conf"), "--offline", "--no-audit", "--no-fund"] - unique = sorted({str(checked.path) for checked in archives.values()}) - for index in range(0, len(unique), 32): - checked_stage(repo, revision, base + ["cache", "add", "--ignore-scripts", *unique[index:index + 32]], - environment, workspace / f"cache-{index // 32}.log", boundary=boundary, cwd=library) - checked_stage(repo, revision, base + ["ci", "--include=dev", "--foreground-scripts", - "--script-shell", str(workspace / "script-shell")], environment, - workspace / "install.log", boundary=boundary, cwd=library) - # Optional hook failures must not be hidden by npm's success exit status. - events, _ = observed_lifecycle(workspace) - installed = {location for location in review if (library / location).is_dir()} - expected = {f"lifecycle:{location}:{event}" for location in installed for event in review[location]} - if {event["id"] for event in events} != expected: - raise ValueError("missing or unexpected actual lifecycle observations") - if (workspace / "script-shell").read_bytes() != wrapper: - raise ValueError("recipe-owned lifecycle shell changed") - - -# Reuse Task 6's tree and executable identity measurements, not a second hash dialect. -MEASURE_JS = """ -import fs from 'node:fs'; -import path from 'node:path'; -import {pathToFileURL} from 'node:url'; -const [root, output, observations] = process.argv.slice(1); -const {installedOrigins, installedMaterials, actualToolFiles} = await import( - pathToFileURL(path.join(root, 'crates/trusted-server-js/lib/release-composition.mjs'))); -const installRoot = path.join(root, 'crates/trusted-server-js/lib'); -const owners = installedOrigins({repoRoot: root, lockFile: path.join(installRoot, 'package-lock.json')}); -const observed = JSON.parse(fs.readFileSync(observations, 'utf8')); -const actual = actualToolFiles({installRoot}); -const distributions = actual.map(({name, version, sha256, path: file}) => ({ - material: {kind:'tool-distribution', id:'tool:'+name, name, version, sha256}, path:file})); -const tools = actual.map(({name, version, role}) => ({name, version, role, material_id:'tool:'+name})); -fs.writeFileSync(output, JSON.stringify({schema_version:1, packages:installedMaterials(owners), - distributions, tools, lifecycle_events:observed.events, lifecycle_outputs:observed.outputs})+'\\n', {flag:'wx'}); -""" - - -def preparation_document(workspace, revision, node, environment, *, boundary): - """Measure installed packages and actual tools after genuine preparation.""" - workspace = Path(workspace) - events, outputs = observed_lifecycle(workspace) - observations = workspace / "observations.json" - observations.write_bytes(deterministic_json_bytes({"events": events, "outputs": outputs})) - destination = workspace / "preparation.json" - repo = workspace / "source" - checked_stage(repo, revision, [str(node), "--input-type=module", "-e", MEASURE_JS, - str(repo), str(destination), str(observations)], environment, - workspace / "measurement.log", boundary=boundary, cwd=repo / LIB) - prepared = read_json_file(destination, max_bytes=MAX_JSON) - versions = {"node": "24.12.0", "npm": "11.6.2", "vite": "7.3.1", "rollup": "4.57.1", - "esbuild": "0.27.3", "vitest": "4.0.18"} - for tool in prepared["tools"]: - if tool["version"] != versions[tool["name"].split(":")[0]]: - raise ValueError("actual JS tool version differs from reviewed pins") - shell_version = subprocess.run(["/bin/sh", "--version"], env=environment, capture_output=True, - timeout=30, check=True).stdout.decode("utf-8").splitlines()[0] - git_file = Path(shutil.which("git", path=environment["PATH"])).resolve(strict=True) - git_version = subprocess.run([str(git_file), "--version"], env=environment, capture_output=True, - timeout=30, check=True).stdout.decode("utf-8").strip() - for name, file, version in (("python", Path(sys.executable).resolve(), - ".".join(str(part) for part in sys.version_info[:3])), - ("shell", Path("/bin/sh").resolve(), shell_version), - ("git", git_file, git_version)): - material = {"kind": "tool-distribution", "id": "tool:" + name, "name": name, - "version": version, "sha256": sha256_bytes(_regular_bytes(file, MAX_ARCHIVE))} - prepared["distributions"].append({"material": material, "path": str(file)}) - prepared["tools"].append({"name": name, "version": version, "role": "runtime", "material_id": material["id"]}) - destination.write_bytes(deterministic_json_bytes(prepared)) - return prepared - - -def local_boundary(workspace, node, archive_pool): - """Select explicit read-only runtime/archive paths for a local denial fixture.""" - developer = [path for path in (Path("/Applications/Xcode.app/Contents/Developer"), - Path("/Library/Developer/CommandLineTools")) if path.is_dir()] - return LocalMacBoundary(workspace, [Path("/bin"), Path("/usr/bin"), Path("/usr/lib"), Path("/System"), - Path(node).resolve().parent.parent, Path(sys.base_prefix).resolve(), - Path(archive_pool).resolve(), *developer]) - - -def _expected_modules(repo, revision): - names = preflight._git(repo, ["ls-tree", "-rz", "--name-only", revision]).decode("utf-8").split("\0") - prefix = str(LIB) + "/src/" - expected = [] - for name in names: - if name == prefix + "core/index.ts": - expected.append("core") - elif re.fullmatch(re.escape(prefix) + r"integrations/[a-z][a-z0-9_-]*/index\.ts", name): - expected.append(name.split("/")[-2]) - if "core" not in expected or len(expected) != len(set(expected)): - raise ValueError("invalid tracked JS entry points") - return sorted(expected) - - -def _build_js_pair(repo, revision, archive_pool, work, output, node, boundary_factory): - preflight._commit_id(revision) - work = Path(work).resolve() - if os.path.lexists(work) or os.path.lexists(output): - raise ValueError("JS work and publication directories must be fresh") - archive_pool = Path(archive_pool).resolve(strict=True) - node = Path(node).resolve(strict=True) - if work.is_relative_to(archive_pool) or archive_pool.is_relative_to(work): - raise ValueError("shared archives must be outside private workspaces") - work.mkdir(parents=True) - expected = _expected_modules(repo, revision) - for label in ("A", "B"): - workspace = work / label - workspace.mkdir() - materialize_source(repo, revision, workspace / "source") - environment = local_environment(workspace, node, revision) - boundary = boundary_factory(workspace, node, archive_pool) - lock = read_json_file(workspace / "source" / LIB / "package-lock.json", max_bytes=MAX_JSON) - native = subprocess.run([str(node), "-e", "console.log(JSON.stringify([process.platform,process.arch]))"], - env=environment, capture_output=True, timeout=30, check=True) - platform, architecture = load_json_bytes(native.stdout, max_bytes=MAX_JSON) - selected = required_npm_materials(lock, platform, architecture) - archives = {} - for location, record in selected.items(): - integrity = record.get("integrity") - if not isinstance(integrity, str) or not re.fullmatch(r"sha512-[A-Za-z0-9+/]{86}==", integrity): - raise ValueError("missing locked npm archive integrity") - digest = base64.b64decode(integrity[7:], validate=True).hex() - archives[location] = verify_archive(archive_pool / (digest + ".tgz"), integrity) - install_npm(workspace, revision, archives, node, environment, boundary=boundary) - preparation_document(workspace, revision, node, environment, boundary=boundary) - for command, log in zip(js_commands(workspace, node), ("build.log", "tests.log")): - checked_stage(workspace / "source", revision, command, environment, workspace / log, - boundary=boundary, cwd=workspace / "source" / LIB) - compare_and_publish(work / "A", work / "B", expected, output) - return {"source_commit": revision, "modules": expected, "output": str(output)} - - -def local_js_pair(repo, revision, archive_pool, work, output, node): - """Prepare/build/test/compare actual A/B under local denial, never official. - - Archive pool files use their locked SHA-512 hexadecimal digest plus .tgz. - This API intentionally has no official CLI bypass. Task 8 must qualify - authenticated acquisitions and hosted boundary selection before official use. - """ - result = _build_js_pair(repo, revision, archive_pool, work, output, node, local_boundary) - return {"qualification": "local-unqualified", **result} - - -def qualified_js_selection(tools): - """Fail closed until Task 8 supplies source-owned authenticated path selection.""" - preflight.validate_tools(tools, require_qualified=True) - raise ValueError("release qualification pending: authenticated hosted JS paths/boundary required") - - -def compare_and_publish(a, b, expected, output): - """Publish only A's final complete bundles and inventory after byte equality.""" - expected = sorted(f"tsjs-{name}.js" for name in expected) - if not expected or len(expected) != len(set(expected)) or any( - not re.fullmatch(r"tsjs-[a-z][a-z0-9_-]*\.js", name) for name in expected): - raise ValueError("invalid expected final module set") - measured = [] - for workspace in (a, b): - directory = Path(workspace) / "bundles" - if directory.is_symlink() or sorted(path.name for path in directory.iterdir()) != expected: - raise ValueError("final bundle set mismatch") - files = {name: _regular_bytes(directory / name, MAX_FILE) for name in expected} - if any(not data for data in files.values()): - raise ValueError("empty final bundle") - measured.append(files) - if measured[0] != measured[1]: - raise ValueError("independent final JS bytes differ") - inventory = _regular_bytes(Path(a) / "inventory.json", MAX_JSON) - document = load_json_bytes(inventory, max_bytes=MAX_JSON) - if (not isinstance(document, dict) or set(document) != { - "schema_version", "source_commit", "modules", "tools", "materials"} - or type(document["schema_version"]) is not int or document["schema_version"] != 1 - or not isinstance(document["source_commit"], str) - or not re.fullmatch(r"[0-9a-f]{40}", document["source_commit"]) - or any(not isinstance(document[field], list) for field in ("modules", "tools", "materials"))): - raise ValueError("invalid final inventory root") - modules = document["modules"] - for record in modules: - if (not isinstance(record, dict) or set(record) != { - "id", "filename", "size", "sha256", "contributions"} - or not isinstance(record["filename"], str) or record["filename"] not in measured[0] - or record["id"] != record["filename"][5:-3] - or not isinstance(record["contributions"], list)): - raise ValueError("invalid final inventory module") - try: - size = require_nonnegative_integer(record["size"]) - except ValueError as error: - raise ValueError("invalid final inventory module size") from error - if (size != len(measured[0][record["filename"]]) - or record["sha256"] != sha256_bytes(measured[0][record["filename"]])): - raise ValueError("final inventory does not match published A bytes") - if sorted(record["filename"] for record in modules) != expected: - raise ValueError("final inventory does not match published A bytes") - output = Path(output) - if os.path.lexists(output): - raise ValueError("publication destination must be fresh") - temporary = output.with_name(output.name + ".pending") - temporary.mkdir() - try: - (temporary / "bundles").mkdir() - for name, data in measured[0].items(): - (temporary / "bundles" / name).write_bytes(data) - (temporary / "tsjs-inventory.json").write_bytes(inventory) - temporary.rename(output) - finally: - if temporary.exists(): - shutil.rmtree(temporary) - - -def main(): - """Official entry: pending acquisition/boundary qualification always refuses.""" - parser = argparse.ArgumentParser(description=__doc__) - for name in ("source", "commit", "archives", "work", "output"): - parser.add_argument("--" + name, required=True) - args = parser.parse_args() - preflight.audit_environment(os.environ) - tools = read_json_file(Path(__file__).with_name("release-tools.json"), max_bytes=MAX_JSON) - node, boundary_factory = qualified_js_selection(tools) - _build_js_pair(Path(args.source), args.commit, Path(args.archives), Path(args.work), Path(args.output), - node, boundary_factory) - - -if __name__ == "__main__": - try: - main() - except (ValueError, OSError, subprocess.SubprocessError, tarfile.TarError) as error: - logging.error("Release JS preparation refuses: %s", error) - sys.exit(1) diff --git a/scripts/release/qualification/README.md b/scripts/release/qualification/README.md index 5b577e806a..c7a26d6dc2 100644 --- a/scripts/release/qualification/README.md +++ b/scripts/release/qualification/README.md @@ -1,71 +1,76 @@ # Disposable release qualification framework -Status: **local framework only; Task 8 qualification remains pending**. No tool, +Status: **Rust migration pending; Tasks 8–9 qualification remains incomplete**. No tool, SDK, OCI image or recipe has become qualified. `release-tools.json` retains null measurement slots. A source checksum and a successful unit test cannot supply hosted execution or acquisition authenticity. -The locally authorized work consists of a strict observation schema, semantic -checks, source-owned candidate socket boundaries and an inert hosted workflow -proposal. It creates no repository, dispatches no job, registers no workflow, +The retained observation schema, source-owned candidate boundary design and inert +hosted proposal guide replacement Rust semantic checks and native execution. It creates no repository, dispatches no job, registers no workflow, publishes no tag, signs no release and deploys no publisher service. -## Task 9 local recipe status +## Rust migration and Task 9 status -Task 9 is **partial local implementation**. The closed Rust recipe, immutable -material checks, Cargo JSON final selection, native parsing and final-byte Fastly -runtime gates are reviewable. Actual local socket/Cargo download-attempt and tiny -Mach-O fixtures provide diagnostic evidence only. They do not establish selected -macOS 15/GNU startup, native dependency closure, or a canonical application build. +Introduced Python qualification/recipe tooling and validator requirements are removed. Historical local socket/Cargo/tiny Mach-O probes are not replacement Rust boundary results or selected macOS 15/GNU qualification. The [Rust migration addendum](../../../docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md) and [primary plan](../../../docs/superpowers/plans/2026-10-13-attested-release-implementation.md#rust-migration-and-current-execution-boundary) define the host-only `crates/trusted-server-release` tool. Its document/schema utilities are implemented; source/preflight, complete helper closure and actual qualification remain pending. -The public selectors still refuse pending qualification. Before measuring the -executable closure, the focused Task 8 integration must supply independently -authenticated bootstrap/acquisition selection, a data-driven reviewed receipt -adapter, and the shared-JS producer/current-run transfer binding. An inventory's -source labels and byte hashes alone do not authenticate its producer. The private -candidate tool policy has no self-referencing source commit; independent caller -approval must match the actual executing checkout and loaded helper bytes. +Current wrappers fail closed until the independently approved native Rust +bootstrap and source-owned authenticated selections are implemented. Neither +inventory labels/hashes nor a self-authored checksum authenticates its producer. +Private typed source/acquisition approval and actual JS completion are constructed +only by the corresponding successful verification/execution path; no serialized +authorization boolean is accepted. The tool has no core/CLI/JS crate dependency. -The central recipe has not yet executed vendor preparation, the complete product -compile, final-byte acceptance and output copying together. Those integration -paths require the authenticated adapters above; helper-level local tests must not -be reported as canonical build proof. Task 10 must also capture metadata/tree -messages through the same source-owned environment and boundary before hosted -qualification. Native selected runtimes and Fastly A/B acceptance remain pending. +Task 9 remains incomplete. Rust MUST compose original-material preparation, actual +whole-product Cargo compilation, final-byte acceptance and output copying. Task 10 +MUST collect explicit-target metadata/tree, effective unit features, native archive +and sysroot evidence through the same boundary before hosted measurement. Native +selected runtimes and complete Fastly A/B acceptance remain mandatory. Independently +reviewed all-cohort remote evidence is distinct from current installed-tool rehash +and SDK/environment checks; neither is a substitute for the other. -## Local checks +## Local Rust checks -Use a private Python 3.11+ environment and install the complete locked validator -set before running tests: +Current registered utility checks use the source-built developer target. The +qualification module/filter below is a future instruction: it MUST fail its +nonzero guard until the real qualification tests are registered. ```sh -python3 -m venv /private/tmp/ts-qualification-venv -/private/tmp/ts-qualification-venv/bin/python -m pip install --require-hashes --only-binary=:all: -r scripts/release/requirements.txt -/private/tmp/ts-qualification-venv/bin/python -m pip check -/private/tmp/ts-qualification-venv/bin/python -B -m unittest discover -s scripts/release/tests -p 'test_qualification.py' -v +set -eu +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +test -n "$HOST_TARGET" +RELEASE_TEST_LIST=$(mktemp) +trap 'rm -f "$RELEASE_TEST_LIST"' EXIT +cargo test -p trusted-server-release --target "$HOST_TARGET" qualification::tests -- --list > "$RELEASE_TEST_LIST" +awk '/: test$/ { count++ } END { if (count == 0) { print "Qualification filter selected zero tests"; exit 1 } print "Selected " count " qualification tests" }' "$RELEASE_TEST_LIST" +cargo test -p trusted-server-release --target "$HOST_TARGET" qualification::tests +cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings ``` -The real unconfined probe requires local socket permission. On macOS, set -`TS_RELEASE_BOUNDARY_TESTS=1` to additionally execute the actual candidate -sandbox-exec profile. Run those tests outside an enclosing application sandbox. -The child and grandchild execute the same probe through inherited confinement, -with private HOME/TMP and closed inherited descriptors. A successful local test -does not qualify macOS 15, GNU, Intel execution, a whole SDK or a tool acquisition. - -`qualify.py validate-observation ` applies the bounded closed schema and -semantic joins, but prints an explicit pending qualification diagnostic even -when the observations are coherent. `require_qualified` always refuses. The -production `preflight.validate_tools(require_qualified=True)` and -`prepare.qualified_js_selection` also remain closed. Fields asserting `passed`, -caller commands and unknown keys are rejected. Actual logs, signed acquisition -evidence, trust roots and run identities still require independent verification; -no JSON field authenticates itself. +These commands provide local behavior evidence, not authenticated native bootstrap +or official release proof. Private tests live alongside `src/qualification.rs` or +in its registered child `tests.rs`; public process tests use `tests/commands.rs`. +Fixtures MUST stay fictional. Do not make internal APIs public for tests. + +The real unconfined probe requires local socket permission. Selected actual native +boundary probes MUST run outside an enclosing application sandbox. Child and +grandchild execute the same compiled Rust probe under inherited confinement with +private HOME/TMP and closed inherited descriptors. No ambient interpreter/tool +selection is trusted. A successful local test does not qualify macOS 15, GNU, +Intel execution, the whole SDK or original distribution acquisition. + +The proposed command `trusted-server-release qualify validate-observation ` +MUST check bounded closed schema and semantic joins once implemented; coherent JSON still has pending +qualification until independent evidence is verified. The production qualified +selector remains closed. Fields asserting `passed`, caller commands and unknown +keys are rejected. Actual logs, acquisition signatures, independent trust roots +and run identities MUST be verified; no JSON field authenticates itself. Native +bootstrap/schema pins MUST be reviewed before this command can serve production. ## Closed identities and fingerprints The GNU builder selection is a distinct `gnu-builder-x86_64` slot, with no image chosen or digest invented. UBI 9 is an initial candidate to investigate; its -availability, glibc, compiler and SDK must be measured before selecting it. UBI 9, +availability, glibc, compiler and SDK MUST be measured before selecting it. UBI 9, Ubuntu 22.04 and Debian 12 are three separate compatibility runtime slots. They do not pretend to supply a build SDK. @@ -73,7 +78,7 @@ macOS records GitHub's actual runner image version and the Apple SDK identity. No VM byte hash is available through this contract, so a hash of a version string is never labeled an image digest. The source-owned labels are `macos-15` for ARM64 and `macos-15-intel` for native Intel. Native `uname`, `sw_vers`, translation -`sysctl`, Xcode build version and SDK version/tree identity must be recorded. +`sysctl`, Xcode build version and SDK version/tree identity MUST be recorded. The actual startup OS is 15; the Mach-O loader floor is explicitly 13.0. Neither Rosetta nor startup on a newer developer Mac establishes this matrix. @@ -91,68 +96,85 @@ This avoids a self-hash cycle: a result cannot hash a future tools file containi that result's digest. The observation records its own source commit; the later reviewed measured-pin commit has different raw tools bytes. Recipe/profile/helper changes or changed executable/acquisition selections invalidate qualification. -`candidate_fingerprints` refuses missing shared Task 9 helpers. The existing +The Rust candidate fingerprint validator MUST refuse missing shared Task 9 helpers or changed executable source. The closure includes the release-tool manifest/lockfile and full compiled path-dependency closure, all loaded Rust modules, POSIX adapters, retained Node collector, reviewed profiles and schema bytes; a candidate cannot choose that path list. The existing Git reader assumes a trusted immutable object store; the actual staging boundary -must make Git objects and tracked source files read-only while permitting the +MUST make Git objects and tracked source files read-only while permitting the private installation's required node_modules/output writes. This framework does not claim race-proof reads against mutable Git storage. The reviewed closure explicitly includes the Rust build scripts and their shared git-version/JS prebuilt/inventory/bundle-set helpers, Cargo configuration -and manifests, and the executable JS build-all/module-options/composition/Vitest +and ALL path-dependency manifests, explicitly `crates/trusted-server-openrtb/Cargo.toml`, tool-version files, authenticated executable digests, SDK identities and raw receipts, and the executable JS build-all/module-options/composition/Vitest pipeline. Ordinary application source and dependencies have separate commit and -dependency identities. Future imported build helpers must enter this reviewed +dependency identities. Future imported build helpers MUST enter this reviewed closure before execution; the candidate selector cannot supply its own path list. +The host release helper itself MUST load/run inside the GNU <=2.34 builder; build +it through an independently approved baseline image/toolchain/material channel, +not ambient Ubuntu 24 followed by an assumed UBI 9 invocation. Bind helper target, +loader/runtime and its own source/dependency closure. Native macOS helper CPU/SDK +and floor compatibility need the same independent approval. Initial shell/hash/ +Rust/Cargo trust is external to the tool; authenticate rustup only if actually used; compiled native socket/lifecycle probes +cannot bootstrap their own authority. + ## Candidate boundaries -The Linux seccomp baseline is the exact Moby `seccomp/v0.2.1` source revision -`e295db0a4d7b67853387fef0b2ef0a29e796b1c1`, file +The Linux seccomp baseline is retained from `moby/profiles`: annotated tag +`seccomp/v0.2.1` has tag-object identity +`e295db0a4d7b67853387fef0b2ef0a29e796b1c1` and peels to commit +`5ad5f40ecde90d78e4a7c861c003fe92747d5518`, file `seccomp/default.json`, SHA-256 `536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74`. -Its Apache license is retained in `SECCOMP-LICENSE`. The baseline's default deny, -architecture mapping and other syscall conditions are preserved. Every socket, -socketpair and socketcall allowance is removed; only socket/socketpair with -argument zero equal to AF_UNIX is added. This avoids inventing a syscall allowlist. -The file hash is checked before generating the effective profile. - -The candidate container command combines `--network none` with that seccomp -profile; network namespace isolation alone permits loopback sockets. It also -drops all capabilities, sets no-new-privileges, uses private IPC/PID namespaces, -a read-only root, an unprivileged fixed UID, bounded memory/CPU/processes, and -separate controlled read-only tool/source mounts plus a private writable root. -No host daemon socket is mounted. Preparation must establish writable ownership -for UID 65532. Container/profile command construction is tested locally; Linux -execution remains unmeasured because no local Docker proof exists. - -Linux positive controls execute in the same selected OCI image/interpreter with -the checked default seccomp baseline and its ordinary private network namespace; -confined controls use the AF_UNIX-only profile and network-none namespace. -Both map the private host workspace to `/work`. Mount scanning rejects sockets -and symlink members escaping the approved roots; preparation must keep those -prepared trees immutable while the command executes. No local scan claims to -prevent an adversarial concurrent mount mutation. - -The macOS candidate reuses Task 7's locally exercised file isolation and adds -AF_UNIX/private-path rules. INET, arbitrary Mach lookup and AppleEvent delegation -remain denied by default. The same generated profile must wrap probes and the -complete Cargo/compiler/proc-macro/build-script/native-tool invocation. This file -does not replace Task 7's independent JS boundary or qualify hosted recipes. - -The candidate macOS profile can mark actual tracked source files and Git-object -directories inside the private root immutable, while leaving node_modules and -output writes available. It also denies unlink/rename of their ancestors so a -writable ancestor cannot move a protected file outside its literal deny rule. -This separation is locally exercised with actual ancestor rename/removal, -hard-link, source-write and private-output controls; the full recipe must pass -its reviewed tracked-object set. +Its Apache license is retained in `SECCOMP-LICENSE`. The future Rust profile MUST preserve the baseline's default deny, +architecture mapping and other reviewed syscall conditions. It MUST remove every socket/socketpair/socketcall +allowance and add only socket/socketpair with argument zero equal to AF_UNIX. This avoids inventing a syscall allowlist. +The original file hash MUST be checked before generating the effective profile. +Both original and effective profile hashes MUST be checked before EVERY host command. +The AF_UNIX layer is required because this contract denies INET sockets including +loopback; `--network none` alone permits loopback. Preserve reviewed syscalls +rather than introducing an unchecked custom allowlist. + +The pending Rust container adapter MUST combine `--network none` with that +seccomp profile, drop all capabilities, set no-new-privileges, use private IPC +and Docker's default private PID namespace (no invalid `--pid=private`), a +read-only root and UID 65532 with prepared writable ownership. Memory, CPU and +process limits MUST fit the actual host; Cargo A jobs 4/B jobs 2 does not +authorize four container CPUs on a two-vCPU host. No host daemon socket is mounted. + +Both profiles and an empty Docker client configuration MUST live in a host-owned +root disjoint from every container-writable workspace. Scan BEFORE creating +these inputs and rehash BOTH profiles before EVERY host command. Select the +authenticated fixed Docker client, explicit empty config and Unix daemon; reject +TTY, plugins, alternate contexts and inherited Docker overrides. Read-only +anchors MUST remain outside the writable root. Test escaping/dangling aliases +in the CONTAINER namespace, not only their host interpretation. + +A local Docker CLIENT exists, but the daemon is unusable; no actual Linux +execution proof follows. Rust MUST execute positive, denied and full-recipe +controls with the same selected native probe and OCI image, not merely compare +command arguments. Controls map approved workspace inputs to `/work`, with +shared tools/source read-only. Scan sockets and aliases before use; this does +not claim protection against an adversarial mutable host mount. + +The pending macOS Rust adapter MUST enforce private-path/AF_UNIX rules, denying +INET, arbitrary Mach lookup and AppleEvent delegation. The same generated +profile wraps positive/denied probes and the full Cargo/compiler/proc-macro/ +build-script/native-tool invocation; Task 7's lifecycle/JS boundary needs the +same actual qualification. + +Tracked source and Git-object stores MUST be immutable while private installation/ +output writes remain available. Deny ancestor unlink/rename. Every immutable +regular file MUST have `nlink == 1` before execution; reject pre-existing +hard-link aliases and execute write-through/restoration negatives. Limit signals +to `same-sandbox`: own-child success and outside same-user sibling/orchestrator +denial are required. Deleted local prototypes do not establish these Rust proofs. Both boundaries require actual positive then confined TCP and UDP bind/connect/ send probes for IPv4/IPv6, loopback/documentation-address destinations, across parent/child/grandchild, plus private Unix exchange. External routing failures and unsupported unconfined IPv6 are recorded as unavailable, never successful -controls. Positive IPv4 loopback operations must succeed, every confined INET -operation must return EPERM/EACCES, and private Unix exchange must succeed. A +controls. Positive IPv4 loopback operations MUST succeed, every confined INET +operation MUST return EPERM/EACCES, and private Unix exchange MUST succeed. A socket helper is only boundary evidence; full recipe success is a separate gate. ## Reviewable hosted resource proposal @@ -163,51 +185,72 @@ GitHub-hosted jobs: `ubuntu-24.04`, `macos-15`, `macos-15-intel`; 120 minutes ea no matrix fail-fast, no concurrent cancellation, contents-read only, no OIDC, attestation/provider/release permissions, credentials, cache restore or publish steps. Logs are retained for seven days. Action versions and full revisions were -read from primary GitHub tag refs for checkout 4.2.2, setup-python 5.6.0 and -upload-artifact 4.6.2; they are source-reviewed code selections, not independently +verified from primary action metadata for checkout 5.0.0 and upload-artifact +6.0.0 (Node 24); full fetch history avoids the pending shallow-source rejection. +Pins require manual review/maintenance: Dependabot does not scan this inert +path outside `.github/workflows`. Logs use `tee` with `pipefail` so failure +output is visible and retained. Native Rust bootstrap steps remain pending; they are source-reviewed code selections, not independently authenticated tool archives. Runner labels were checked against primary GitHub -runner documentation on 2026-10-14; availability must be rechecked at dispatch. +runner documentation on 2026-10-14; availability MUST be rechecked at dispatch. This proposal intentionally stops at `check-ready`. It cannot falsely report a complete qualification with version-only probes. The reviewed executable staging -steps may replace that stop only when all prerequisites below are complete: +steps may replace that stop only when all prerequisites below are complete. Production selection MUST +require all qualified inputs by default; named audit diagnostics cannot +construct approval state or turn pending/null pins into success: 1. Task 9 implements the shared full production recipes, native inspection and exact-final-Wasm core-ABI/publisher-response validation. The staging selector - must invoke those same source-owned functions with independently authenticated + MUST invoke those same source-owned functions with independently authenticated candidate selections. It cannot require its own future production-qualified result, accept downloaded commands or introduce a public local-fixture bypass. -2. Review the bootstrap chain and actually acquire Python 3.12.6, Git/shell, - rustup/Rust 1.95.0 components/sysroot, compiler/linker/archiver, Node 24.12.0, +2. Review independently approved-source native Rust bootstrap and actually acquire + Git/shell, signed Rust 1.95.0 components/sysroot, compiler/linker/archiver, + Node 24.12.0, npm 11.6.2 and the selected runtime tools through source-owned authenticators. Node uses signed checksums and independently reviewed keys; Rust uses signed manifests/components; gh uses its verified archive and independent bootstrap. A colocated checksum alone does not authenticate Fastly or an SDK. Retain raw signature/checksum evidence, reviewed trust roots, archive and extracted-tree digests and actual selected file identities. SDK/platform trust needs an - explicit vendor/platform evidence chain. Ambient PATH or PYTHONPATH cannot - select the interpreter before isolation. setup-python alone supplies no - qualifying bootstrap proof. + explicit vendor/platform evidence chain. Ambient PATH cannot select unreviewed native tools before isolation. A + source-built tool and colocated checksum supply no qualifying bootstrap proof. + Authenticate original Fastly vendor archive bytes through independently reviewed + vendor-source evidence; no missing-root fallback is permitted. 3. Acquire the candidate GNU image by authenticated content identity, record both OCI index and selected AMD64 manifest digests, and measure glibc <=2.34 inside that builder. Select the three runtime-only images by their actual digests. Record native archive/link/sysroot identities: aws-lc-sys 0.42 target-suffix overrides and generic/target system-use=0, AWS-LC 5.1 source, ring 0.17.14 pregenerated source membership, and SQLite 0.30.1 bundled 3.46/pkg-config=0. -4. Network-enabled authenticated acquisition/lifecycle preparation happens before - compilation. Close source/object-store writes and run positive/negative probes, +4. Network-enabled authenticated archive/tool acquisition precedes fresh offline + installation and compilation. Execute actual lifecycle hooks under the selected + native denial boundary; recording an install flag alone is insufficient. + Close source/object-store writes and run positive/negative probes, full working compilation, actual Cargo/npm download-attempt negatives and actual missing-tool negatives through the same selected boundary. Retain outcomes and attempted fetch evidence even when a script catches denial. + Audit inherited hosted/setup exports before clearing them; only explicitly + reviewed qualified values/path identities may survive. Bound subprocess + pipes/deadlines, clear token/provider/injection environment, terminate/reap + descendant groups on timeout/cancellation/failure and + clean up private profiles/resources and Docker containers with bounded removal. 5. Outside the compile-denial boundary, run the exact CLI bytes outside checkout on clean UBI 9/Ubuntu 22.04/Debian 12 and actual native macOS 15 ARM/Intel. Verify GNU symbol ceiling/libraries and Mach-O LC_BUILD_VERSION 13.0. Validate the exact final Fastly Wasm with pinned Wasmtime and complete publisher-origin response/injection through pinned Viceroy, retaining pre/post byte hashes. No health-only success, cross-Intel inference or changed cohort is accepted. -6. Collect the closed observations with actual repository/owner/run/attempt/job +6. Join every observation to the reviewed approved-source catalog, selection + digest, exact tool versions/authentication methods and full helper closure + before accepting it. Record actual errno; do not invent `ETIMEDOUT`. Hash the + selected shell rather than assuming `/bin/sh --version` works. Use + host-independent tool/environment fixtures. Collect the closed observations + with actual repository/owner/run/attempt/job identities and raw retained evidence. Independently authenticate and review - those identities, acquisitions, source closure and selections. Only then + those identities, acquisitions, source closure and selections. A valid receipt + proves only its signed contents; independent catalog/source/root approval is + still required and cannot be supplied by that receipt or its checksum. Only then update the source-owned measured pins and implement their production selector. New workflow files on a feature branch may be unavailable to manual dispatch @@ -222,5 +265,5 @@ resource spending and dispatch remain separate permission gates. Missing native Intel or the GNU baseline stops this cohort. Only after actual hosted qualification may the final Task 8 commit use the title `Qualify controlled -release build environments`. The local framework commit must use an accurate +release build environments`. The local framework commit MUST use an accurate title such as `Add release environment qualification checks`. diff --git a/scripts/release/qualification/approvals.json b/scripts/release/qualification/approvals.json new file mode 100644 index 0000000000..9c9af1782b --- /dev/null +++ b/scripts/release/qualification/approvals.json @@ -0,0 +1 @@ +{"schema_version":1,"environments":{"gnu-builder-x86_64":null,"macos15-aarch64":null,"macos15-x86_64":null},"qualifications":{"tsjs":null,"trusted-server-fastly.wasm":null,"ts-aarch64-apple-darwin":null,"ts-x86_64-apple-darwin":null,"ts-x86_64-unknown-linux-gnu":null}} diff --git a/scripts/release/qualification/boundaries.py b/scripts/release/qualification/boundaries.py deleted file mode 100644 index ffb290ca22..0000000000 --- a/scripts/release/qualification/boundaries.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Source-owned candidate boundaries; local probes never qualify hosted builds.""" - -import copy -import os -import selectors -import signal -import time -import re -import subprocess -import sys -import uuid -from pathlib import Path - -RELEASE = Path(__file__).resolve().parents[1] -DOCKER_EXECUTABLE = "/usr/bin/docker" -sys.path.insert(0, str(RELEASE)) - -from common import deterministic_json_bytes, load_json_bytes, sha256_bytes -from prepare import LocalMacBoundary - -SECCOMP_SOURCE_REVISION = "e295db0a4d7b67853387fef0b2ef0a29e796b1c1" -SECCOMP_SOURCE_SHA256 = "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74" - - -def linux_seccomp_profile(): - """Augment the exact reviewed Moby default baseline with AF_UNIX only. - - Preserve its default deny and other syscall conditions. Remove every socket, - socketpair and old socketcall allowance before adding argument-zero filtering. - Linux execution remains unmeasured until the authorized hosted run. - """ - data = (Path(__file__).parent / "seccomp-default.json").read_bytes() - if sha256_bytes(data) != SECCOMP_SOURCE_SHA256: - raise ValueError("reviewed default seccomp baseline bytes changed") - baseline = load_json_bytes(data, max_bytes=1048576) - if baseline["defaultAction"] != "SCMP_ACT_ERRNO" or baseline.get("defaultErrnoRet", 1) != 1: - raise ValueError("default seccomp denial required") - profile = copy.deepcopy(baseline) - rules = [] - for rule in profile["syscalls"]: - rule["names"] = [name for name in rule["names"] if name not in {"socket", "socketpair", "socketcall"}] - if rule["names"]: - rules.append(rule) - rules.append({"names": ["socket", "socketpair"], "action": "SCMP_ACT_ALLOW", - "args": [{"index": 0, "value": 1, "op": "SCMP_CMP_EQ"}]}) - profile["syscalls"] = rules - return profile - - -def bounded_process(argv, environment, *, timeout=30, maximum=65536): - """Drain both pipes while running, bounded in time and retained bytes.""" - process = subprocess.Popen(argv, env=environment, stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - close_fds=True, start_new_session=True) - selector = selectors.DefaultSelector() - outputs = [bytearray(), bytearray()] - for index, stream in enumerate((process.stdout, process.stderr)): - os.set_blocking(stream.fileno(), False) - selector.register(stream, selectors.EVENT_READ, index) - deadline = time.monotonic() + timeout - try: - while selector.get_map(): - if time.monotonic() >= deadline: - raise ValueError("bounded process timeout") - for key, _ in selector.select(min(0.1, max(0, deadline - time.monotonic()))): - chunk = os.read(key.fileobj.fileno(), 65536) - if not chunk: - selector.unregister(key.fileobj) - continue - if len(outputs[key.data]) + len(chunk) > maximum: - raise ValueError("bounded process output bound exceeded") - outputs[key.data].extend(chunk) - process.wait(timeout=max(0.001, deadline - time.monotonic())) - return subprocess.CompletedProcess(argv, process.returncode, bytes(outputs[0]), bytes(outputs[1])) - finally: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait(timeout=5) - selector.close() - process.stdout.close() - process.stderr.close() - - -def _roots(private_root, readonly): - root = Path(private_root).resolve(strict=True) - reads = [Path(path).resolve(strict=True) for path in readonly] - if (not root.is_dir() or root == Path("/") or Path(private_root).is_symlink() - or any(path == Path("/") or root.is_relative_to(path) or path.is_relative_to(root) - for path in reads)): - raise ValueError("private and readonly roots must be disjoint and confined") - return root, reads - - -def macos_profile(private_root, readonly, *, immutable=()): - """Reuse tested local file isolation and add private Unix sockets only. - - Default denial covers INET, Mach lookup and AppleEvent delegation. This is a - candidate profile: full hosted ARM/Intel recipes still need measurement. - """ - root, reads = _roots(private_root, readonly) - # Profile construction is inspectable on any host; only execution is macOS. - boundary = object.__new__(LocalMacBoundary) - boundary.private_root, boundary.readonly = root, reads - profile = boundary.command([])[2] - quoted = deterministic_json_bytes(str(root)).decode().strip() - protected_ancestors = set() - for path in immutable: - path = Path(path) - resolved = path.resolve(strict=True) - if path.is_symlink() or not resolved.is_relative_to(root) or resolved == root: - raise ValueError("immutable source must be a real member of the private workspace") - predicate = "subpath" if resolved.is_dir() else "literal" - quoted_path = deterministic_json_bytes(str(resolved)).decode().strip() - profile += f"(deny file-write* ({predicate} {quoted_path}))" - protected_ancestors.update(parent for parent in resolved.parents if parent.is_relative_to(root)) - # Path-based write denial must remain attached to the same source pathname. - # Deny unlink/rename of its ancestors while permitting writes to their private - # node_modules/output children. Broad ancestor file-write denial would block - # the required private installations as well. - for ancestor in sorted(protected_ancestors): - quoted_ancestor = deterministic_json_bytes(str(ancestor)).decode().strip() - profile += f"(deny file-write-unlink (literal {quoted_ancestor}))" - return (profile + "(allow system-socket (socket-domain AF_UNIX))" - + f"(allow network-bind network-inbound (local unix-socket (subpath {quoted})))" - + f"(allow network-outbound (remote unix-socket (subpath {quoted})))") - - -class MacBoundary: - """Use the same generated candidate profile for probes and compilation.""" - - def __init__(self, private_root, readonly, *, immutable=()): - if sys.platform != "darwin": - raise ValueError("macOS candidate boundary requires an actual macOS host") - self.profile = macos_profile(private_root, readonly, immutable=immutable) - self.sha256 = sha256_bytes(self.profile.encode()) - - def command(self, command, *, environment=None, cwd=None): - """Wrap source-owned argv, closing descriptors in the caller.""" - return ["/usr/bin/sandbox-exec", "-p", self.profile, *map(str, command)] - - -class LinuxBoundary: - """Construct a digest-selected isolated GNU candidate container. - - Only the later source-owned authenticated staging selector may instantiate - this class for recipes. The image argument is internal, never a CLI input. - Preparation must create writable ownership for the fixed unprivileged UID. - """ - - def __init__(self, private_root, readonly, image, *, immutable=(), python=None, execution_umask=0o022): - self.private_root, self.readonly = _roots(private_root, readonly) - if type(execution_umask) is not int or execution_umask not in {0o022, 0o027}: - raise ValueError("source-owned release umask required") - if python is not None and (not Path(python).is_absolute() or ".." in Path(python).parts): - raise ValueError("explicit authenticated container Python required") - self.python, self.execution_umask = python, execution_umask - self.containers = set() - self.docker_config = self.private_root / "docker-config" - self.docker_config.mkdir(mode=0o755) - (self.docker_config / "config.json").write_bytes(b"{}\n") - self.docker_prefix = [DOCKER_EXECUTABLE, "--host", "unix:///var/run/docker.sock", - "--config", str(self.docker_config)] - self.immutable = [self.docker_config] - for path in immutable: - path = Path(path) - resolved = path.resolve(strict=True) - if path.is_symlink() or resolved == self.private_root or not resolved.is_relative_to(self.private_root): - raise ValueError("immutable source must be a real member of the private workspace") - self.immutable.append(resolved) - if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+@sha256:[0-9a-f]{64}", image): - raise ValueError("selected OCI manifest digest required; tags are forbidden") - self.image = image - self.profile_path = self.private_root / "network-seccomp.json" - self.profile_path.write_bytes(deterministic_json_bytes(linux_seccomp_profile())) - self.profile_path.chmod(0o444) - self.sha256 = sha256_bytes(self.profile_path.read_bytes()) - self.control_profile_path = self.private_root / "control-seccomp.json" - self.control_profile_path.write_bytes((Path(__file__).parent / "seccomp-default.json").read_bytes()) - self.control_profile_path.chmod(0o444) - for root in [self.private_root, *self.readonly]: - for path in root.rglob("*"): - if path.is_symlink(): - resolved = path.resolve(strict=True) - if not any(resolved.is_relative_to(approved) for approved in [self.private_root, *self.readonly]): - raise ValueError("container mount symlink escapes approved roots") - if path.is_socket(): - raise ValueError("host Unix/daemon sockets must not enter container mounts") - - def command(self, command, *, environment=None, cwd=None): - """Use no host namespace, daemon socket, privileges or writable tools.""" - return self._container_command(command, confined=True, environment=environment, cwd=cwd) - - def map_path(self, path): - """Map internal source-owned workspace paths to the private container.""" - pattern = r"(? 256: - raise ValueError("closed controlled container environment required") - for key, value in sorted(environment.items()): - if (not isinstance(key, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) - or not isinstance(value, str) or len(value) > 65536 or "\0" in value or "\n" in value): - raise ValueError("unsupported controlled environment entry") - result.extend(["--env", key + "=" + self.map_path(value)]) - if confined: - result.extend(["--network", "none"]) - for path in self.readonly: - result.extend(["--mount", f"type=bind,src={path},dst={path},readonly"]) - for path in self.immutable: - result.extend(["--mount", f"type=bind,src={path},dst={self.map_path(path)},readonly"]) - argv = [self.map_path(value) for value in command] - if self.python is not None: - # Docker does not inherit its client's umask. Apply it to the actual - # container process before exec; no shell evaluation is involved. - driver = f"import os,sys;os.umask({self.execution_umask});os.execve(sys.argv[1],sys.argv[1:],dict(os.environ))" - argv = [str(self.python), "-I", "-B", "-c", driver, *argv] - return [*result, self.image, *argv] - - -def probe_commands(boundary, private_root, python): - """Construct controls in the same OS/image and map private container paths. - - No public denial toggle exists. Only this source-owned control orchestration - invokes the internal baseline container command; compilation uses command(). - """ - root = Path(private_root).resolve(strict=True) - python = Path(python) - if isinstance(boundary, LinuxBoundary): - # This identity is selected and measured inside the OCI builder. Resolving - # a host Python symlink could select a different container executable. - if not python.is_absolute() or ".." in python.parts: - raise ValueError("explicit in-container prepared Python path required") - else: - python = python.resolve(strict=True) - command = [str(python), "-I", "-B", str(Path(__file__).parent / "socket_probe.py"), - "--private-root", "/work" if isinstance(boundary, LinuxBoundary) else str(root)] - if isinstance(boundary, LinuxBoundary): - if root != boundary.private_root: - raise ValueError("probe workspace differs from selected private container mount") - return {"unconfined": boundary._container_command(command, confined=False), - "confined": boundary.command(command)} - if not isinstance(boundary, MacBoundary): - raise ValueError("unknown source-owned candidate boundary") - return {"unconfined": command, "confined": boundary.command(command)} - - -def probe_controls(boundary, private_root, python): - """Measure positive then denied probes with this same boundary instance. - - This local helper does not perform compilation or qualification. The shared - staging/production orchestrator must run its full recipe through command(). - """ - root = Path(private_root).resolve(strict=True) - python = Path(python) - if not isinstance(boundary, LinuxBoundary) and not python.resolve(strict=True).is_file(): - raise ValueError("explicit prepared Python executable required") - observations = {} - for phase, argv in probe_commands(boundary, root, python).items(): - try: - result = bounded_process(argv, {"HOME": str(root), "TMPDIR": str(root), "LANG": "C", "LC_ALL": "C"}) - finally: - if isinstance(boundary, LinuxBoundary): - boundary.cleanup(argv) - if result.returncode or len(result.stdout) > 65536 or len(result.stderr) > 65536: - raise ValueError(f"{phase} probe failed; actual local boundary unavailable") - observations[phase] = load_json_bytes(result.stdout, max_bytes=65536) - return observations diff --git a/scripts/release/qualification/hosted-workflow.proposal.yml b/scripts/release/qualification/hosted-workflow.proposal.yml index 0d078f66b7..166b72e5c2 100644 --- a/scripts/release/qualification/hosted-workflow.proposal.yml +++ b/scripts/release/qualification/hosted-workflow.proposal.yml @@ -1,4 +1,5 @@ # Inert proposal: this file is deliberately outside .github/workflows. +# Action pins here require manual review/maintenance; Dependabot does not scan this path. # Register only in a separately authorized disposable mirror after Task 9 and # independently authenticated candidate acquisitions are reviewed. name: Disposable release environment qualification @@ -26,23 +27,20 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24) with: ref: ${{ github.sha }} persist-credentials: false - fetch-depth: 1 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: '3.12.6' + fetch-depth: 0 - name: Refuse incomplete shared recipes and staging acquisitions shell: bash run: | - set -eu + set -euo pipefail mkdir -p "$RUNNER_TEMP/qualification-logs" - python -I -B scripts/release/qualification/qualify.py check-ready > "$RUNNER_TEMP/qualification-logs/readiness.log" 2>&1 + /bin/sh scripts/release/bootstrap.sh qualify check-ready 2>&1 | tee "$RUNNER_TEMP/qualification-logs/readiness.log" - name: Retain diagnostic logs if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 (Node 24) with: name: qualification-${{ matrix.environment }}-${{ github.run_attempt }} path: ${{ runner.temp }}/qualification-logs/ diff --git a/scripts/release/qualification/qualify.py b/scripts/release/qualification/qualify.py deleted file mode 100644 index 33bf633189..0000000000 --- a/scripts/release/qualification/qualify.py +++ /dev/null @@ -1,308 +0,0 @@ -"""Validate closed disposable hosted observations without conferring trust. - -Production qualification remains pending authenticated hosted resources and the -shared complete production recipes. JSON observations never select commands. -""" - -import argparse -import errno -import logging -import re -import stat -import sys -from pathlib import Path - -RELEASE = Path(__file__).resolve().parents[1] -sys.dont_write_bytecode = True -sys.path.insert(0, str(RELEASE)) - -from common import deterministic_json_bytes, read_json_file, require_nonnegative_integer, sha256_bytes -import preflight - -BASE_TOOLS = {"python", "git", "shell", "rustup", "rustc", "cargo", "compiler", "linker", - "archiver", "node", "npm"} -RECIPE_ENVIRONMENTS = { - "ts-aarch64-apple-darwin": "macos15-aarch64", - "ts-x86_64-apple-darwin": "macos15-x86_64", - "ts-x86_64-unknown-linux-gnu": "gnu-builder-x86_64", - "trusted-server-fastly.wasm": "gnu-builder-x86_64", "tsjs": "gnu-builder-x86_64", -} -SHARED_CLOSURE = ( - ".cargo/config.toml", ".tool-versions", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", - "scripts/release/common.py", "scripts/release/preflight.py", "scripts/release/prepare.py", - "scripts/release/build-js.sh", "scripts/release/collect-js.mjs", "scripts/release/requirements.txt", - "scripts/release/qualification/qualify.py", "scripts/release/qualification/boundaries.py", - "scripts/release/qualification/socket_probe.py", "scripts/release/qualification/results.schema.json", - "scripts/release/qualification/seccomp-default.json", - "crates/trusted-server-core/build.rs", "crates/trusted-server-core/build_support/git_version.rs", - "crates/trusted-server-cli/build.rs", "crates/trusted-server-adapter-fastly/build.rs", - "crates/trusted-server-js/build.rs", "crates/trusted-server-js/build/bundle_set.rs", - "crates/trusted-server-js/build/inventory.rs", "crates/trusted-server-js/build/prebuilt.rs", - "crates/trusted-server-js/lib/build-all.mjs", "crates/trusted-server-js/lib/build-module-options.mjs", - "crates/trusted-server-js/lib/release-composition.mjs", "crates/trusted-server-js/lib/vitest.config.ts", - "crates/trusted-server-js/lib/vite.config.ts", "crates/trusted-server-js/lib/tsconfig.json", - "crates/trusted-server-js/lib/package.json", "crates/trusted-server-js/lib/package-lock.json", - "crates/trusted-server-cli/Cargo.toml", "crates/trusted-server-core/Cargo.toml", - "crates/trusted-server-js/Cargo.toml", "crates/trusted-server-adapter-fastly/Cargo.toml", -) -RUST_CLOSURE = ("trusted-server.example.toml", "edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs", - "scripts/release/build.sh", "scripts/release/inspect-native.py", - "scripts/release/validate-fastly.py", "scripts/release/rust_recipe.py", - "crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml", - "crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml") - - -def _version(value): - if not isinstance(value, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,2}", value): - raise ValueError("unknown or nonnumeric compatibility version") - components = list(map(int, value.split("."))) - while len(components) > 1 and components[-1] == 0: - components.pop() - return tuple(components) - - -def validate_catalog(tools): - """Validate source-owned selections; null observations stay pending.""" - preflight.validate_tools(tools) - - -def selection_digest(tools): - """Bind executable selections, excluding derived recipe/evidence digests. - - The raw whole tools JSON digest is separately recorded. Including it in this - fingerprint would require a result to hash the future file containing itself. - """ - selected = { - "schema_version": tools["schema_version"], - "tools": [{key: value for key, value in tool.items() - if key != "qualification"} for tool in tools["tools"]], - "environments": [{key: value for key, value in environment.items() - if key != "qualification"} - for environment in tools["environments"]], - "recipes": [{key: value for key, value in recipe.items() - if key not in {"sha256", "qualification"}} for recipe in tools["recipes"]], - } - return sha256_bytes(b"trusted-server-execution-selection-v1\n" + deterministic_json_bytes(selected)) - - -def candidate_fingerprints(root, tools, recipe_id): - """Bind the reviewed recipe closure; absent Task 9 helpers refuse staging.""" - validate_catalog(tools) - if recipe_id not in RECIPE_ENVIRONMENTS: - raise ValueError("unknown fixed recipe identity") - # The complete cohort needs the shared production Rust/runtime gates even - # when inspecting its JS leg. No version-only substitute closes this gate. - paths = (*SHARED_CLOSURE, *RUST_CLOSURE) - if any(not (Path(root) / path).is_file() for path in paths): - raise ValueError("Task 9 missing reviewed closure files") - recipe = next(recipe for recipe in tools["recipes"] if recipe["id"] == recipe_id) - return {"closure_sha256": closure_digest(root, paths, recipe["settings"]), - "selection_sha256": selection_digest(tools)} - - -def closure_digest(root, paths, settings): - """Hash a reviewed sorted regular-file closure and effective settings. - - The caller is source code, never a downloaded descriptor. Symlinks, escaped - paths, duplicate names and derived tools/results files are rejected. - """ - root = Path(root).resolve(strict=True) - names = sorted(paths) - if len(names) != len(set(names)) or not names: - raise ValueError("closure requires unique reviewed files") - files = [] - for name in names: - path = root / name - if (not isinstance(name, str) or Path(name).is_absolute() or ".." in Path(name).parts - or path.is_symlink() or not path.resolve(strict=True).is_relative_to(root) - or any(parent.is_symlink() for parent in path.parents if parent.is_relative_to(root)) - or path.name in {"release-tools.json", "results.json"}): - raise ValueError("unsafe or self-referencing closure path") - information = path.stat() - if not stat.S_ISREG(information.st_mode) or information.st_size > 33554432: - raise ValueError("closure requires bounded regular files") - files.append({"path": name, "executable": bool(information.st_mode & 0o111), - "sha256": sha256_bytes(path.read_bytes())}) - return sha256_bytes(b"trusted-server-recipe-closure-v1\n" + - deterministic_json_bytes({"files": files, "settings": settings})) - - -def _schema_validate(result): - try: - from jsonschema import Draft202012Validator - except ImportError as error: - raise ValueError("locked qualification schema validator is required") from error - schema = read_json_file(RELEASE / "qualification/results.schema.json", max_bytes=1048576) - Draft202012Validator.check_schema(schema) - errors = sorted(Draft202012Validator(schema).iter_errors(result), key=lambda error: str(error.path)) - if errors: - raise ValueError(f"qualification result schema: {errors[0].message}") - - -def _probe_key(probe): - return tuple(probe[key] for key in ("depth", "family", "protocol", "operation", "destination")) - - -def validate_socket_pair(positive, confined): - """Require actual denial across the process tree and private Unix success. - - Unsupported external/IPv6 positive operations remain unavailable; they never - count as successful controls. Every confined INET operation must deny with - EPERM/EACCES, even when its matching positive routing test was unavailable. - """ - expected = {(depth, family, protocol, operation, destination) for depth in range(3) - for family in ("ipv4", "ipv6") for protocol in ("tcp", "udp") - for operation in ("bind", "connect", "send") - for destination in ("loopback", "external")} - expected.update((depth, "unix", "unix", "exchange", "private") for depth in range(3)) - for records in (positive, confined): - if len(records) != len(expected) or {_probe_key(probe) for probe in records} != expected: - raise ValueError("missing or duplicate socket controls/descendant coverage") - controls = {_probe_key(probe): probe for probe in positive} - for probe in confined: - control = controls[_probe_key(probe)] - for observation in (probe, control): - if ((observation["outcome"] == "allowed" and observation["errno"] != 0) - or (observation["outcome"] != "allowed" and observation["errno"] == 0)): - raise ValueError("socket outcome contradicts actual errno") - if observation["outcome"] == "allowed" and observation["phase"] != observation["operation"]: - raise ValueError("successful socket observation must complete its intended operation") - if probe["family"] == "unix": - if any(p["outcome"] != "allowed" or p["errno"] != 0 for p in (probe, control)): - raise ValueError("private Unix IPC must succeed") - else: - phases = {"socket", probe["operation"]} - if probe["operation"] == "send" and probe["protocol"] == "tcp": - phases.add("connect") - if probe["phase"] not in phases: - raise ValueError("socket denial happened at an unrelated helper operation") - if probe["outcome"] != "denied" or probe["errno"] not in {errno.EACCES, errno.EPERM}: - raise ValueError("confined internet operation was not denied") - if control["outcome"] == "denied" or control["errno"] in {errno.EACCES, errno.EPERM}: - raise ValueError("unconfined control was denied") - if control["family"] == "ipv4" and control["destination"] == "loopback": - if control["outcome"] != "allowed" or control["errno"] != 0: - raise ValueError("unconfined positive IPv4 loopback control required") - - -def validate_observation(result): - """Reject incomplete or contradictory hosted observations with ValueError. - - This checks claims and their joins, not remote authenticity. All referenced - logs, archives, signatures and run identities still need independent review. - """ - _schema_validate(result) - for value in (result["schema_version"], result["run"]["attempt"], result["recipe"]["exit_code"], - *(runtime["exit_code"] for runtime in result["runtime"]), - *(probe[field] for phase in ("unconfined", "confined") - for probe in result["recipe"][phase] for field in ("depth", "errno"))): - require_nonnegative_integer(value) - host, recipe = result["host"], result["recipe"] - if host["environment_id"] != RECIPE_ENVIRONMENTS[recipe["id"]]: - raise ValueError("recipe builder mismatch") - mac = host["environment_id"].startswith("macos") - architecture = "aarch64" if host["environment_id"] == "macos15-aarch64" else "x86_64" - if (host["architecture"] != architecture or host["kernel_architecture"] != architecture - or host["translated"]): - raise ValueError("wrong or translated host architecture") - if mac: - if (_version(host["os_version"])[0] != 15 or host["kind"] != "github-hosted-macos" - or not host["runner_image_version"] or host["oci_index_sha256"] is not None - or host["oci_manifest_sha256"] is not None or host["glibc_version"] is not None - or result["sdk"]["kind"] != "apple-sdk" or recipe["deployment_target"] != "13.0" - or recipe["glibc_symbols"]): - raise ValueError("macOS hosted identity/SDK/loader floor mismatch") - else: - if (host["kind"] != "oci-builder" or not host["oci_index_sha256"] - or not host["oci_manifest_sha256"] or host["runner_image_version"] is not None - or _version(host["glibc_version"]) > (2, 34) - or result["sdk"]["kind"] != "gnu-sysroot" or recipe["deployment_target"] is not None): - raise ValueError("unqualified GNU builder/sysroot identity") - for symbol in recipe["glibc_symbols"]: - if not symbol.startswith("GLIBC_") or _version(symbol[6:]) > (2, 34): - raise ValueError("GNU symbol exceeds glibc 2.34 floor") - names = [tool["name"] for tool in result["tools"]] - required = BASE_TOOLS | ({"viceroy", "wasmtime"} if recipe["id"] == "trusted-server-fastly.wasm" else set()) - if set(names) != required or len(names) != len(required): - raise ValueError("missing, duplicate or unselected tool identity") - if recipe["exit_code"] or not recipe["full_recipe"] or not recipe["same_boundary"]: - raise ValueError("full recipe must succeed inside the same boundary") - if recipe["download_attempts"] != {"cargo": "denied", "npm": "denied", "missing_tool": "failed"}: - raise ValueError("download/missing-tool negative fixtures required") - validate_socket_pair(recipe["unconfined"], recipe["confined"]) - if recipe["id"] == "trusted-server-fastly.wasm": - expected = {"fastly-viceroy"} - runtime_kind = "publisher-request" - runtime_architecture = "wasm32" - elif recipe["id"] == "tsjs": - expected = {"js-pair"} - runtime_kind = "js-tests-and-equality" - runtime_architecture = architecture - else: - expected = {host["environment_id"]} if mac else {"ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64"} - runtime_kind = "cli-startup" - runtime_architecture = architecture - runtimes = result["runtime"] - if {runtime["environment_id"] for runtime in runtimes} != expected or len(runtimes) != len(expected): - raise ValueError("missing actual compatibility/application execution") - for runtime in runtimes: - if (runtime["exit_code"] or not runtime["outside_checkout"] or runtime["kind"] != runtime_kind - or runtime["architecture"] != runtime_architecture - or runtime["executable_sha256"] != recipe["executable_sha256"] - or runtime["executable_after_sha256"] != recipe["executable_sha256"]): - raise ValueError("failed startup/application request or changed final bytes") - if mac and (_version(runtime["os_version"])[0] != 15 - or runtime["os_version"] != host["os_version"]): - raise ValueError("invented macOS compatibility") - if not mac and runtime_kind == "cli-startup": - version = _version(runtime["os_version"]) - expected_version = {"ubi9-x86_64": (9,), "ubuntu22.04-x86_64": (22, 4), - "debian12-x86_64": (12,)}[runtime["environment_id"]] - if version[:len(expected_version)] != expected_version: - raise ValueError("invented Linux runtime compatibility") - if runtime_kind != "cli-startup" and runtime["response_sha256"] is None: - raise ValueError("complete application/equality response evidence required") - return result - - -def require_qualified(result): - """Refuse self-conferred qualification even for complete observations.""" - validate_observation(result) - raise ValueError("qualification pending: independently authenticated hosted evidence and source approval required") - - -def check_execution_prerequisites(root, output): - """Refuse execution before shared Task 9 recipes and authentic pins exist.""" - root = Path(root).resolve(strict=True) - for name in ("build.sh", "inspect-native.py", "validate-fastly.py"): - if not (root / "scripts/release" / name).is_file(): - raise ValueError("Task 9 shared production recipe/runtime gates are not implemented") - tools = read_json_file(root / "scripts/release/release-tools.json", max_bytes=1048576) - preflight.validate_tools(tools) - # Staging cannot require production qualification: it measures candidate - # selections before the later source-reviewed result pins exist. - raise ValueError("authenticated candidate staging dispatcher pending resource authorization") - - -def main(): - """Offer bounded observation validation and a closed readiness check.""" - parser = argparse.ArgumentParser(description=__doc__) - commands = parser.add_subparsers(dest="command", required=True) - validate = commands.add_parser("validate-observation") - validate.add_argument("result", type=Path) - commands.add_parser("check-ready") - arguments = parser.parse_args() - try: - if arguments.command == "validate-observation": - validate_observation(read_json_file(arguments.result, max_bytes=1048576)) - sys.stdout.write("valid observation; release qualification remains pending\n") - else: - check_execution_prerequisites(RELEASE.parents[1], None) - except (ValueError, OSError) as error: - logging.error("Qualification refuses input: %s", error) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/release/qualification/socket_probe.py b/scripts/release/qualification/socket_probe.py deleted file mode 100644 index 36fef6c9a7..0000000000 --- a/scripts/release/qualification/socket_probe.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Measure real socket operations in this process and inherited descendants. - -No result here grants qualification. External destinations are documentation -addresses, not services: routing/refusal/timeout is recorded as unavailable. -""" - -import argparse -import errno -import json -import os -import socket -import subprocess -import sys -from pathlib import Path - -DENIAL = {errno.EPERM, errno.EACCES} - - -def internet_probe(depth, family, protocol, operation, destination): - """Attempt an operation, retaining the actual failing phase and errno.""" - address_family = socket.AF_INET if family == "ipv4" else socket.AF_INET6 - loopback = "127.0.0.1" if family == "ipv4" else "::1" - external = "192.0.2.1" if family == "ipv4" else "2001:db8::1" - record = {"depth": depth, "family": family, "protocol": protocol, "operation": operation, - "destination": destination, "outcome": "unavailable", "errno": 0, - "phase": "socket"} - sockets = [] - try: - transport = socket.SOCK_DGRAM if protocol == "udp" else socket.SOCK_STREAM - client = socket.socket(address_family, transport) - sockets.append(client) - client.settimeout(0.15) - address = loopback if destination == "loopback" else external - port = 9 - if protocol == "tcp" and operation in {"connect", "send"} and destination == "loopback": - # An unconfined local listener gives the client a positive endpoint. - # Its setup failure must never masquerade as client connect denial: - # still attempt the actual client syscall against the fallback port. - try: - server = socket.socket(address_family, socket.SOCK_STREAM) - sockets.append(server) - server.bind((loopback, 0)) - server.listen(1) - port = server.getsockname()[1] - except OSError: - pass - record["phase"] = operation - if operation == "bind": - client.bind((address, 0)) - elif operation == "connect": - client.connect((address, port)) - elif protocol == "udp": - client.sendto(b"qualification-probe", (address, port)) - else: - record["phase"] = "connect" - client.connect((address, port)) - record["phase"] = "send" - client.sendall(b"qualification-probe") - record["outcome"] = "allowed" - except OSError as error: - record["errno"] = error.errno or errno.ETIMEDOUT - record["outcome"] = "denied" if error.errno in DENIAL else "unavailable" - finally: - for connection in sockets: - connection.close() - return record - - -def unix_probe(depth, private_root): - """Exchange bytes over a filesystem Unix socket inside the private root.""" - record = {"depth": depth, "family": "unix", "operation": "exchange", - "destination": "private", "outcome": "unavailable", "errno": 0, - "phase": "socket", "protocol": "unix"} - path = str(private_root / f"probe-{depth}.sock") - sockets = [] - try: - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sockets.append(server) - record["phase"] = "bind" - server.bind(path) - server.listen(1) - client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sockets.append(client) - record["phase"] = "connect" - client.connect(path) - accepted, _ = server.accept() - sockets.append(accepted) - record["phase"] = "exchange" - client.sendall(b"private") - if accepted.recv(7) == b"private": - record["outcome"] = "allowed" - except OSError as error: - record["errno"] = error.errno or errno.ETIMEDOUT - record["outcome"] = "denied" if error.errno in DENIAL else "unavailable" - finally: - for connection in sockets: - connection.close() - if os.path.exists(path): - os.unlink(path) - return record - - -def probe_tree(private_root, depth=0): - """Close inherited FDs for descendants and probe three generations.""" - results = [internet_probe(depth, family, protocol, operation, destination) - for family in ("ipv4", "ipv6") for protocol in ("tcp", "udp") - for operation in ("bind", "connect", "send") - for destination in ("loopback", "external")] - results.append(unix_probe(depth, private_root)) - if depth < 2: - child = subprocess.run([sys.executable, "-I", "-B", str(Path(__file__).resolve()), - "--private-root", str(private_root), "--depth", str(depth + 1)], - env={"HOME": str(private_root), "TMPDIR": str(private_root), - "LANG": "C", "LC_ALL": "C"}, close_fds=True, - capture_output=True, check=False, timeout=15) - if child.returncode or len(child.stdout) > 65536: - raise ValueError("socket descendant probe failed") - results.extend(json.loads(child.stdout)) - return results - - -def main(): - """Print observations only; no caller-supplied commands are accepted.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--private-root", type=Path, required=True) - parser.add_argument("--depth", type=int, choices=(0, 1, 2), default=0) - arguments = parser.parse_args() - root = arguments.private_root.resolve(strict=True) - if not root.is_dir() or arguments.private_root.is_symlink(): - raise ValueError("probe requires a private existing directory") - sys.stdout.write(json.dumps(probe_tree(root, arguments.depth), separators=(",", ":")) + "\n") - - -if __name__ == "__main__": - main() diff --git a/scripts/release/release-tools.json b/scripts/release/release-tools.json index e15583a22b..17594dba50 100644 --- a/scripts/release/release-tools.json +++ b/scripts/release/release-tools.json @@ -97,7 +97,7 @@ "cargo_message_format": "json-render-diagnostics", "download_control": "actual-cargo-build-script-required", "fresh_target": true, - "network_boundary": "qualification-pending", + "network_boundary": "source-owned-os-denial-v1", "normalized_inputs": [ "compiler-locale-C", "compiler-timezone-UTC", @@ -153,7 +153,7 @@ "cargo_message_format": "json-render-diagnostics", "download_control": "actual-cargo-build-script-required", "fresh_target": true, - "network_boundary": "qualification-pending", + "network_boundary": "source-owned-os-denial-v1", "normalized_inputs": [ "compiler-locale-C", "compiler-timezone-UTC", @@ -209,7 +209,7 @@ "cargo_message_format": "json-render-diagnostics", "download_control": "actual-cargo-build-script-required", "fresh_target": true, - "network_boundary": "qualification-pending", + "network_boundary": "source-owned-os-denial-v1", "normalized_inputs": [ "compiler-locale-C", "compiler-timezone-UTC", @@ -265,7 +265,7 @@ "cargo_message_format": "json-render-diagnostics", "download_control": "actual-cargo-build-script-required", "fresh_target": true, - "network_boundary": "qualification-pending", + "network_boundary": "source-owned-os-denial-v1", "normalized_inputs": [ "compiler-locale-C", "compiler-timezone-UTC", @@ -299,7 +299,7 @@ "fresh_private_npm_caches": true, "fresh_source": "tracked-objects-only", "mandatory_tests": true, - "network_boundary": "qualification-pending", + "network_boundary": "source-owned-os-denial-v1", "node_version": "24.12.0", "npm_audit": false, "npm_fund": false, diff --git a/scripts/release/requirements.txt b/scripts/release/requirements.txt deleted file mode 100644 index 0ce5fb5634..0000000000 --- a/scripts/release/requirements.txt +++ /dev/null @@ -1,153 +0,0 @@ -# Install in a private Python 3.11+ virtual environment with --require-hashes. -# Includes every transitive dependency and published distribution SHA-256. -# typing-extensions is required by referencing on Python versions below 3.13. - -jsonschema==4.26.0 \ - --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ - --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce - -attrs==26.1.0 \ - --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ - --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 - -jsonschema-specifications==2025.9.1 \ - --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ - --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d - -referencing==0.37.0 \ - --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ - --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 - -rpds-py==2026.9.1 \ - --hash=sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25 \ - --hash=sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68 \ - --hash=sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495 \ - --hash=sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32 \ - --hash=sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96 \ - --hash=sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834 \ - --hash=sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0 \ - --hash=sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6 \ - --hash=sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e \ - --hash=sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa \ - --hash=sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759 \ - --hash=sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff \ - --hash=sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7 \ - --hash=sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c \ - --hash=sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903 \ - --hash=sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d \ - --hash=sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9 \ - --hash=sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e \ - --hash=sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b \ - --hash=sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a \ - --hash=sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a \ - --hash=sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2 \ - --hash=sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945 \ - --hash=sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91 \ - --hash=sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044 \ - --hash=sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69 \ - --hash=sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e \ - --hash=sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3 \ - --hash=sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4 \ - --hash=sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33 \ - --hash=sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52 \ - --hash=sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9 \ - --hash=sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb \ - --hash=sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35 \ - --hash=sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299 \ - --hash=sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c \ - --hash=sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063 \ - --hash=sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a \ - --hash=sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6 \ - --hash=sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12 \ - --hash=sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14 \ - --hash=sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0 \ - --hash=sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30 \ - --hash=sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31 \ - --hash=sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02 \ - --hash=sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af \ - --hash=sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef \ - --hash=sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0 \ - --hash=sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888 \ - --hash=sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a \ - --hash=sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c \ - --hash=sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b \ - --hash=sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86 \ - --hash=sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f \ - --hash=sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531 \ - --hash=sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6 \ - --hash=sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82 \ - --hash=sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d \ - --hash=sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7 \ - --hash=sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448 \ - --hash=sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391 \ - --hash=sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb \ - --hash=sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4 \ - --hash=sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e \ - --hash=sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c \ - --hash=sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15 \ - --hash=sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f \ - --hash=sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297 \ - --hash=sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36 \ - --hash=sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d \ - --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b \ - --hash=sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237 \ - --hash=sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb \ - --hash=sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477 \ - --hash=sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c \ - --hash=sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e \ - --hash=sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608 \ - --hash=sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07 \ - --hash=sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c \ - --hash=sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35 \ - --hash=sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0 \ - --hash=sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785 \ - --hash=sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad \ - --hash=sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc \ - --hash=sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9 \ - --hash=sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025 \ - --hash=sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f \ - --hash=sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009 \ - --hash=sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d \ - --hash=sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118 \ - --hash=sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db \ - --hash=sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9 \ - --hash=sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab \ - --hash=sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37 \ - --hash=sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d \ - --hash=sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc \ - --hash=sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9 \ - --hash=sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3 \ - --hash=sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569 \ - --hash=sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69 \ - --hash=sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b \ - --hash=sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018 \ - --hash=sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f \ - --hash=sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c \ - --hash=sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f \ - --hash=sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5 \ - --hash=sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445 \ - --hash=sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1 \ - --hash=sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e \ - --hash=sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429 \ - --hash=sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa \ - --hash=sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b \ - --hash=sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250 \ - --hash=sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e \ - --hash=sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83 \ - --hash=sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f \ - --hash=sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd \ - --hash=sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346 \ - --hash=sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858 \ - --hash=sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871 \ - --hash=sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da \ - --hash=sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0 \ - --hash=sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f \ - --hash=sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050 \ - --hash=sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31 \ - --hash=sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df \ - --hash=sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12 \ - --hash=sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567 - -typing-extensions==4.16.0 \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 diff --git a/scripts/release/rust_recipe.py b/scripts/release/rust_recipe.py deleted file mode 100644 index da6f7a0246..0000000000 --- a/scripts/release/rust_recipe.py +++ /dev/null @@ -1,1176 +0,0 @@ -"""Closed canonical Rust recipe shared by production and private qualification. - -Selectors remain closed until independently authenticated source-owned candidate -acquisitions exist. A candidate build never grants consumer or publishing trust. -""" -import argparse -import copy -from contextlib import ExitStack -import importlib.util -import hashlib -import logging -import os -import re -import selectors -import shutil -import shlex -import signal -import stat -import subprocess -import sys -import time -import tomllib -from pathlib import Path - -sys.dont_write_bytecode = True -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from common import deterministic_json_bytes, load_json_bytes, read_json_file, sha256_bytes -import preflight -import prepare - -RELEASE = Path(__file__).resolve().parent -ROOT = RELEASE.parents[1] -sys.path.insert(0, str(RELEASE / "qualification")) -_BOUNDARY_SOURCE = RELEASE / "qualification/boundaries.py" -_BOUNDARY_MODULE = sys.modules.get("boundaries") -if _BOUNDARY_MODULE is None: - _boundary_spec = importlib.util.spec_from_file_location("boundaries", _BOUNDARY_SOURCE) - if _boundary_spec is None or _boundary_spec.loader is None: - raise ValueError("source-owned qualification boundary module unavailable") - _BOUNDARY_MODULE = importlib.util.module_from_spec(_boundary_spec) - sys.modules["boundaries"] = _BOUNDARY_MODULE - try: - _boundary_spec.loader.exec_module(_BOUNDARY_MODULE) - except BaseException: - sys.modules.pop("boundaries", None) - raise -if Path(getattr(_BOUNDARY_MODULE, "__file__", "")).resolve() != _BOUNDARY_SOURCE: - raise ValueError("source-owned qualification boundary module mismatch") -DOCKER_EXECUTABLE = _BOUNDARY_MODULE.DOCKER_EXECUTABLE -LinuxBoundary = _BOUNDARY_MODULE.LinuxBoundary -MacBoundary = _BOUNDARY_MODULE.MacBoundary -probe_controls = _BOUNDARY_MODULE.probe_controls -MAX_CAPTURE = 134217728 -PROFILES = preflight.ambient_profiles() -CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" -CARGO_REGISTRY_CACHE_NAME = "index.crates.io-1949cf8c6b5b557f" -MAX_TOOL_FILE = 536870912 -MAX_TOOL_TREE = 8589934592 -MAX_TOOL_MEMBERS = 250000 -EXECUTING_HELPERS = ("common.py", "preflight.py", "prepare.py", "rust_recipe.py", "inspect-native.py", "validate-fastly.py", - "qualification/boundaries.py", "qualification/qualify.py", "qualification/socket_probe.py") -EXECUTING_HELPER_HASHES = {name: sha256_bytes(prepare._regular_bytes(RELEASE / name, 33554432)) for name in EXECUTING_HELPERS} - - -def ambient_profile(role): - """Return a copy of reviewed role policy; callers cannot choose its values.""" - if role not in PROFILES: - raise ValueError("unknown build role") - return copy.deepcopy(PROFILES[role]) - - -def artifact_selection(name): - """Select only the fixed cohort's default package/bin/target/features.""" - for artifact in preflight.fixed_artifacts(): - if artifact["name"] == name: - return artifact - raise ValueError("unknown fixed release asset") - - -def parse_request(arguments=None): - """Parse the seven closed fields without executable/config/profile inputs.""" - parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) - parser.add_argument("--asset", required=True) - parser.add_argument("--role", required=True, choices=("A", "B")) - parser.add_argument("--source-commit", required=True) - for name in ("js-dir", "js-inventory", "materials", "output"): - parser.add_argument("--" + name, required=True, type=Path) - args = parser.parse_args(arguments) - artifact_selection(args.asset) - preflight._commit_id(args.source_commit) - for name in ("js_dir", "js_inventory", "materials", "output"): - path = getattr(args, name) - if not path.is_absolute() or ".." in path.parts: - raise ValueError("absolute normalized request paths required") - return args - - -def fresh_root(path): - """Create private generated state only at a fresh real absolute pathname.""" - path = Path(path) - if any(character.isspace() or ord(character) < 32 or character in ",:" for character in str(path)): - raise ValueError("recipe path cannot inject compiler/mount arguments") - if not path.is_absolute() or os.path.lexists(path) or path.parent.resolve(strict=True) != path.parent: - raise ValueError("workspace/output root must be fresh and absolute") - path.mkdir(mode=0o700) - path.chmod(0o700) - return path - - -def tree_digest(root): - """Measure bounded regular immutable material trees, rejecting escaped links.""" - root = Path(root).resolve(strict=True) - if not root.is_dir(): - raise ValueError("material tree must be a directory") - records, total = [], 0 - for path in sorted(root.rglob("*")): - information = path.lstat() - name = str(path.relative_to(root)) - if len(records) >= 100000 or stat.S_ISLNK(information.st_mode): - raise ValueError("material tree links/member bound forbidden") - if stat.S_ISREG(information.st_mode): - data = prepare._regular_bytes(path, prepare.MAX_ARCHIVE) - total += len(data) - if total > 2147483648: - raise ValueError("material tree byte bound exceeded") - records.append({"path": name, "mode": stat.S_IMODE(information.st_mode), "sha256": sha256_bytes(data)}) - elif stat.S_ISDIR(information.st_mode): - records.append({"path": name, "mode": stat.S_IMODE(information.st_mode), "directory": True}) - else: - raise ValueError("unsupported material tree file") - return sha256_bytes(deterministic_json_bytes(records)) - - - -def tool_file_sha256(path): - """Stream an authenticated regular tool file through one held descriptor.""" - path = Path(path) - identity = lambda value: (value.st_dev, value.st_ino, value.st_mode, value.st_nlink, value.st_size, value.st_mtime_ns, value.st_ctime_ns) - with os.fdopen(os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW), "rb") as stream: - before = os.fstat(stream.fileno()) - if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_TOOL_FILE or identity(before) != identity(path.lstat()): - raise ValueError("bounded regular authenticated tool file required") - digest, total = hashlib.sha256(), 0 - deadline = time.monotonic() + 120 - while chunk := stream.read(1048576): - total += len(chunk) - if total > MAX_TOOL_FILE or time.monotonic() > deadline: - raise ValueError("tool file hash byte/time bound exceeded") - digest.update(chunk) - if total != before.st_size or identity(os.fstat(stream.fileno())) != identity(before) or identity(path.lstat()) != identity(before): - raise ValueError("tool file changed during held-descriptor measurement") - return digest.hexdigest() - - -def _resolve_tool_link(path): - current, pending, links, components = Path("/"), list(Path(path).parts[1:]), 0, 0 - while pending: - components += 1 - if components > 8192: - raise ValueError("tool link resolution component bound exceeded") - part = pending.pop(0) - if part in {"", "."}: - continue - if part == "..": - current = current.parent - continue - candidate = current / part - if candidate.is_symlink(): - links += 1 - if links > 64: - raise ValueError("tool link cycle/resolution bound exceeded") - raw = os.readlink(candidate) - if len(os.fsencode(raw)) > 4096: - raise ValueError("tool link target bound exceeded") - target = Path(raw) - if target.is_absolute(): - current = Path("/") - parts = list(target.parts[1:]) - else: - parts = list(target.parts) - pending = parts + pending - else: - candidate.lstat() - current = candidate - return current - - -def tool_tree_digest(root, *, approved_roots): - """Measure tool/SDK aliases without weakening source/vendor material policy. - - Preserve every raw symlink and require bounded final resolution within an - explicitly authenticated immutable root. Hash regular files by streaming; - never delete SDK aliases or rewrite vendor distribution contents. - """ - original = Path(root) - roots = [Path(path).resolve(strict=True) for path in approved_roots] - root = original.resolve(strict=True) - if (not roots or len(roots) > 32 or len(roots) != len(set(roots)) or Path("/") in roots - or root not in roots or original.is_symlink() or not root.is_dir()): - raise ValueError("explicit authenticated real tool roots required") - records, total = [], 0 - deadline = time.monotonic() + 1200 - for path in sorted(root.rglob("*")): - if len(records) >= MAX_TOOL_MEMBERS or time.monotonic() > deadline: - raise ValueError("tool tree member/time bound exceeded") - information = path.lstat() - record = {"path": str(path.relative_to(root)), "mode": stat.S_IMODE(information.st_mode)} - if stat.S_ISLNK(information.st_mode): - raw = os.readlink(path) - resolved = _resolve_tool_link(path) - approved = [parent for parent in roots if resolved.is_relative_to(parent)] - if not approved: - raise ValueError("tool link escapes authenticated immutable roots") - if path.lstat() != information or os.readlink(path) != raw: - raise ValueError("tool link changed during measurement") - parent = max(approved, key=lambda value: len(value.parts)) - record.update(link=raw, resolved_root=roots.index(parent), resolved_path=str(resolved.relative_to(parent))) - elif stat.S_ISREG(information.st_mode): - total += information.st_size - if total > MAX_TOOL_TREE: - raise ValueError("tool tree byte bound exceeded") - record["sha256"] = tool_file_sha256(path) - elif stat.S_ISDIR(information.st_mode): - record["directory"] = True - else: - raise ValueError("unsupported authenticated tool tree file") - records.append(record) - return sha256_bytes(deterministic_json_bytes(records)) - - -def check_tree(root, expected): - """Reject mutation against a previously measured or authenticated tree.""" - if not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected) or tree_digest(root) != expected: - raise ValueError("immutable material tree changed") - - -class RecipeProcessError(ValueError): - """Retain bounded process results for expected negative fixture evidence.""" - - def __init__(self, code, stdout, stderr): - super().__init__(f"recipe process failed with exit code {code}") - self.code, self.stdout, self.stderr = code, stdout, stderr - - -def capture(argv, environment, cwd, log, *, timeout=7200, maximum=MAX_CAPTURE, umask=0o022, boundary=None): - """Drain independent bounded stdout/stderr; close/reap and clean OS boundary. - - Fresh log descriptors open before descendants start. Compiler hooks cannot - redirect parent writes with symlinks. Cargo stdout remains unchanged JSON. - """ - result = {"stdout": bytearray(), "stderr": bytearray()} - with ExitStack() as stack: - logs = {name: stack.enter_context(Path(str(log) + "." + name).open("xb")) for name in result} - process = subprocess.Popen(list(map(str, argv)), env=environment, cwd=cwd, - stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - close_fds=True, start_new_session=True, umask=umask) - deadline = time.monotonic() + timeout - poller = selectors.DefaultSelector() - for name in result: - poller.register(getattr(process, name), selectors.EVENT_READ, name) - try: - while poller.get_map(): - if time.monotonic() >= deadline: - raise ValueError("recipe process timeout") - for key, _ in poller.select(min(0.25, deadline - time.monotonic())): - chunk = os.read(key.fileobj.fileno(), 65536) - if not chunk: - poller.unregister(key.fileobj) - else: - result[key.data].extend(chunk) - if len(result[key.data]) > maximum: - raise ValueError("recipe capture bound exceeded") - remaining = deadline - time.monotonic() - if remaining <= 0: - raise ValueError("recipe process timeout") - code = process.wait(timeout=remaining) - finally: - poller.close() - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - for name, value in result.items(): - getattr(process, name).close() - logs[name].write(value) - if boundary is not None and hasattr(boundary, "cleanup"): - boundary.cleanup(list(map(str, argv))) - if code: - raise RecipeProcessError(code, bytes(result["stdout"]), bytes(result["stderr"])) - return bytes(result["stdout"]), bytes(result["stderr"]) - - -def cargo_command(cargo, artifact, target, configuration): - """Construct the sole reviewed compiler invocation from source selection.""" - if artifact != artifact_selection(artifact["name"]): - raise ValueError("unreviewed artifact selection") - return [str(cargo), "build", "--package", artifact["package"], "--bin", artifact["bin"], - "--target", artifact["target"], "--release", "--frozen", - "--message-format=json-render-diagnostics", "--target-dir", str(target), - "--config", str(configuration)] - - -def final_cargo_artifact(stdout, artifact, target_root, *, source_root=None, reader=None): - """Join exactly one final Cargo event to the source-owned expected output.""" - root = Path(target_root).resolve(strict=True) - name = artifact["bin"] + (".wasm" if artifact["kind"] == "wasm-core" else "") - expected = root / artifact["target"] / "release" / name - events = [] - for line in stdout.splitlines(): - event = load_json_bytes(line, max_bytes=1048576) - if not isinstance(event, dict): - raise ValueError("Cargo JSON event must be an object") - if event.get("reason") != "compiler-artifact" or event.get("executable") is None: - continue - target = event.get("target", {}) - package = event.get("package_id", "") - if (target.get("name") == artifact["bin"] and target.get("kind") == ["bin"] - and isinstance(package, str) and re.search(r"(?:/|#)" + re.escape(artifact["package"]) + r"(?:#|@)", package)): - events.append(event) - if len(events) != 1: - raise ValueError("exactly one selected final Cargo executable required") - event = events[0] - profile = event.get("profile", {}) - if (event.get("features") != artifact["features"] or profile.get("test") is not False - or profile.get("opt_level") != "3" or type(profile.get("debuginfo")) is not int or profile.get("debuginfo") != 1): - raise ValueError("final Cargo features/release debug profile mismatch") - actual = Path(event["executable"]) - if actual != expected: - raise ValueError("final Cargo output is escaped, unrelated or absent") - if source_root is not None: - crate = Path(source_root) / "crates" / artifact["package"] - if event.get("manifest_path") != str(crate / "Cargo.toml") or not event["package_id"].startswith("path+file://" + str(crate) + "#"): - raise ValueError("final Cargo output package/source identity mismatch") - if reader is None: - if actual.is_symlink() or not actual.is_file() or actual.resolve(strict=True) != expected: - raise ValueError("final Cargo output is escaped, unrelated or absent") - data = prepare._regular_bytes(actual, 536870912) - else: - data = reader(actual) - if not isinstance(data, bytes) or not data or len(data) > 536870912: - raise ValueError("final Cargo output byte observation missing or oversized") - return actual - - -def _material_path(root, name): - if not isinstance(name, str) or not re.fullmatch(r"[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*", name) or any(part in {".", ".."} for part in name.split("/")): - raise ValueError("unsafe Cargo material index path") - path = Path(root) / name - if path.is_symlink() or not path.resolve(strict=True).is_relative_to(Path(root).resolve(strict=True)): - raise ValueError("Cargo material index path escapes pool") - return path - - -def verify_cargo_materials(lock_bytes, index, pool): - """Authenticate original archives/Git pins joined exactly to source Cargo.lock. - - The index is closed data; it never carries executable commands/configuration. - Git workspace normalization belongs to the selected authenticated Cargo's - vendor command, not hand-edited dependency manifests. - """ - if (not isinstance(index, dict) or set(index) != {"schema_version", "registry", "git"} - or type(index["schema_version"]) is not int or index["schema_version"] != 1 - or not isinstance(index["registry"], list) or not isinstance(index["git"], list)): - raise ValueError("closed Cargo material index required") - lock = tomllib.loads(lock_bytes.decode("utf-8")) - required_registry, required_git = {}, {} - for package in lock.get("package", []): - source = package.get("source", "") - if source.startswith("registry+"): - key = (package["name"], package["version"], source) - if key in required_registry: - raise ValueError("duplicate Cargo lock registry package") - required_registry[key] = package["checksum"] - elif source.startswith("git+"): - if not re.fullmatch(r"git\+https://[^#]+#[a-f0-9]{40}", source): - raise ValueError("unsupported locked Git source") - required_git[source] = source.rsplit("#", 1)[1] - elif source: - raise ValueError("unsupported locked Cargo source") - archives, registry_seen, git_seen = {}, set(), set() - for record in index["registry"]: - if not isinstance(record, dict) or set(record) != {"name", "version", "source", "checksum", "archive"}: - raise ValueError("closed Cargo registry index record required") - key = (record["name"], record["version"], record["source"]) - if key in registry_seen or key not in required_registry or record["checksum"] != required_registry[key]: - raise ValueError("Cargo material index registry mismatch") - registry_seen.add(key) - path = _material_path(pool, record["archive"]) - checked = prepare.verify_archive(path, record["checksum"], algorithm="sha256", expected_root=record["name"] + "-" + record["version"]) - archives[key] = checked - for record in index["git"]: - if not isinstance(record, dict) or set(record) != {"source", "commit", "tree", "object_store"}: - raise ValueError("closed Cargo Git index record required") - source = record["source"] - if source in git_seen or source not in required_git or record["commit"] != required_git[source]: - raise ValueError("Cargo material index Git revision mismatch") - git_seen.add(source) - if not isinstance(record["tree"], str) or not re.fullmatch(r"[a-f0-9]{40}", record["tree"]): - raise ValueError("Cargo Git tree must have authenticated full identity") - prepare.verify_git_revision(_material_path(pool, record["object_store"]), record["commit"], record["tree"]) - if registry_seen != set(required_registry) or git_seen != set(required_git): - raise ValueError("Cargo material index missing/extra locked packages") - return archives - - - -def prepare_cargo_home(seed, home, archives, index, pool): - """Populate fresh private Cargo state from original verified materials. - - Seed only authenticated registry index metadata and pinned bare Git objects. - Never trust restored registry source/extraction/cache trees. Pinned Cargo - expands original archives and normalizes Git workspace inheritance itself. - """ - seed, home = Path(seed), Path(home) - if list(home.iterdir()) or {path.name for path in seed.iterdir()} - {"registry", "git"}: - raise ValueError("fresh Cargo home and closed metadata seed required") - registry = seed / "registry" - if registry.exists(): - if {path.name for path in registry.iterdir()} != {"index"}: - raise ValueError("Cargo seed restored registry sources/cache are forbidden") - namespaces = list((registry / "index").iterdir()) - if len(namespaces) != 1 or namespaces[0].name != CARGO_REGISTRY_CACHE_NAME or not namespaces[0].is_dir(): - raise ValueError("source-owned default Cargo registry namespace required") - shutil.copytree(registry / "index", home / "registry/index") - elif archives: - raise ValueError("authenticated Cargo registry index metadata required") - cache = home / "registry/cache" / CARGO_REGISTRY_CACHE_NAME - if archives: - cache.mkdir(parents=True) - for (name, version, source), checked in archives.items(): - if source != CARGO_REGISTRY_SOURCE or not re.fullmatch(r"[a-zA-Z0-9_-]+", name) or not re.fullmatch(r"[0-9][a-zA-Z0-9.+-]*", version): - raise ValueError("unselected Cargo registry material identity") - data = prepare._authenticated_bytes(checked.path, checked.integrity, "sha256", prepare.MAX_ARCHIVE) - (cache / (name + "-" + version + ".crate")).write_bytes(data) - git = seed / "git" - if git.exists(): - if {path.name for path in git.iterdir()} != {"db"}: - raise ValueError("Cargo seed restored Git checkouts are forbidden") - databases = list((git / "db").iterdir()) - if len(databases) != len(index["git"]): - raise ValueError("Cargo seed Git object-store membership mismatch") - matched = set() - for database in databases: - matches = [] - for record in index["git"]: - try: - prepare.verify_git_revision(database, record["commit"], record["tree"]) - matches.append(record["source"]) - except ValueError: - continue - if len(matches) != 1 or matches[0] in matched: - raise ValueError("Cargo seed Git bytes do not join the authenticated original revision") - matched.add(matches[0]) - shutil.copytree(git / "db", home / "git/db") - elif index["git"]: - raise ValueError("authenticated pinned Cargo Git object metadata required") - - -def validate_pair_observations(a, b, qualification): - """Reject dishonest/missing ambient variation and normalized compiler claims.""" - fields = {"role", "checkout_root", "umask", "jobs", "locale", "timezone", "start_time_ns", - "execution_user", "user_variation", "normalized"} - for observation, role in ((a, "A"), (b, "B")): - if not isinstance(observation, dict) or set(observation) != fields or observation["role"] != role: - raise ValueError("missing role-specific ambient observations") - selected = ambient_profile(role) - for field in ("umask", "jobs"): - if observation[field] != selected[field] or type(observation[field]) is not type(selected[field]): - raise ValueError(f"actual {field} observation differs from selected role") - if type(observation["start_time_ns"]) is not int or observation["start_time_ns"] < 0: - raise ValueError("actual start time observation required") - if not isinstance(observation["checkout_root"], str) or not Path(observation["checkout_root"]).is_absolute(): - raise ValueError("actual external checkout root observation required") - if a["checkout_root"] == b["checkout_root"] or a["start_time_ns"] == b["start_time_ns"]: - raise ValueError("selected root/start-time variation missing") - if a["locale"] != "C" or b["locale"] != qualification["locale"] or a["locale"] == b["locale"]: - raise ValueError("selected locale variation missing") - if a["timezone"] != "UTC" or b["timezone"] != qualification["timezone"] or a["timezone"] == b["timezone"]: - raise ValueError("selected timezone variation missing") - if a["normalized"] != b["normalized"] or a["normalized"].get("locale") != "C" or a["normalized"].get("timezone") != "UTC" or a["normalized"].get("source_root") != "/source": - raise ValueError("normalized values must be identical and cannot be reported varied") - if qualification["user_variation"]: - if a["execution_user"] == b["execution_user"] or a["user_variation"] != "passed" or b["user_variation"] != "passed": - raise ValueError("qualified execution user variation missing") - elif a["user_variation"] != "unsupported" or b["user_variation"] != "unsupported" or a["execution_user"] != b["execution_user"]: - raise ValueError("unsupported user variation must be reported honestly") - - - -# Empty until vendor acquisitions, external roots and execution identities have -# independent review. This source table is the authority for candidate staging; -# downloaded JSON and version output can never authenticate their own tools. -CANDIDATE_ACQUISITIONS = {} - - - -def executing_source_context(commit): - """Bind independent source approval to the actual executing checkout/helpers. - - No acquisition policy embeds its own future Git commit. Compare immutable - requested objects to executing ROOT, and also to helper bytes captured when - this module loaded, so later edits cannot retitle already loaded code. - """ - preflight._commit_id(commit) - if Path(__file__).resolve() != ROOT / "scripts/release/rust_recipe.py" or RELEASE != ROOT / "scripts/release": - raise ValueError("executing helper checkout identity mismatch") - for module in (preflight, prepare, sys.modules["common"]): - if Path(module.__file__).resolve().parent != RELEASE: - raise ValueError("executing helper import comes from an alternate checkout") - try: - snapshot = preflight.source_snapshot(ROOT, commit, {}) - except (OSError, ValueError) as error: - raise ValueError("executing source commit/helper snapshot mismatch") from error - for name, digest in EXECUTING_HELPER_HASHES.items(): - record = snapshot["files"].get("scripts/release/" + name) - if record is None or record["sha256"] != digest: - raise ValueError("executing helper bytes changed after import or are untracked") - return {"source": str(ROOT), "source_commit": commit, "snapshot": snapshot} - - -def candidate_selection(asset, commit): - """Authenticate private candidate selections without requiring future results. - - Source-owned acquisition pins precede Task 8 measurement. Results, recipe - qualification and signing permissions cannot bootstrap these pins. Missing - pins refuse before workspace creation. There is deliberately no CLI bypass. - """ - artifact = artifact_selection(asset) - preflight._commit_id(commit) - environment_id = ("macos15-aarch64" if artifact["target"] == "aarch64-apple-darwin" - else "macos15-x86_64" if artifact["target"] == "x86_64-apple-darwin" - else "gnu-builder-x86_64") - selected = CANDIDATE_ACQUISITIONS.get(environment_id) - if selected is None: - raise ValueError("authenticated candidate acquisition pins pending independent review") - required = {"environment_id", "tools", "readonly", "trees", "tool_trees", - "archive_pool", "cargo_seed", "rustup", "sdk", "variation", "image", "runtime"} - if not isinstance(selected, dict) or set(selected) != required or selected["environment_id"] != environment_id: - raise ValueError("candidate acquisition policy must not select source/context") - selected = copy.deepcopy(selected) - required_tools = {"python", "git", "cargo", "rustc", "cc", "cxx", "linker", "ar", "cmake", "shell"} - if environment_id == "gnu-builder-x86_64": - required_tools.add("docker") - if artifact["kind"] == "wasm-core": - required_tools.update({"wasmtime", "viceroy"}) - if set(selected["tools"]) != required_tools: - raise ValueError("candidate acquisition tool closure incomplete") - if environment_id == "gnu-builder-x86_64": - if selected["tools"]["docker"]["path"] != DOCKER_EXECUTABLE: - raise ValueError("candidate must bind the actual source-owned Docker executable") - for name, tool in selected["tools"].items(): - if not isinstance(tool, dict) or set(tool) != {"path", "sha256", "acquisition_sha256"}: - raise ValueError("candidate acquisition tool binding incomplete") - path = Path(tool["path"]) - if not path.is_absolute() or path.is_symlink() or not isinstance(tool["acquisition_sha256"], str) or not re.fullmatch(r"[a-f0-9]{64}", tool["acquisition_sha256"]): - raise ValueError("candidate acquisition authority required") - if tool_file_sha256(path) != tool["sha256"]: - raise ValueError("candidate acquired executable digest mismatch") - if selected["tools"]["git"]["path"] != "/usr/bin/git": - raise ValueError("source inspection must bind the actual trusted Git executable") - if Path(selected["tools"]["python"]["path"]).resolve() != Path(sys.executable).resolve(): - raise ValueError("candidate interpreter must be the actual independently authenticated bootstrap") - for path, digest in selected["trees"].items(): - check_tree(path, digest) - for key in ("archive_pool", "cargo_seed"): - if selected[key] not in selected["trees"]: - raise ValueError("candidate immutable original material tree missing") - tool_roots = list(selected["tool_trees"]) - for key in ("rustup", "sdk"): - if selected[key] not in selected["tool_trees"]: - raise ValueError("candidate authenticated tool/SDK tree missing") - for path, digest in selected["tool_trees"].items(): - if tool_tree_digest(path, approved_roots=tool_roots) != digest: - raise ValueError("candidate authenticated tool/SDK tree changed") - context = executing_source_context(commit) - selected.update(source=context["source"], source_commit=context["source_commit"]) - return selected - - -def vendor_configuration(lock_bytes, vendor): - """Generate reviewed exact source replacement, discarding Cargo's suggestions.""" - lock = tomllib.loads(lock_bytes.decode()) - configuration = {"source": {"release-vendor": {"directory": str(vendor)}}, "net": {"offline": True}} - git = sorted({package["source"] for package in lock["package"] if package.get("source", "").startswith("git+")}) - registries = {package["source"] for package in lock["package"] if package.get("source", "").startswith("registry+")} - if registries - {CARGO_REGISTRY_SOURCE}: - raise ValueError("unselected Cargo registry source") - if registries: - configuration["source"]["crates-io"] = {"replace-with": "release-vendor"} - for index, source in enumerate(git): - match = re.fullmatch(r"git\+(https://[^?#]+)\?rev=([a-f0-9]{40})#([a-f0-9]{40})", source) - if not match or match[2] != match[3]: - raise ValueError("unselected Cargo Git URL/revision") - configuration["source"][f"release-git-{index}"] = {"git": match[1], "rev": match[2], "replace-with": "release-vendor"} - # JSON strings are also valid quoted TOML strings for these selected paths. - quote = lambda value: deterministic_json_bytes(value).decode().strip() - lines = ["[net]", "offline = true"] - for name, record in sorted(configuration["source"].items()): - lines.extend(["", "[source." + quote(name) + "]"]) - lines.extend(key + " = " + quote(value) for key, value in sorted(record.items())) - data = ("\n".join(lines) + "\n").encode() - if tomllib.loads(data.decode()) != configuration: - raise ValueError("source-owned Cargo configuration encoding mismatch") - return data - - -def write_vendor_configuration(lock_bytes, vendor, configuration, boundary): - """Write source-owned Cargo replacement paths in the execution namespace.""" - directory = boundary.map_path(vendor) if isinstance(boundary, LinuxBoundary) else str(vendor) - Path(configuration).write_bytes(vendor_configuration(lock_bytes, directory)) - - -def compare_final_bytes(a, b, artifact): - """Require full Fastly equality; retain nonblocking native mismatch measures.""" - left = prepare._regular_bytes(a, 536870912) - right = prepare._regular_bytes(b, 536870912) - equal = left == right - blocking = artifact["kind"] == "wasm-core" - if blocking and not equal: - raise ValueError("complete final Fastly A/B bytes differ") - return {"equal": equal, "blocking": blocking, "A_sha256": sha256_bytes(left), - "B_sha256": sha256_bytes(right), "A_size": len(left), "B_size": len(right)} - - - -def snapshot_final(selected, protected_root, name, *, data=None): - """Snapshot exact Cargo bytes into a directory denied to compile descendants. - - The caller creates and protects this directory in the compile boundary - before launching any build. Inspection/runtime gates use this stable copy, - so detached compiler children cannot race a mutable target after evidence. - """ - if not re.fullmatch(r"[a-zA-Z0-9_.-]+", name): - raise ValueError("invalid fixed final executable name") - path = Path(protected_root) / name - if os.path.lexists(path): - raise ValueError("accepted final executable must be fresh") - if data is None: - data = prepare._regular_bytes(selected, 536870912) - if not isinstance(data, bytes) or not data or len(data) > 536870912: - raise ValueError("bounded selected executable snapshot required") - with path.open("xb") as output: - output.write(data) - path.chmod(0o755 if not name.endswith(".wasm") else 0o644) - if sha256_bytes(prepare._regular_bytes(path, 536870912)) != sha256_bytes(data): - raise ValueError("accepted final bytes changed during snapshot") - return path - - -def _load_gate(name): - spec = importlib.util.spec_from_file_location(name.replace("-", "_"), RELEASE / (name + ".py")) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _compile_environment(selection, workspace, artifact, request, snapshot, jobs): - source, vendor, target = workspace / "source", workspace / "vendor", workspace / "target" - commit, epoch = request.source_commit, snapshot["source_date_epoch"] - js_dir, inventory = workspace / "js", workspace / "js-inventory.json" - tools = {name: record["path"] for name, record in selection["tools"].items()} - paths = {"home": str(workspace / "home"), "cargo_home": str(workspace / "cargo-home"), - "rustup_home": selection["rustup"], "tmpdir": str(workspace / "tmp"), - "tool_path": sorted({str(Path(path).parent) for path in tools.values()}), "sysroot": selection["sdk"]} - environment = preflight.build_environment({}, commit, epoch, artifact["target"], paths) - environment.update({"RUSTC": tools["rustc"], "CC": tools["cc"], "CXX": tools["cxx"], "AR": tools["ar"], - "CMAKE": tools["cmake"], "CARGO_BUILD_JOBS": str(jobs), "CARGO_PROFILE_RELEASE_DEBUG": "1", - "CARGO_PROFILE_RELEASE_STRIP": "none", "TSJS_PREBUILT_DIR": str(js_dir), - "TSJS_PREBUILT_INVENTORY": str(inventory)}) - remaps = ((source, "/source"), (vendor, "/vendor"), (target, "/target"), - (Path(selection["sdk"]), "/sdk"), (Path(selection["rustup"]), "/rust-toolchain"), - (workspace, "/build")) - host = artifact["target"] if artifact["target"].endswith("apple-darwin") else "x86_64-unknown-linux-gnu" - environment["CARGO_TARGET_" + host.replace("-", "_").upper() + "_LINKER"] = tools["linker"] - environment["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join("--remap-path-prefix=" + str(path) + "=" + normalized for path, normalized in remaps) - flags = " ".join("-ffile-prefix-map=" + str(path) + "=" + normalized + " -fdebug-prefix-map=" + str(path) + "=" + normalized for path, normalized in remaps) - environment["CFLAGS"] = flags - environment["CXXFLAGS"] = flags - return environment, tools - - -def native_cli_contract(source, commit): - """Bind native CLI version and embedded fictional scaffold to prepared source.""" - preflight._commit_id(commit) - source = Path(source) - workspace = tomllib.loads(prepare._regular_bytes(source / "Cargo.toml", 1048576).decode()) - package = tomllib.loads(prepare._regular_bytes(source / "crates/trusted-server-cli/Cargo.toml", 1048576).decode())["package"] - if package.get("name") != "trusted-server-cli": - raise ValueError("native CLI owning package mismatch") - version = package.get("version") - if version == {"workspace": True}: - version = workspace["workspace"]["package"]["version"] - if not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?", version): - raise ValueError("native CLI source package version invalid") - scaffold = prepare._regular_bytes(source / "trusted-server.example.toml", 1048576) - if not scaffold or not tomllib.loads(scaffold.decode()): - raise ValueError("native CLI source scaffold missing") - return {"package_version": version, "source_commit": commit, "scaffold": scaffold, - "version_stdout": ("ts " + version + " (" + commit + ")\n").encode()} - - -def native_cli_outcomes(contract, help_output, version, initialization, scaffold): - """Require actual help, full source/version binding and exact generated config.""" - if b"Usage: ts" not in help_output: - raise ValueError("native CLI help startup missing") - if version != contract["version_stdout"]: - raise ValueError("native CLI version/source binding mismatch") - if not re.fullmatch(rb"Initialized config at [^\r\n]+/trusted-server\.toml\n", initialization): - raise ValueError("native CLI config initialization outcome missing") - if scaffold != contract["scaffold"]: - raise ValueError("native CLI generated scaffold missing or differs from prepared source") - return {"help": {"exit_code": 0, "stdout_sha256": sha256_bytes(help_output)}, - "version": {"exit_code": 0, "stdout_sha256": sha256_bytes(version), - "package_version": contract["package_version"], "source_commit": contract["source_commit"]}, - "config_init": {"exit_code": 0, "stdout_sha256": sha256_bytes(initialization), - "scaffold_sha256": sha256_bytes(scaffold), "scaffold_size": len(scaffold)}} - - -def native_cli_probe(executable, runtime, contract, environment, log_root): - """Run fixed native commands outside checkout with fresh writable config state.""" - generated = runtime / "output" - generated.mkdir(mode=0o700) - configuration = generated / "trusted-server.toml" - help_output, _ = capture([str(executable), "--help"], environment, generated, log_root / "help", timeout=30, maximum=65536) - version, _ = capture([str(executable), "--version"], environment, generated, log_root / "version", timeout=30, maximum=65536) - initialized, _ = capture([str(executable), "config", "init", "--app-config", str(configuration)], - environment, generated, log_root / "config-init", timeout=30, maximum=65536) - scaffold = prepare._regular_bytes(configuration, 1048576) - return native_cli_outcomes(contract, help_output, version, initialized, scaffold) - - -def native_runtime_identity(output, identity): - """Parse actual selected GNU OS/architecture and positive CLI help output.""" - expected = {"ubi9-x86_64": ("rhel", r"9(?:\.[0-9]+)*"), - "ubuntu22.04-x86_64": ("ubuntu", r"22\.04(?:\.[0-9]+)*"), - "debian12-x86_64": ("debian", r"12(?:\.[0-9]+)*")} - if identity not in expected or len(output) > 65536: - raise ValueError("unknown selected runtime identity") - try: - release, rest = output.decode("utf-8", errors="strict").split("\nTS_RELEASE_ARCH\n") - architecture, help_output = rest.split("\nTS_RELEASE_HELP\n") - except (ValueError, UnicodeError) as error: - raise ValueError("missing actual runtime OS/architecture evidence") from error - fields = {} - for line in release.splitlines(): - if not line or line.startswith("#"): - continue - key, separator, value = line.partition("=") - if not separator or not re.fullmatch(r"[A-Z_]+", key) or key in fields: - raise ValueError("invalid runtime OS identity fields") - values = shlex.split(value) - if len(values) != 1: - raise ValueError("invalid runtime OS identity value") - fields[key] = values[0] - operating_system, version = expected[identity] - if fields.get("ID") != operating_system or not re.fullmatch(version, fields.get("VERSION_ID", "")): - raise ValueError("actual runtime OS/version differs from selected image slot") - if architecture != "x86_64" or "Usage:" not in help_output: - raise ValueError("actual runtime architecture/CLI startup mismatch") - return {"os_id": operating_system, "os_version": fields["VERSION_ID"], "architecture": architecture, - "help_stdout_sha256": sha256_bytes(help_output.encode())} - - -def _native_startup(final, artifact, selection, workspace, environment): - """Run exact CLI outside checkout on selected real macOS or clean OCI images.""" - runtime = workspace / "native-runtime" - runtime.mkdir(mode=0o755) - executable = runtime / "ts" - shutil.copyfile(final, executable) - executable.chmod(0o755) - before = sha256_bytes(prepare._regular_bytes(executable, 536870912)) - observations = [] - contract = native_cli_contract(workspace / "source", selection["source_commit"]) - if artifact["target"].endswith("apple-darwin"): - import platform - if sys.platform != "darwin" or platform.mac_ver()[0].split(".")[0] != "15": - raise ValueError("actual macOS15 native startup required; newer local macOS is unqualified") - architecture = "aarch64" if platform.machine() == "arm64" else platform.machine() - if architecture != artifact["target"].split("-")[0]: - raise ValueError("actual native startup architecture mismatch") - translated = subprocess.run(["/usr/sbin/sysctl", "-in", "sysctl.proc_translated"], capture_output=True, timeout=10, check=False) - if translated.stdout.strip() == b"1": - raise ValueError("translated native startup forbidden") - commands = native_cli_probe(executable, runtime, contract, - {"HOME": str(runtime), "TMPDIR": str(runtime), "LANG": "C", "LC_ALL": "C", "TZ": "UTC"}, runtime) - observations.append({"environment_id": selection["environment_id"], "os_version": platform.mac_ver()[0], - "architecture": architecture, "executable_sha256": before, - "stdout_sha256": commands["help"]["stdout_sha256"], "commands": commands}) - else: - expected = {"ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64"} - if not isinstance(selection["runtime"], dict) or set(selection["runtime"]) != expected: - raise ValueError("all three selected clean GNU runtime images required") - for identity, image in sorted(selection["runtime"].items()): - if not isinstance(image, str) or not re.fullmatch(r"[a-z0-9./_-]+@sha256:[a-f0-9]{64}", image): - raise ValueError("authenticated clean native OCI runtime digest required") - control = workspace / ("native-control-" + identity) - control.mkdir(mode=0o755) - generated = control / "output" - generated.mkdir(mode=0o777) - generated.chmod(0o777) - boundary = LinuxBoundary(control, [runtime], image) - runtime_environment = {"HOME": "/work/output", "TMPDIR": "/work/output", "LANG": "C", "LC_ALL": "C", "TZ": "UTC"} - driver = ('umask 022; cat /etc/os-release && printf "\\nTS_RELEASE_ARCH\\n" && uname -m ' - '&& printf "TS_RELEASE_HELP\\n" && "$1" --help ' - '&& printf "TS_RELEASE_VERSION\\n" && "$1" --version ' - '&& printf "TS_RELEASE_INIT\\n" && "$1" config init --app-config "$2" ' - '&& printf "TS_RELEASE_SCAFFOLD\\n" && test -f "$2" && test ! -L "$2" && cat "$2"') - argv = boundary.command(["/bin/sh", "-c", driver, "release-runtime", str(executable), "/work/output/trusted-server.toml"], - environment=runtime_environment, cwd=generated) - output, _ = capture(argv, {"HOME": str(control), "LANG": "C", "LC_ALL": "C"}, - runtime, runtime / identity, timeout=60, maximum=65536, boundary=boundary) - try: - help_and_identity, remaining = output.split(b"TS_RELEASE_VERSION\n") - version, remaining = remaining.split(b"TS_RELEASE_INIT\n") - initialized, scaffold = remaining.split(b"TS_RELEASE_SCAFFOLD\n") - help_output = help_and_identity.split(b"\nTS_RELEASE_HELP\n")[1] - except (ValueError, IndexError) as error: - raise ValueError("native runtime missing help/version/config-init evidence") from error - details = native_runtime_identity(help_and_identity, identity) - commands = native_cli_outcomes(contract, help_output, version, initialized, scaffold) - observations.append({"environment_id": identity, "image": image, "executable_sha256": before, - "stdout_sha256": sha256_bytes(output), "commands": commands, **details}) - if sha256_bytes(prepare._regular_bytes(executable, 536870912)) != before or sha256_bytes(prepare._regular_bytes(final, 536870912)) != before: - raise ValueError("native final bytes changed during clean startup") - if any(not record["stdout_sha256"] or record["stdout_sha256"] == sha256_bytes(b"") for record in observations): - raise ValueError("native startup requires actual help output") - return {"sha256_before": before, "sha256_after": before, "outside_checkout": True, "runtime": observations} - - - -DOWNLOAD_BUILD_SCRIPT = r""" -use std::io::{Read as _, Write as _}; -fn main() { - let address = std::env::var("DOWNLOAD_PROBE_ADDRESS").expect("should receive fixture address"); - let marker = std::env::var("DOWNLOAD_PROBE_MARKER").expect("should receive fixture marker"); - let jobs = std::env::var("NUM_JOBS").expect("should receive actual Cargo concurrency").parse::().expect("should parse actual Cargo concurrency"); - let result = std::net::TcpStream::connect(address).and_then(|mut stream| { - stream.write_all(b"GET /fixture HTTP/1.0\r\nHost: example.com\r\n\r\n")?; - let mut response = Vec::new(); - stream.read_to_end(&mut response)?; - if response.ends_with(b"fixture-download-example") { Ok(()) } - else { Err(std::io::Error::other("fixture response mismatch")) } - }); - match result { - Ok(()) => { - let observation = format!("{{\"outcome\":\"allowed\",\"errno\":0,\"jobs\":{jobs}}}"); - std::fs::write(marker, observation).expect("should record fixture success"); - }, - Err(error) => { - let code = error.raw_os_error().unwrap_or(0); - let outcome = if code == 1 || code == 13 { "denied" } else { "failed" }; - let observation = format!("{{\"outcome\":\"{outcome}\",\"errno\":{code},\"jobs\":{jobs}}}"); - std::fs::write(marker, observation).expect("should record actual build-script error"); - panic!("fixture download must fail without network access"); - } - } -} -""" -DOWNLOAD_DRIVER = r""" -import os, subprocess, sys -from pathlib import Path -cargo, fixture, target, marker, phase = sys.argv[1:] -server = None -if phase == 'unconfined': - import http.server, threading - class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.end_headers() - self.wfile.write(b'fixture-download-example') - def log_message(self, *args): - pass - server = http.server.HTTPServer(('127.0.0.1', 0), Handler) - threading.Thread(target=server.serve_forever, daemon=True).start() -address = '127.0.0.1:' + str(server.server_port if server else 9) -environment = dict(os.environ, DOWNLOAD_PROBE_ADDRESS=address, DOWNLOAD_PROBE_MARKER=marker) -try: - result = subprocess.run([cargo, 'build', '--release', '--frozen', '--manifest-path', fixture + '/Cargo.toml', '--target-dir', target], - env=environment, stdin=subprocess.DEVNULL, close_fds=True, timeout=600, check=False) -finally: - if server: - server.shutdown() - server.server_close() -sys.stdout.buffer.write(Path(marker).read_bytes()) -sys.exit(result.returncode) -""" - - -def prepare_download_fixture(workspace): - """Prepare an immutable dependency-free Cargo project with a real HTTP hook.""" - fixture = Path(workspace) / "download-fixture" - fixture.mkdir() - (fixture / "src").mkdir() - (fixture / "Cargo.toml").write_text('[package]\nname = "release-download-fixture"\nversion = "0.1.0"\nedition = "2024"\n[workspace]\n') - (fixture / "Cargo.lock").write_text('version = 4\n[[package]]\nname = "release-download-fixture"\nversion = "0.1.0"\n') - (fixture / "src/main.rs").write_text('fn main() {}\n') - (fixture / "build.rs").write_text(DOWNLOAD_BUILD_SCRIPT) - return fixture - - -def verify_download_fixture(boundary, workspace, tools, environment, *, execution_umask=0o022, log_root=None): - """Require actual build-script HTTP success outside and denial inside boundary. - - Each phase compiles its own fresh target. A compiler/bootstrap failure cannot - count as a download denial because the build-script marker must exist and - contain the actual network syscall's permission errno. - """ - workspace = Path(workspace) - fixture = workspace / "download-fixture" - log_root = Path(log_root) if log_root is not None else workspace - observations = {} - for phase in ("unconfined", "confined"): - target = workspace / ("download-target-" + phase) - marker = workspace / ("download-marker-" + phase + ".json") - if target.exists() or marker.exists(): - raise ValueError("actual Cargo download fixture target/marker must be fresh") - argv = [tools["python"], "-I", "-B", "-c", DOWNLOAD_DRIVER, tools["cargo"], str(fixture), str(target), str(marker), phase] - if phase == "confined": - argv = boundary.command(argv, environment=environment, cwd=fixture) - elif isinstance(boundary, LinuxBoundary): - argv = boundary._container_command(argv, confined=False, environment=environment, cwd=fixture) - failure = None - try: - measured, _ = capture(argv, environment, fixture, log_root / ("download-" + phase), timeout=660, umask=execution_umask, boundary=boundary if isinstance(boundary, LinuxBoundary) else None) - except RecipeProcessError as error: - failure, measured = error, error.stdout - if not measured: - raise ValueError("actual build-script download was not reached; missing syscall evidence") from failure - observed = load_json_bytes(measured, max_bytes=65536) - if not isinstance(observed, dict) or set(observed) != {"outcome", "errno", "jobs"} or type(observed["errno"]) is not int or type(observed["jobs"]) is not int: - raise ValueError("actual build-script download evidence invalid") - if observed["jobs"] != int(environment["CARGO_BUILD_JOBS"]): - raise ValueError("actual Cargo build-script concurrency differs from selected jobs") - if phase == "unconfined" and (failure is not None or observed["outcome"] != "allowed" or observed["errno"] != 0): - raise ValueError("actual unconfined build-script HTTP control must succeed") from failure - if phase == "confined" and (failure is None or observed["outcome"] != "denied" or observed["errno"] not in {1, 13}): - raise ValueError("actual confined build-script HTTP attempt must fail with permission denial") - observations[phase] = observed - return {"actual_build_script": True, "boundary_sha256": boundary.sha256, **observations} - - - -def container_permissions(workspace, writable, immutable): - """Permit fixed container UID access while outer host root remains private. - - These modes only make prepared bytes readable and generated state writable. - Read-only OCI overlays enforce immutability; owner chmod is never its proof. - Container-produced B files remain private to its UID and are read through an - authenticated reader in that same container context, not by host DAC bypass. - """ - workspace = Path(workspace).resolve(strict=True) - if workspace.parent.stat().st_mode & 0o077: - raise ValueError("outer host material root must remain private") - workspace.chmod(0o777) - for root in writable: - root = Path(root) - if not root.resolve(strict=True).is_relative_to(workspace) or root.is_symlink(): - raise ValueError("writable generated state must be inside private workspace") - for path in (root, *root.rglob("*")): - if path.is_symlink(): - raise ValueError("writable generated state must not contain symlinks") - path.chmod(0o777 if path.is_dir() or path.stat().st_mode & 0o111 else 0o666) - for root in immutable: - root = Path(root) - if not root.resolve(strict=True).is_relative_to(workspace) or root.is_symlink(): - raise ValueError("immutable prepared input must be inside private workspace") - members = (root, *root.rglob("*")) if root.is_dir() else (root,) - for path in members: - if path.is_symlink(): - # The only source link was already checked by tracked export; - # preserve it and never chmod through its target. - continue - path.chmod(0o755 if path.is_dir() or path.stat().st_mode & 0o111 else 0o644) - - - -def verified_shared_js_input(request): - """Select an independently verified current-run shared-JS producer binding. - - Inventory hashes/source labels prove consistency only. This private boundary - stays closed until the authenticated Task 7 producer integration or a current - run transfer-receipt verifier supplies its binding. There is no public JSON - authenticated flag, receipt-path option or candidate bypass. Implement the - authenticating integration before measuring the complete Task 8 closure. - """ - preflight._commit_id(request.source_commit) - raise ValueError("independent shared-JS producer binding pending Task 8/transfer integration") - - -def _build_recipe(request, selection): - """Execute preparation, same-boundary controls/compile and final-byte gates. - - The only official/private selectors authenticate source-owned acquisition - bindings before this entry. No JSON commands, fixture bypass, transforms, - publication authority or network-enabled compile path exist here. - """ - from qualify import validate_socket_pair - artifact = artifact_selection(request.asset) - role = ambient_profile(request.role) - if request.source_commit != selection["source_commit"]: - raise ValueError("independently approved source selection mismatch") - # Reauthenticate every source-owned acquisition at the entry; no Boolean - # authenticated flag in an observation/descriptor can substitute for this. - authentic = candidate_selection(request.asset, request.source_commit) - if selection != authentic: - raise ValueError("candidate acquisition selection mismatch") - shared_js_binding = verified_shared_js_input(request) - for path in (request.materials, request.output): - if os.path.lexists(path): - raise ValueError("recipe materials/output must be fresh") - if request.materials.is_relative_to(Path(selection["source"]).resolve()) or request.output.is_relative_to(request.materials) or request.materials.is_relative_to(request.output): - raise ValueError("external disjoint recipe roots required") - variation = selection["variation"] - if (not isinstance(variation, dict) or set(variation) != {"locale", "timezone", "user_variation"} - or variation["locale"] in {"C", "qualification-selected-alternate"} - or variation["timezone"] in {"UTC", "qualification-selected-alternate"}): - raise ValueError("reviewed candidate alternate ambient selections required; support remains unqualified") - if variation["user_variation"] is not False: - raise ValueError("execution user variation unsupported by current fixed boundary") - work = fresh_root(request.materials) - workspace = work / role["checkout_root"] - workspace.mkdir(mode=0o700) - source = workspace / "source" - snapshot = prepare.materialize_source(selection["source"], request.source_commit, source) - for name in ("home", "tmp", "cargo-home", "target", "js", "accepted-final"): - (workspace / name).mkdir(mode=0o700) - target, vendor = workspace / "target", workspace / "vendor" - # Preparation operates only on independently authenticated original cached - # materials. Cargo performs its own workspace-inheritance normalization. - lock_path = source / "Cargo.lock" - lock_bytes = prepare._regular_bytes(lock_path, 1048576) - pool = Path(selection["archive_pool"]) - index = read_json_file(pool / "cargo-materials.json", max_bytes=1048576) - originals = verify_cargo_materials(lock_bytes, index, pool) - seed = Path(selection["cargo_seed"]) - for name in ("config", "config.toml"): - if os.path.lexists(seed / name): - raise ValueError("Cargo preparation seed configuration forbidden") - prepare_cargo_home(seed, workspace / "cargo-home", originals, index, pool) - js_inventory = prepare._regular_bytes(request.js_inventory, 1048576) - inventory = load_json_bytes(js_inventory, max_bytes=1048576) - if inventory.get("source_commit") != request.source_commit: - raise ValueError("prepared JS inventory source identity mismatch") - expected_js = sorted("tsjs-" + name + ".js" for name in prepare._expected_modules(source, request.source_commit)) - if sorted(path.name for path in request.js_dir.iterdir()) != expected_js: - raise ValueError("complete prepared JS pair required") - modules = inventory.get("modules", []) - if sorted(record.get("filename", "") for record in modules) != expected_js: - raise ValueError("prepared JS inventory module membership mismatch") - for record in modules: - data = prepare._regular_bytes(request.js_dir / record["filename"], prepare.MAX_FILE) - if record.get("size") != len(data) or record.get("sha256") != sha256_bytes(data): - raise ValueError("prepared JS inventory bytes mismatch") - (workspace / "js" / record["filename"]).write_bytes(data) - private_inventory = workspace / "js-inventory.json" - private_inventory.write_bytes(js_inventory) - environment, tools = _compile_environment(selection, workspace, artifact, request, snapshot, role["jobs"]) - capture([tools["cargo"], "vendor", "--offline", "--locked", "--versioned-dirs", str(vendor)], environment, source, workspace / "preparation", umask=int(role["umask"], 8)) - prepare.source_check(source, request.source_commit, snapshot) - if prepare._regular_bytes(lock_path, 1048576) != lock_bytes: - raise ValueError("Cargo vendor mutated locked source") - for original in originals.values(): - prepare._authenticated_bytes(original.path, original.integrity, "sha256", prepare.MAX_ARCHIVE) - configuration = workspace / "cargo-release.toml" - configuration.touch() - download_fixture = prepare_download_fixture(workspace) - evidence_logs = workspace / "accepted-final" / "logs" - evidence_logs.mkdir(mode=0o755) - immutable = [source, vendor, workspace / "js", private_inventory, configuration, download_fixture, workspace / "accepted-final"] - readonly = [Path(path) for path in selection["readonly"]] - if not artifact["target"].endswith("apple-darwin"): - container_permissions(workspace, [workspace / name for name in ("home", "tmp", "cargo-home", "target")], immutable) - vendor_digest = tree_digest(vendor) - boundary = (MacBoundary(workspace, readonly, immutable=immutable) if artifact["target"].endswith("apple-darwin") - else LinuxBoundary(workspace, readonly, selection["image"], immutable=immutable, python=tools["python"], execution_umask=int(role["umask"], 8))) - write_vendor_configuration(lock_bytes, vendor, configuration, boundary) - controls = probe_controls(boundary, workspace, tools["python"]) - validate_socket_pair(controls["unconfined"], controls["confined"]) - (workspace / "boundary-controls.json").write_bytes(deterministic_json_bytes(controls)) - download = verify_download_fixture(boundary, workspace, tools, environment, execution_umask=int(role["umask"], 8), log_root=evidence_logs) - (workspace / "download-controls.json").write_bytes(deterministic_json_bytes(download)) - # Actual ambient observations run inside the exact compile boundary before - # compiler locale/timezone/epoch are normalized. Selected jobs and umask - # remain effective compiler inputs, and are independently observed here. - ambient = dict(environment) - ambient.update({"LANG": "C" if request.role == "A" else variation["locale"], "LC_ALL": "C" if request.role == "A" else variation["locale"], "TZ": "UTC" if request.role == "A" else variation["timezone"]}) - probe = [tools["python"], "-I", "-B", "-c", "import json,locale,os,time;locale.setlocale(locale.LC_ALL,'');time.tzset();mask=os.umask(0);os.umask(mask);print(json.dumps(dict(checkout_root=os.getcwd(),umask=format(mask,'03o'),jobs=int(os.environ['CARGO_BUILD_JOBS']),locale=os.environ['LC_ALL'],timezone=os.environ['TZ'],start_time_ns=time.time_ns(),execution_user=str(os.getuid()))))"] - observed, _ = capture(boundary.command(probe, environment=ambient, cwd=source), ambient, source, evidence_logs / "ambient", umask=int(role["umask"], 8), timeout=30, boundary=boundary) - observations = load_json_bytes(observed, max_bytes=65536) - actual_root = str(source.resolve(strict=True)) - if observations["checkout_root"] != (boundary.map_path(source) if isinstance(boundary, LinuxBoundary) else actual_root): - raise ValueError("observed boundary cwd differs from actual external checkout") - observations["checkout_root"] = actual_root - observations.update({"role": request.role, "user_variation": "unsupported", "normalized": {"locale": "C", "timezone": "UTC", "epoch": snapshot["source_date_epoch"], "source_root": "/source"}}) - if observations["umask"] != role["umask"] or observations["jobs"] != role["jobs"]: - raise ValueError("actual compiler jobs/umask differ from selected role") - argv = [*cargo_command(tools["cargo"], artifact, target, configuration), "--jobs", str(role["jobs"])] - compile_stdout, _ = capture(boundary.command(argv, environment=environment, cwd=source), environment, source, evidence_logs / "cargo", umask=int(role["umask"], 8), boundary=boundary) - prepare.source_check(source, request.source_commit, snapshot) - check_tree(vendor, vendor_digest) - if prepare._regular_bytes(private_inventory, 1048576) != js_inventory: - raise ValueError("prepared JS inventory mutated during compile") - # Container Cargo emits its mapped target path. Convert only the exact - # source-owned mount prefix, then still apply expected final path checks. - if isinstance(boundary, LinuxBoundary): - converted = [] - for line in compile_stdout.splitlines(): - event = load_json_bytes(line, max_bytes=1048576) - if isinstance(event.get("executable"), str) and event["executable"].startswith("/work/"): - event["executable"] = str(workspace) + event["executable"][len("/work"):] - converted.append(deterministic_json_bytes(event).rstrip(b"\n")) - selected_stdout = b"\n".join(converted) - else: - selected_stdout = compile_stdout - captured_final = {} - def verified_reader(path): - code = "import sys;from pathlib import Path;sys.path.insert(0,sys.argv[1]);from prepare import _regular_bytes;p=Path(sys.argv[2]);\nif p.resolve(strict=True)!=p: raise ValueError('final executable path alias');\nsys.stdout.buffer.write(_regular_bytes(p,536870912))" - command = [tools["python"], "-I", "-B", "-c", code, str(source / "scripts/release"), str(path)] - data, _ = capture(boundary.command(command, environment=environment, cwd=source), environment, - source, evidence_logs / "final-read", timeout=60, maximum=536870912, boundary=boundary) - captured_final["bytes"] = data - return data - cargo_final = final_cargo_artifact(selected_stdout, artifact, target, - source_root=Path(boundary.map_path(source)) if isinstance(boundary, LinuxBoundary) else source, - reader=verified_reader if isinstance(boundary, LinuxBoundary) else None) - final = snapshot_final(cargo_final, workspace / "accepted-final", request.asset, data=captured_final.get("bytes")) - final_before = sha256_bytes(prepare._regular_bytes(final, 536870912)) - if artifact["kind"] == "wasm-core": - runtime = workspace / "accepted-final" / "fastly-runtime" - runtime.mkdir() - inspection = _load_gate("validate-fastly").validate_final(final, source, runtime, tools, environment, workspace / "js") - else: - inspection = _load_gate("inspect-native").inspect(final, artifact["target"], forbidden_paths=(str(workspace), str(pool), str(seed), selection["source"])) - inspection["startup"] = _native_startup(final, artifact, selection, workspace, environment) - if sha256_bytes(prepare._regular_bytes(final, 536870912)) != final_before: - raise ValueError("final executable changed after inspection/runtime evidence") - if candidate_selection(request.asset, request.source_commit) != selection: - raise ValueError("authenticated source/tool/material selection changed during recipe") - evidence = {"asset": artifact, "source_commit": request.source_commit, "ambient": observations, - "effective_environment": environment, "cargo_argv": argv, "boundary_sha256": boundary.sha256, - "controls_sha256": sha256_bytes(deterministic_json_bytes(controls)), "vendor_sha256": vendor_digest, - "cargo_executable": str(cargo_final), - "cargo_stdout_sha256": sha256_bytes(compile_stdout), "js_inventory_sha256": sha256_bytes(js_inventory), - "shared_js_binding": shared_js_binding, "download_attempt": download, "inspection": inspection, "qualification": "candidate-only"} - output = fresh_root(request.output) - final_bytes = prepare._regular_bytes(final, 536870912) - if sha256_bytes(final_bytes) != final_before: - raise ValueError("final executable changed before output copy") - (output / request.asset).write_bytes(final_bytes) - (output / request.asset).chmod(0o755 if artifact["kind"] == "native-executable" else 0o644) - (output / "recipe-evidence.json").write_bytes(deterministic_json_bytes(evidence)) - return evidence - - -def qualified_selection(artifact): - """Refuse official execution before reviewed measured qualification exists.""" - artifact_selection(artifact["name"]) - tools = read_json_file(RELEASE / "release-tools.json", max_bytes=1048576) - preflight.validate_tools(tools, require_qualified=True) - raise ValueError("release qualification pending: authenticated executable/bootstrap selection required") - - -def main(): - request = parse_request() - preflight.audit_environment(os.environ) - selection = qualified_selection(artifact_selection(request.asset)) - _build_recipe(request, selection) - - -if __name__ == "__main__": - try: - main() - except (ValueError, OSError, subprocess.SubprocessError) as error: - logging.error("Canonical release build refuses: %s", error) - sys.exit(1) diff --git a/scripts/release/tests/fixtures/README.md b/scripts/release/tests/fixtures/README.md index 63d1fb1e41..d6908e9ce4 100644 --- a/scripts/release/tests/fixtures/README.md +++ b/scripts/release/tests/fixtures/README.md @@ -1,63 +1,63 @@ # Release document fixtures -The utility tests construct small byte fixtures in `test_common.py` so duplicate -keys, invalid UTF-8, surrogate escapes, and trailing control bytes retain their -exact representations. These cases cover strict parsing, bounded reads, nesting, -flat asset names, lengths, identity tuples, original-byte hashes, deterministic -producer output, and atomic replacement failures. Future document fixtures belong -in this directory and must use fictional names and `example.com` URLs only. - -Run the standard-library utility tests from the repository root: - -```sh -python3 -m unittest discover -s scripts/release/tests -p 'test_common.py' -v -``` - -Schema validation requires Python 3.11 or newer and an isolated environment. Use -the environment's interpreter explicitly; do not rely on an ambient `jsonschema` -installation. - -This also qualifies the validator against the Draft 2020-12 `prefixItems` and -`items` semantics, which distinguish this dialect from older drafts. The temporary -root is private, and the cleanup trap removes the test environment when the shell -exits. +The host-only `crates/trusted-server-release` implements current strict document/schema utilities. Introduced Python utilities and requirements are removed; their behavior/results do not establish replacement Rust execution. The [migration addendum](../../../../docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md) and [primary plan](../../../../docs/superpowers/plans/2026-10-13-attested-release-implementation.md#rust-migration-and-current-execution-boundary) describe remaining source/bootstrap/qualification gates. + +Private unit cases live alongside `src/documents.rs` or in registered +`src/documents/tests.rs`. Shared byte fixtures live under +`crates/trusted-server-release/tests/fixtures/`, and `tests/commands.rs` exercises +public process behavior. Do not expose internal APIs for integration tests. +Exact byte cases must retain duplicate keys, invalid UTF-8/surrogates/BOM, trailing +controls, nonfinite/unsafe numbers, depth 65 and oversized role inputs. Test lexical +integer overflow in opaque nested fields and top-level values, decimal/exponent +float overflow, and exact `i64` negative/`u64` nonnegative boundary controls. Large +number-like text inside JSON strings, including escaped quotes/backslashes, must +remain accepted text. Role fixtures additionally reject unsafe integers and +negative/noninteger lengths. Fixtures +also cover flat names, integer lengths, ambiguous identities, original-byte hashes, +deterministic output and atomic replacement failures. Use fictional names and +`example.com` URLs only. + +Run the registered local utility tests: ```sh set -eu -RELEASE_TEST_ROOT=$(mktemp -d) -trap 'rm -rf "$RELEASE_TEST_ROOT"' EXIT -python3 -m venv "$RELEASE_TEST_ROOT/venv" -"$RELEASE_TEST_ROOT/venv/bin/python" -m pip install --require-hashes --only-binary=:all: -r scripts/release/requirements.txt -"$RELEASE_TEST_ROOT/venv/bin/python" -m pip check -"$RELEASE_TEST_ROOT/venv/bin/python" -m unittest discover -s scripts/release/tests -p 'test_common.py' -v -"$RELEASE_TEST_ROOT/venv/bin/python" - <<'PY' -from jsonschema import Draft202012Validator -from jsonschema.exceptions import ValidationError - -schema = {"type": "array", "prefixItems": [{"type": "integer"}], "items": False} -Draft202012Validator.check_schema(schema) -validator = Draft202012Validator(schema) -validator.validate([1]) -for invalid in (["example"], [1, 2]): - try: - validator.validate(invalid) - except ValidationError: - continue - raise AssertionError("should reject invalid Draft 2020-12 arrays") -print("Draft 2020-12 qualification passed") -PY +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +test -n "$HOST_TARGET" +RELEASE_TEST_LIST=$(mktemp) +trap 'rm -f "$RELEASE_TEST_LIST"' EXIT +cargo test -p trusted-server-release --target "$HOST_TARGET" documents::tests -- --list > "$RELEASE_TEST_LIST" +awk '/: test$/ { count++ } END { if (count == 0) { print "Documents filter selected zero tests"; exit 1 } print "Selected " count " document tests" }' "$RELEASE_TEST_LIST" +cargo test -p trusted-server-release --target "$HOST_TARGET" documents::tests +cargo test -p trusted-server-release --target "$HOST_TARGET" --test commands +cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings +cargo fmt --all -- --check ``` -`requirements.txt` pins `jsonschema` and every transitive dependency, with SHA-256 -hashes for the published distributions. It includes `typing-extensions`, which -`referencing` requires below Python 3.13. Future schemas use the explicit -`jsonschema.Draft202012Validator`; installing this dependency does not replace the -strict byte parser or authorize schema-based reserialization before hashing. - -The parsing and reading helpers require callers to choose `max_bytes` for the -document role. Their default maximum depth is 64 containers, including the root; -brackets inside strings do not increase depth. Hashing always uses the supplied -original bytes. Producer JSON uses sorted keys, compact separators, UTF-8, and one -final newline. Atomic writes replace a destination using a private sibling file -with mode `0600`, syncing its bytes first and cleaning up the temporary file on -failure. +These source-built developer commands provide local behavior evidence. Production +uses the independently approved-source, authenticated native bootstrap and reviewed +qualification; no official release-binary proof follows from this local build. + +The implemented Task 2 utility uses a recursive Serde visitor/seed to reject duplicate keys before +inserting values. Its finite-float check is not a complete strict front end: Serde +can convert lexical integer overflow to a finite float. Before `deserialize_any`, +scan the whole document's numeric tokens while correctly skipping quoted text and +escapes. Negative integers must fit `i64`, nonnegative integers must fit `u64`, and +decimal/exponent tokens must parse as finite `f64`. The existing scanner in +`crates/trusted-server-js/build/inventory.rs` is a behavioral reference, not a new +crate dependency. Apply stricter role safe-integer/length checks afterward. +It chooses `max_bytes` for each role before reading/allocation; +container depth is at most 64 including the root, and brackets within strings do +not increase depth. Hash original bytes before any semantic decoding. Producer +JSON sorts keys, uses compact UTF-8 and one final newline. Atomic writes use a +private sibling with mode `0600`, sync before replacement and clean failures. + +The selected Rust evaluator is locally checked with explicit offline Draft 2020-12 +`prefixItems`/`items` positives/negatives for governing schemas and Draft 7 for the +authoritative SPDX 2.3 schema. Retain the 45,312-byte official schema at commit +`aadf3b0b8dbbabdb4d880b0fc714255fea436ff7`, SHA-256 +`239208b7ac287b3cf5d9a9af23f9d69863971102a5e1587a27a398b43490b89b`. +Use only known retained schemas, reject remote/file retrieval and unknown refs, +and permit no arbitrary public schema-file override. The evaluator dependency is locked; native acquisition and production qualification +remain pending. Schema validation does not +replace strict byte parsing, original-byte hashing or full real-artifact SPDX +coverage/semantic checks. diff --git a/scripts/release/tests/test_common.py b/scripts/release/tests/test_common.py deleted file mode 100644 index f7bc8cfc45..0000000000 --- a/scripts/release/tests/test_common.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Tests for strict release document utilities.""" - -import hashlib -import io -import os -import stat -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -import common - - -class JsonBytesTests(unittest.TestCase): - def test_rejects_duplicate_keys(self): - with self.assertRaisesRegex(ValueError, "duplicate"): - common.load_json_bytes(b'{"name":1,"name":2}', max_bytes=1024) - - def test_rejects_nonfinite_numbers(self): - for number in (b"NaN", b"Infinity", b"-Infinity", b"1e999", b"-1e999"): - with self.subTest(number=number): - with self.assertRaisesRegex(ValueError, "finite"): - common.load_json_bytes(b'{"value":' + number + b"}", max_bytes=1024) - - def test_rejects_invalid_utf8_and_unpaired_surrogates(self): - for data in ( - b'"\xff"', - b'"\xed\xa0\x80"', - b'"\\ud800"', - b'"\\udfff"', - b'{"\\ud800":0}', - ): - with self.subTest(data=data): - with self.assertRaises(ValueError): - common.load_json_bytes(data, max_bytes=1024) - - def test_rejects_trailing_controls_and_accepts_json_whitespace(self): - for control in (b"\x00", b"\x0b", b"\x0c", b"\x1f"): - with self.subTest(control=control): - with self.assertRaises(ValueError): - common.load_json_bytes(b"{}" + control, max_bytes=1024) - self.assertEqual( - common.load_json_bytes(b" \t\r\n{} \t\r\n", max_bytes=1024), - {}, - "should accept only the JSON whitespace characters", - ) - - def test_accepts_valid_unicode_and_finite_numbers(self): - self.assertEqual( - common.load_json_bytes( - b'{"value":1.25,"text":"\\ud83d\\ude00"}', max_bytes=1024 - ), - {"value": 1.25, "text": "😀"}, - "should preserve finite numbers and paired surrogate escapes", - ) - - def test_rejects_container_depth_65_and_accepts_64(self): - with self.assertRaisesRegex(ValueError, "depth"): - common.load_json_bytes(b"[" * 65 + b"0" + b"]" * 65, max_bytes=1024) - document = common.load_json_bytes( - b"[" * 64 + b"0" + b"]" * 64, max_bytes=1024 - ) - for _ in range(64): - self.assertIsInstance(document, list, "should accept every allowed level") - document = document[0] - self.assertEqual(document, 0, "should preserve the nested scalar") - - def test_depth_check_ignores_brackets_and_escaped_quotes_in_strings(self): - data = b'{"text":"[\\\"{]"}' - self.assertEqual( - common.load_json_bytes(data, max_bytes=1024, max_depth=1), - {"text": '["{]'}, - "should count containers rather than bracket characters in strings", - ) - - def test_rejects_oversized_bytes_before_decoding(self): - with self.assertRaisesRegex(ValueError, "size"): - common.load_json_bytes(b"\xff" * 5, max_bytes=4) - self.assertEqual( - common.load_json_bytes(b"{}", max_bytes=2), - {}, - "should accept exactly the configured byte limit", - ) - - def test_requires_bytes_and_nonnegative_integer_limits(self): - with self.assertRaisesRegex(ValueError, "bytes"): - common.load_json_bytes("{}", max_bytes=1024) - for invalid in (-1, True, False, 1.5, "4"): - with self.subTest(invalid=invalid): - with self.assertRaisesRegex(ValueError, "integer"): - common.load_json_bytes(b"{}", max_bytes=invalid) - with self.assertRaisesRegex(ValueError, "integer"): - common.load_json_bytes(b"{}", max_bytes=1024, max_depth=invalid) - - -class IntegerTests(unittest.TestCase): - def test_rejects_negative_lengths_and_booleans(self): - for value in (-1, True, False, 1.0, "1", None): - with self.subTest(value=value): - with self.assertRaisesRegex(ValueError, "integer"): - common.require_nonnegative_integer(value) - for value in (0, 1, 512 * 1024 * 1024): - self.assertEqual( - common.require_nonnegative_integer(value), - value, - "should preserve a valid nonnegative integer", - ) - - -class BoundedReadTests(unittest.TestCase): - def test_rejects_oversized_stream_without_unbounded_read(self): - stream = io.BytesIO(b"x" * 10) - with self.assertRaisesRegex(ValueError, "size"): - common.read_bounded(stream, max_bytes=4) - self.assertEqual(stream.tell(), 5, "should read only the limit and one byte") - - def test_accepts_limit_and_short_stream_reads(self): - class ShortReads(io.BytesIO): - def read(self, size=-1): - return super().read(min(size, 2)) - - for stream in (io.BytesIO(b"1234"), ShortReads(b"1234")): - with self.subTest(stream=type(stream).__name__): - self.assertEqual( - common.read_bounded(stream, max_bytes=4), - b"1234", - "should read through short reads up to EOF", - ) - self.assertEqual( - common.read_bounded(io.BytesIO(), max_bytes=0), - b"", - "should accept empty data at a zero byte limit", - ) - - def test_rejects_invalid_stream_limits_and_text_streams(self): - for limit in (-1, True): - with self.subTest(limit=limit): - with self.assertRaisesRegex(ValueError, "integer"): - common.read_bounded(io.BytesIO(b"{}"), max_bytes=limit) - with self.assertRaisesRegex(ValueError, "binary"): - common.read_bounded(io.StringIO("{}"), max_bytes=4) - - def test_read_json_file_checks_actual_bytes(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "index.json" - path.write_bytes(b'{"name":"example"}') - with self.assertRaisesRegex(ValueError, "size"): - common.read_json_file(path, max_bytes=4) - self.assertEqual( - common.read_json_file(path, max_bytes=1024), - {"name": "example"}, - "should parse a document read from bounded binary data", - ) - - -class DigestTests(unittest.TestCase): - def test_sha256_hashes_original_bytes(self): - compact = b'{"name":"example"}' - spaced = b'{ "name" : "example" }\n' - self.assertEqual( - common.load_json_bytes(compact, max_bytes=1024), - common.load_json_bytes(spaced, max_bytes=1024), - "should parse both byte representations to the same document", - ) - for data in (compact, spaced): - self.assertEqual( - common.sha256_bytes(data), - hashlib.sha256(data).hexdigest(), - "should match the digest of the exact original bytes", - ) - self.assertNotEqual( - common.sha256_bytes(compact), - common.sha256_bytes(spaced), - "should distinguish different serialization bytes", - ) - - def test_sha256_rejects_text(self): - with self.assertRaisesRegex(ValueError, "bytes"): - common.sha256_bytes("{}") - - -class AssetNameTests(unittest.TestCase): - def test_rejects_unsafe_flat_asset_names(self): - for name in ( - "", ".", "..", "../index.json", "a/b", "a\\b", "/index.json", - "C:index.json", "a\x00b", "a\nb", "a\x7fb", ".hidden", - "-option", "a b", "é.json", "a%2fb", None, 12, - ): - with self.subTest(name=name): - with self.assertRaisesRegex(ValueError, "asset name"): - common.require_safe_asset_name(name) - for name in ("index.json", "trusted-server-linux-x86_64.tar.gz", "a_b-1.wasm"): - self.assertEqual( - common.require_safe_asset_name(name), name, - "should preserve an unambiguous flat ASCII asset name", - ) - - def test_rejects_names_outside_release_asset_contract(self): - for name in ("name+extra", "a..b", "a.", "a" * 161): - with self.subTest(name=name): - with self.assertRaisesRegex(ValueError, "asset name"): - common.require_safe_asset_name(name) - self.assertEqual( - common.require_safe_asset_name("a" * 160), "a" * 160, - "should accept the maximum contract name length", - ) - - -class IdentityTests(unittest.TestCase): - def test_rejects_ambiguous_duplicate_identities(self): - records = [ - {"kind": "asset", "name": "index.json", "sha256": "a" * 64}, - {"kind": "asset", "name": "index.json", "sha256": "b" * 64}, - ] - with self.assertRaisesRegex(ValueError, "duplicate identity"): - common.require_unique_identities(records, ("kind", "name")) - - def test_accepts_distinct_composite_identities(self): - common.require_unique_identities( - [{"kind": "asset", "name": "index.json"}, - {"kind": "statement", "name": "index.json"}], - ("kind", "name"), - ) - - def test_rejects_missing_and_nonscalar_identity_fields(self): - for record in ({}, {"name": []}, {"name": {}}, {"name": True}, {"name": None}): - with self.subTest(record=record): - with self.assertRaisesRegex(ValueError, "identity"): - common.require_unique_identities([record], ("name",)) - - def test_generator_fields_accept_distinct_identities(self): - result = common.require_unique_identities( - [{"name": "index.json"}, {"name": "statement.json"}], - iter(("name",)), - ) - self.assertIsNone( - result, "should accept distinct identities with iterator fields" - ) - - def test_generator_fields_reject_duplicate_identities(self): - with self.assertRaisesRegex(ValueError, "duplicate identity"): - common.require_unique_identities( - [{"name": "index.json"}, {"name": "index.json"}], - iter(("name",)), - ) - - -class SerializationTests(unittest.TestCase): - def test_deterministic_json_bytes_sorts_keys_and_uses_utf8(self): - expected = '{"a":"é","z":{"a":1,"b":2}}\n'.encode("utf-8") - for document in ( - {"z": {"b": 2, "a": 1}, "a": "é"}, - {"a": "é", "z": {"a": 1, "b": 2}}, - ): - self.assertEqual( - common.deterministic_json_bytes(document), expected, - "should write compact sorted UTF-8 JSON with a final newline", - ) - - def test_serialization_rejects_nonfinite_and_unpaired_surrogates(self): - for value in (float("nan"), float("inf"), float("-inf"), "\ud800"): - with self.subTest(value=repr(value)): - with self.assertRaises(ValueError): - common.deterministic_json_bytes({"value": value}) - - def test_serialization_rejects_nonstring_object_keys(self): - with self.assertRaisesRegex(ValueError, "keys"): - common.deterministic_json_bytes({1: "example"}) - - def test_serialization_reports_mixed_keys_as_value_errors(self): - for document in ( - {"name": "example", 1: "example"}, - {"items": [{"name": "example", 1: "example"}]}, - ): - with self.subTest(document=document): - with self.assertRaisesRegex(ValueError, "JSON serialization"): - common.deterministic_json_bytes(document) - - def test_serialization_reports_unsupported_values_as_value_errors(self): - with self.assertRaisesRegex(ValueError, "JSON serialization"): - common.deterministic_json_bytes({"items": {"example"}}) - - def test_serialization_rejects_nonstring_keys_inside_tuple_containers(self): - for key in (1, True): - for document in (({key: "example"},), {"items": (({key: "example"},),)}): - with self.subTest(key=key, document=document): - with self.assertRaisesRegex(ValueError, "keys"): - common.deterministic_json_bytes(document) - - def test_serialization_accepts_valid_tuple_containers(self): - self.assertEqual( - common.deterministic_json_bytes(({"name": "example"},)), - b'[{"name":"example"}]\n', - "should serialize valid tuples using JSON array syntax", - ) - - def test_serialization_rejects_cycles_promptly(self): - source = """ -import sys -sys.path.insert(0, sys.argv[1]) -import common -document = [] -document.append(document) -try: - common.deterministic_json_bytes(document) -except ValueError: - sys.exit(0) -raise AssertionError('should reject a cyclic producer document') -""" - try: - result = subprocess.run( - [sys.executable, "-B", "-c", source, str(Path(common.__file__).parent)], - capture_output=True, text=True, timeout=2, check=False, - ) - except subprocess.TimeoutExpired: - self.fail("should reject a cyclic producer document promptly") - self.assertEqual( - result.returncode, 0, f"should reject cyclic documents: {result.stderr}" - ) - - -class AtomicWriteTests(unittest.TestCase): - def test_atomic_write_replaces_existing_bytes_privately(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "index.json" - path.write_bytes(b"old") - common.atomic_write(path, b"new") - self.assertEqual(path.read_bytes(), b"new", "should replace the old bytes") - self.assertEqual( - stat.S_IMODE(path.stat().st_mode), 0o600, - "should publish the private temporary file's permissions", - ) - self.assertEqual( - list(Path(directory).iterdir()), [path], - "should leave no temporary files after success", - ) - - def test_atomic_write_failure_preserves_old_file_and_removes_temporary(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "index.json" - path.write_bytes(b"old") - with mock.patch.object( - os, "replace", side_effect=OSError("replace failed") - ): - with self.assertRaisesRegex(OSError, "replace failed"): - common.atomic_write(path, b"new") - self.assertEqual( - path.read_bytes(), b"old", "should preserve existing bytes" - ) - self.assertEqual( - list(Path(directory).iterdir()), [path], - "should remove the unpublished temporary file", - ) - - def test_atomic_write_rejects_text_before_creating_a_file(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "index.json" - with self.assertRaisesRegex(ValueError, "bytes"): - common.atomic_write(path, "text") - self.assertEqual( - list(Path(directory).iterdir()), [], - "should leave the directory empty for invalid input", - ) - - def test_atomic_write_sync_failure_cleans_up_before_replace(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "index.json" - path.write_bytes(b"old") - with mock.patch.object(os, "fsync", side_effect=OSError("sync failed")): - with self.assertRaisesRegex(OSError, "sync failed"): - common.atomic_write(path, b"new") - self.assertEqual( - path.read_bytes(), b"old", "should preserve existing bytes" - ) - self.assertEqual( - list(Path(directory).iterdir()), [path], - "should remove a temporary file after write or sync failure", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/release/tests/test_fastly_runtime.py b/scripts/release/tests/test_fastly_runtime.py deleted file mode 100644 index 9ede78ba73..0000000000 --- a/scripts/release/tests/test_fastly_runtime.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Fastly final-byte acceptance negatives and response fixture contract.""" -import importlib.util -import sys -import tempfile -import unittest -from pathlib import Path - -RELEASE = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(RELEASE)) -SPEC = importlib.util.spec_from_file_location("validate_fastly", RELEASE / "validate-fastly.py") -fastly = None -if SPEC and SPEC.loader and Path(SPEC.origin).exists(): - fastly = importlib.util.module_from_spec(SPEC) - SPEC.loader.exec_module(fastly) - - -class FastlyTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(fastly, "canonical Fastly validator is missing") - - def test_non_wasm_and_component_rejected(self): - for data in (b"hello", b"\0asm\x0d\0\x01\0", b"\0asm\x01\0\0\0"): - with self.subTest(data=data), self.assertRaisesRegex(ValueError, "Wasm|ABI"): - fastly.core_wasm(data) - - def test_health_only_is_not_positive_publisher_fixture(self): - with self.assertRaisesRegex(ValueError, "publisher"): - fastly.publisher_response(200, b"healthy", {}) - - def test_injection_and_origin_marker_required(self): - with self.assertRaisesRegex(ValueError, "injection"): - fastly.publisher_response(200, b"release-origin-example", {"content-type": "text/html"}) - - def test_wrong_abi_rejected(self): - # A valid empty module has no Fastly imports or WASI request entry. - with self.assertRaisesRegex(ValueError, "ABI"): - fastly.core_wasm(b"\0asm\x01\0\0\0") - -class RuntimeConfigTests(unittest.TestCase): - def test_source_owned_runtime_config_matches_manifest_store(self): - self.assertIsNotNone(fastly, "canonical Fastly validator is missing") - with tempfile.TemporaryDirectory() as directory: - import tomllib - destination = Path(directory) / "viceroy.toml" - fastly.runtime_config(RELEASE.parents[1], destination, 12345, "A") - generated = tomllib.loads(destination.read_text()) - manifest = tomllib.loads((RELEASE.parents[1] / "edgezero.toml").read_text()) - store = manifest["stores"]["config"]["default"] - self.assertIn(store, generated["local_server"]["config_stores"]) - -class AbiFramingTests(unittest.TestCase): - def test_core_framing_accepts_required_abi_before_maintained_validation(self): - # Framing-only bytes deliberately omit instruction/type semantics; the - # maintained validator must still reject these as a complete program. - imports = bytes([2, 15]) + b"fastly_http_req" + bytes([4]) + b"send" + bytes([0, 0]) - imports += bytes([22]) + b"wasi_snapshot_preview1" + bytes([8]) + b"fd_write" + bytes([0, 0]) - exports = bytes([1, 6]) + b"_start" + bytes([0, 0]) - data = b"\0asm\x01\0\0\0" + bytes([2, len(imports)]) + imports + bytes([7, len(exports)]) + exports - self.assertEqual(fastly.core_wasm(data)["abi"], "wasip1-fastly-default") - - def test_positive_full_publisher_fixture_extracts_script(self): - path = b"/static/tsjs=tsjs-unified.min.js?v=" + b"a" * 64 - injection = b'' - html = fastly.HTML.replace(b"", b"" + injection) - self.assertEqual(fastly.publisher_response(200, html, {"content-type": "text/html"}), path.decode()) - -class CompletePublisherTests(unittest.TestCase): - def test_truncation_of_each_origin_document_region_is_rejected(self): - injection = b'' - complete = fastly.HTML.replace(b"", b"" + injection) - damaged = (injection + b'release-origin-example', - complete.replace(b'', b''), - complete.replace(b'', b''), - complete.replace(b'', b''), - complete.replace(b'Release fixture', b''), - complete.replace(b'', b''), - complete.replace(b'', b''), - complete.replace(b'Release fixture', b'Changed example'), - complete.replace(b'', b'')) - for body in damaged: - with self.subTest(body=body), self.assertRaisesRegex(ValueError, "complete HTML|origin fixture"): - fastly.publisher_response(200, body, {"content-type": "text/html"}) - -class ExactJavascriptTests(unittest.TestCase): - def test_unrelated_javascript_success_is_rejected(self): - expected = b"expected example core;\nexpected example creative" - path = "/static/tsjs=tsjs-unified.min.js?v=" + fastly.sha256_bytes(expected) - with self.assertRaisesRegex(ValueError, "JavaScript.*bytes"): - fastly.javascript_response(200, b"unrelated example", {"content-type": "application/javascript"}, path, expected) - wrong = path[:-1] + ("b" if path[-1] != "b" else "c") - with self.assertRaisesRegex(ValueError, "hash"): - fastly.javascript_response(200, expected, {"content-type": "application/javascript"}, wrong, expected) - self.assertEqual(fastly.javascript_response(200, expected, {"content-type": "application/javascript"}, path, expected), fastly.sha256_bytes(expected)) - - def test_expected_runtime_bundle_uses_exact_prepared_modules_and_separator(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "tsjs-core.js").write_bytes(b"example core\n") - (root / "tsjs-creative.js").write_bytes(b"example creative\n") - self.assertEqual(fastly.expected_javascript(RELEASE.parents[1], root), b"example core\n;\nexample creative\n") - - -class RuntimeLogBoundsTests(unittest.TestCase): - def test_live_runtime_logs_are_bounded_and_process_is_reaped(self): - import subprocess - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - process = subprocess.Popen([sys.executable, "-I", "-c", "import sys,time;sys.stdout.write('x'*100000);sys.stdout.flush();time.sleep(30)"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) - logs = None - try: - logs = fastly.RuntimeLogs(process, root / "viceroy", maximum=1024) - self.assertTrue(logs.failure.wait(3), "live log bound should trigger") - with self.assertRaisesRegex(ValueError, "bound"): - logs.check() - finally: - if logs is not None: - logs.close() - else: - process.kill() - process.wait() - self.assertIsNotNone(process.poll()) - self.assertLessEqual((root / "viceroy.stdout").stat().st_size, 1024) diff --git a/scripts/release/tests/test_js_release.py b/scripts/release/tests/test_js_release.py deleted file mode 100644 index 9d16f8e450..0000000000 --- a/scripts/release/tests/test_js_release.py +++ /dev/null @@ -1,478 +0,0 @@ -"""JS recipe failure tests; synthetic bundles never claim release qualification.""" - -import importlib.util -import base64 -import json -import hashlib -import logging -import re -import os -import http.server -import threading -import shutil -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -from test_prepare import RELEASE, archive, git, source -import preflight - -prepare = __import__("prepare") if importlib.util.find_spec("prepare") else None - - -def pinned_node(test): - executable = shutil.which("node") - if not executable or subprocess.run([executable, "--version"], capture_output=True).stdout.strip() != b"v24.12.0": - test.skipTest("exact local Node fixture unavailable; hosted selection belongs to Task 8") - return Path(executable).resolve() - - -class JsReleaseTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(prepare, "should provide independent JS release orchestration") - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.a = self.root / "A" - self.b = self.root / "B" - for workspace in (self.a, self.b): - (workspace / "bundles").mkdir(parents=True) - for name in ("tsjs-core.js", "tsjs-example.js"): - (workspace / "bundles" / name).write_bytes(b"console.log('synthetic');") - modules = [{"id": name, "filename": f"tsjs-{name}.js", "size": 25, - "sha256": hashlib.sha256(b"console.log('synthetic');").hexdigest(), "contributions": []} - for name in ("core", "example")] - (workspace / "inventory.json").write_bytes(json.dumps({"schema_version": 1, - "source_commit": "a" * 40, "modules": modules, "tools": [], "materials": []}).encode()) - self.expected = ["core", "example"] - - def test_equal_final_bytes_publish_only_a_and_its_inventory(self): - (self.b / "inventory.json").write_bytes(b'{"synthetic":true,"path":"B"}\n') - output = self.root / "published" - prepare.compare_and_publish(self.a, self.b, self.expected, output) - self.assertEqual(sorted(path.name for path in output.iterdir()), ["bundles", "tsjs-inventory.json"]) - self.assertEqual(sorted(path.name for path in (output / "bundles").iterdir()), - ["tsjs-core.js", "tsjs-example.js"]) - self.assertEqual((output / "tsjs-inventory.json").read_bytes(), (self.a / "inventory.json").read_bytes()) - - def test_one_byte_difference_publishes_nothing(self): - (self.b / "bundles/tsjs-example.js").write_bytes(b"console.log('synthetic')!") - output = self.root / "published" - with self.assertRaisesRegex(ValueError, "differ"): - prepare.compare_and_publish(self.a, self.b, self.expected, output) - self.assertFalse(output.exists(), "should not publish any unequal build") - - def test_inventory_changed_after_build_cannot_publish(self): - inventory = json.loads((self.a / "inventory.json").read_bytes()) - inventory["modules"][0]["sha256"] = "0" * 64 - (self.a / "inventory.json").write_text(json.dumps(inventory)) - output = self.root / "published" - with self.assertRaisesRegex(ValueError, "inventory"): - prepare.compare_and_publish(self.a, self.b, self.expected, output) - self.assertFalse(output.exists()) - - def test_inventory_rejects_float_boolean_sizes_and_nonobject_modules(self): - original = json.loads((self.a / "inventory.json").read_bytes()) - for size, payload in ((25.0, b"console.log('synthetic');"), (True, b"x")): - inventory = json.loads(json.dumps(original)) - for module in inventory["modules"]: - module["size"] = size - module["sha256"] = hashlib.sha256(payload).hexdigest() - for workspace in (self.a, self.b): - (workspace / "bundles" / module["filename"]).write_bytes(payload) - (self.a / "inventory.json").write_text(json.dumps(inventory)) - output = self.root / ("published-boolean" if size is True else "published-float") - with self.subTest(size=size): - with self.assertRaisesRegex(ValueError, "inventory"): - prepare.compare_and_publish(self.a, self.b, self.expected, output) - self.assertFalse(output.exists()) - for item in (None, True, "module", [], 1): - inventory = json.loads(json.dumps(original)) - inventory["modules"][0] = item - (self.a / "inventory.json").write_text(json.dumps(inventory)) - with self.subTest(item=item), self.assertRaisesRegex(ValueError, "inventory"): - prepare.compare_and_publish(self.a, self.b, self.expected, self.root / "malformed") - - def test_inventory_rejects_nonclosed_root_and_module_fields(self): - original = json.loads((self.a / "inventory.json").read_bytes()) - for change in ("schema", "commit", "extra", "missing", "module-extra", "module-missing", "contributions"): - inventory = json.loads(json.dumps(original)) - if change == "schema": - inventory["schema_version"] = True - elif change == "commit": - inventory["source_commit"] = "HEAD" - elif change == "extra": - inventory["extra"] = True - elif change == "missing": - del inventory["materials"] - elif change == "module-extra": - inventory["modules"][0]["extra"] = True - elif change == "module-missing": - del inventory["modules"][0]["contributions"] - else: - inventory["modules"][0]["contributions"] = None - (self.a / "inventory.json").write_text(json.dumps(inventory)) - output = self.root / change - with self.subTest(change=change): - with self.assertRaisesRegex(ValueError, "inventory"): - prepare.compare_and_publish(self.a, self.b, self.expected, output) - self.assertFalse(output.exists()) - - def test_missing_extra_symlink_and_empty_bundles_are_rejected(self): - for change in ("missing", "extra", "symlink", "empty"): - path = self.b / "bundles/tsjs-example.js" - original = path.read_bytes() - if change == "missing": - path.unlink() - elif change == "extra": - (self.b / "bundles/extra.js").write_bytes(b"x") - elif change == "symlink": - path.unlink() - path.symlink_to(self.a / "bundles/tsjs-example.js") - else: - path.write_bytes(b"") - with self.subTest(change=change), self.assertRaises(ValueError): - prepare.compare_and_publish(self.a, self.b, self.expected, self.root / "published") - if change == "extra": - (self.b / "bundles/extra.js").unlink() - if path.is_symlink(): - path.unlink() - path.write_bytes(original) - - def test_official_cli_fails_closed_pending_qualification(self): - recipe = RELEASE / "build-js.sh" - self.assertTrue(recipe.exists(), "should provide executable build recipe") - result = subprocess.run(["/bin/sh", str(recipe), "--source", str(RELEASE.parents[1]), - "--commit", "a" * 40, "--archives", str(self.root), - "--work", str(self.root / "work"), "--output", str(self.root / "output")], - capture_output=True, env={"PATH": "/usr/bin:/bin", "HOME": str(self.root)}) - self.assertNotEqual(result.returncode, 0) - self.assertIn(b"qualification pending", result.stderr) - self.assertFalse((self.root / "work").exists()) - - def test_no_official_local_bypass_argument(self): - result = subprocess.run(["/bin/sh", str(RELEASE / "build-js.sh"), "--local-fixture"], - capture_output=True, env={"PATH": "/usr/bin:/bin"}) - self.assertNotEqual(result.returncode, 0) - self.assertNotIn(b'"qualification":"qualified"', result.stdout) - - def test_wrapper_cannot_execute_inherited_path_or_python_shell_injection(self): - marker = self.root / "injection-ran" - script = self.root / "dirname" - script.write_text(f"#!/bin/sh\ntouch '{marker}'\n") - script.chmod(0o755) - loader = self.root / "loader" - loader.write_text(f"touch '{marker}'\n") - result = subprocess.run(["/bin/sh", str(RELEASE / "build-js.sh"), "--source", "/example", - "--commit", "a" * 40, "--archives", "/example", "--work", "/example", - "--output", "/example"], capture_output=True, - env={"PATH": str(self.root), "PYTHONPATH": str(self.root), - "BASH_ENV": str(loader), "ENV": str(loader)}) - self.assertNotEqual(result.returncode, 0) - self.assertFalse(marker.exists(), "should not execute caller hooks before guard") - - def test_changed_source_checks_test_data_and_build_config(self): - repo = self.root / "source" - commit = source(repo, {"src/index.ts": b"example", "test/data.json": b"{}", - "vitest.config.ts": b"example"}) - before = prepare.source_check(repo, commit) - for name in ("src/index.ts", "test/data.json", "vitest.config.ts"): - path = repo / name - original = path.read_bytes() - path.write_bytes(b"mutated") - with self.subTest(name=name), self.assertRaisesRegex(ValueError, "source"): - prepare.source_check(repo, commit, before) - path.write_bytes(original) - (repo / "test/extra.ts").write_bytes(b"untracked") - with self.assertRaisesRegex(ValueError, "source"): - prepare.source_check(repo, commit, before) - - def test_failed_child_and_changed_source_block_build(self): - # Genuine subprocess failures and mutations, not mocked command success. - repo = self.root / "source" - commit = source(repo, {"src/index.ts": b"example"}) - with self.assertRaisesRegex(ValueError, "failed"): - prepare.checked_stage(repo, commit, ["/bin/sh", "-c", "exit 23"], {}, self.root / "failure.log") - with self.assertRaisesRegex(ValueError, "source"): - prepare.checked_stage(repo, commit, ["/bin/sh", "-c", "echo mutated > src/index.ts"], - {}, self.root / "mutation.log") - - def test_mandatory_test_command_cannot_follow_local_skip(self): - commands = prepare.js_commands(self.root, self.root / "node") - self.assertTrue(any("vitest.mjs" in str(arg) for cmd in commands for arg in cmd), - "should unconditionally run release Vitest") - self.assertFalse(any("npx" in str(arg) for cmd in commands for arg in cmd), - "should never fetch a substitute tool") - - def test_required_native_tools_cannot_be_optional(self): - lock = {"lockfileVersion": 3, "packages": {"": {"name": "example", "version": "1.0.0"}}} - with self.assertRaisesRegex(ValueError, "native"): - prepare.required_npm_materials(lock, "darwin", "arm64") - - def test_recipe_declares_actual_js_settings(self): - recipe = next(record for record in preflight.fixed_tools()["recipes"] if record["id"] == "tsjs") - self.assertEqual(recipe["script"], "scripts/release/build-js.sh") - self.assertNotIn("cargo_locked", recipe["settings"]) - self.assertTrue(recipe["settings"]["mandatory_tests"]) - - def test_release_vitest_uses_literal_host_without_dns_resolution(self): - config = (RELEASE.parents[1] / prepare.LIB / "vitest.config.ts").read_text() - self.assertIn("host: '127.0.0.1'", config, "should avoid localhost resolver access under denial") - - -class LocalBoundaryTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(prepare, "should provide actual local confinement probes") - if sys.platform != "darwin" or os.environ.get("TS_RELEASE_BOUNDARY_TESTS") != "1": - self.skipTest("actual macOS local probes run explicitly outside the outer sandbox") - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.a = self.root / "A" - self.b = self.root / "B" - self.a.mkdir() - self.b.mkdir() - (self.b / "generated.js").write_text("synthetic output from B") - self.boundary = prepare.LocalMacBoundary(self.a, [Path("/bin"), Path("/usr/bin"), Path("/usr/lib"), - Path("/System"), Path(sys.executable).resolve().parent.parent]) - - def run_confined(self, script): - return subprocess.run(self.boundary.command(["/bin/sh", "-c", script]), capture_output=True, - cwd=self.a, env={"PATH": "/usr/bin:/bin", "HOME": str(self.a)}) - - def test_no_a_b_generated_output_exchange_including_symlink_and_parent(self): - (self.a / "link").symlink_to(self.b) - for access in (f"cat '{self.b}/generated.js'", "cat ../B/generated.js", "cat link/generated.js", - f"/usr/bin/stat '{self.b}/generated.js'", f"echo exchanged > '{self.b}/generated.js'"): - with self.subTest(access=access): - self.assertNotEqual(self.run_confined(access).returncode, 0) - self.assertEqual((self.b / "generated.js").read_text(), "synthetic output from B") - self.assertEqual(self.run_confined("echo own > own.js && cat own.js").returncode, 0) - - def test_network_and_writes_to_shared_tools_are_denied(self): - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.BaseHTTPRequestHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - self.addCleanup(server.server_close) - self.addCleanup(server.shutdown) - endpoint = f"http://127.0.0.1:{server.server_port}/" - command = ["/usr/bin/curl", "--noproxy", "*", "--max-time", "2", endpoint] - positive = subprocess.run(command, capture_output=True) - self.assertEqual(positive.returncode, 0, "should prove the same local endpoint is reachable without denial") - self.assertNotEqual(self.run_confined(f"/usr/bin/curl --noproxy '*' --max-time 2 {endpoint}").returncode, 0) - shared = self.root / "shared" - shared.mkdir() - (shared / "tool").write_text("synthetic readonly tool") - boundary = prepare.LocalMacBoundary(self.a, [Path("/bin"), shared]) - result = subprocess.run(boundary.command(["/bin/sh", "-c", f"echo changed > '{shared}/tool'"]), - capture_output=True, cwd=self.a) - self.assertNotEqual(result.returncode, 0) - self.assertEqual((shared / "tool").read_text(), "synthetic readonly tool") - - -class OfflineLifecycleTests(unittest.TestCase): - """Run actual pinned npm on small authenticated synthetic package archives.""" - - def setUp(self): - self.assertIsNotNone(prepare, "should provide actual installation observations") - self.assertTrue(hasattr(prepare, "install_npm"), "should freshly install and observe actual npm execution") - self.node = pinned_node(self) - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - - def fixture(self, name="example", script=None, body=b"", *, optional=False, shadow_node=False): - manifest = {"name": name, "version": "0.27.3" if name == "esbuild" else "1.0.0"} - if script: - manifest["scripts"] = {"postinstall": script} - if shadow_node: - manifest["bin"] = {"node": "node-shadow.sh"} - package = self.root / (name + ".tgz") - members = [("package/package.json", json.dumps(manifest).encode()), - ("package/install.js", body), ("package/index.js", b"// synthetic")] - if shadow_node: - members.append(("package/node-shadow.sh", b"#!/bin/sh\necho shadow > shadow-ran\nexit 99\n")) - integrity = archive(package, members) - checked = prepare.verify_archive(package, integrity) - root_manifest = {"name": "synthetic", "version": "1.0.0", "dependencies": {name: manifest["version"]}} - if optional: - root_manifest["optionalDependencies"] = root_manifest.pop("dependencies") - locked_registry = json.loads((RELEASE.parents[1] / prepare.LIB / "package-lock.json").read_bytes()) - registry = locked_registry["packages"]["node_modules/esbuild"]["resolved"].split("/")[2] - record = {"version": manifest["version"], "resolved": f"https://{registry}/{name}/-/{name}-{manifest['version']}.tgz", - "integrity": integrity} - if script: - record["hasInstallScript"] = True - if optional: - record["optional"] = True - lock = {"name": "synthetic", "version": "1.0.0", "lockfileVersion": 3, - "packages": {"": root_manifest, "node_modules/" + name: record}} - repo = self.root / "repository" - files = {str(prepare.LIB / "package.json"): json.dumps(root_manifest).encode(), - str(prepare.LIB / "package-lock.json"): json.dumps(lock).encode()} - for file in ("prepare.py", "common.py", "preflight.py"): - files["scripts/release/" + file] = (RELEASE / file).read_bytes() - commit = source(repo, files) - return repo, commit, {"node_modules/" + name: checked} - - def test_fresh_private_cache_installs_without_restored_modules(self): - repo, commit, archives = self.fixture() - for label in ("A", "B"): - workspace = self.root / label - workspace.mkdir() - prepare.materialize_source(repo, commit, workspace / "source") - environment = prepare.local_environment(workspace, self.node, commit) - prepare.install_npm(workspace, commit, archives, self.node, environment) - self.assertTrue((workspace / "source" / prepare.LIB / "node_modules/example/index.js").is_file()) - self.assertEqual(prepare.observed_lifecycle(workspace), ([], [])) - self.assertNotEqual(os.stat(self.root / "A/cache").st_ino, os.stat(self.root / "B/cache").st_ino) - with self.assertRaisesRegex(ValueError, "fresh"): - prepare.install_npm(self.root / "A", commit, archives, self.node, - prepare.local_environment(self.root / "A", self.node, commit)) - - def test_actual_execution_records_no_output_hook_and_changes(self): - for body in (b"// genuine execution with no output", b"require('fs').writeFileSync('generated.js', 'synthetic output');"): - with self.subTest(body=body): - # Each iteration uses a different genuine Git/npm fixture. - case = self.root / ("output" if b"writeFile" in body else "no-output") - case.mkdir() - previous = self.root - self.root = case - repo, commit, archives = self.fixture("esbuild", "node install.js", body) - workspace = case / "A" - workspace.mkdir() - prepare.materialize_source(repo, commit, workspace / "source") - prepare.install_npm(workspace, commit, archives, self.node, - prepare.local_environment(workspace, self.node, commit)) - events, outputs = prepare.observed_lifecycle(workspace) - self.assertEqual(len(events), 1, "should observe a completed actual hook even without outputs") - self.assertEqual(events[0]["event"], "postinstall") - self.assertEqual(len(outputs), 1 if b"writeFile" in body else 0) - self.root = previous - - def test_failed_hook_is_rejected_even_if_npm_ignores_optional_failure(self): - repo, commit, archives = self.fixture("esbuild", "node install.js", b"process.exit(23);", optional=True) - workspace = self.root / "A" - workspace.mkdir() - prepare.materialize_source(repo, commit, workspace / "source") - with self.assertRaisesRegex(ValueError, "lifecycle execution failed"): - prepare.install_npm(workspace, commit, archives, self.node, - prepare.local_environment(workspace, self.node, commit)) - with self.assertRaisesRegex(ValueError, "failed"): - prepare.observed_lifecycle(workspace) - - def test_lifecycle_executes_selected_node_despite_npm_bin_shadow(self): - repo, commit, archives = self.fixture("esbuild", "node install.js", b"// actual pinned Node", shadow_node=True) - workspace = self.root / "A" - workspace.mkdir() - prepare.materialize_source(repo, commit, workspace / "source") - shadow = workspace / "npm-prepended-bin" - shadow.mkdir() - marker = workspace / "shadow-ran" - executable = shadow / "node" - executable.write_text(f"#!/bin/sh\necho shadow > '{marker}'\nexit 99\n") - executable.chmod(0o755) - environment = prepare.local_environment(workspace, self.node, commit) - environment["PATH"] = str(shadow) + os.pathsep + environment["PATH"] - positive = subprocess.run(["/bin/sh", "-c", "node --version"], env=environment, capture_output=True) - self.assertEqual(positive.returncode, 99, "should prove the same shadow path would replace bare node") - self.assertTrue(marker.exists()) - marker.unlink() - prepare.install_npm(workspace, commit, archives, self.node, environment) - self.assertFalse(marker.exists(), "should bind hooks to selected Node even after npm extends PATH") - self.assertFalse((workspace / "source" / prepare.LIB / "node_modules/esbuild/shadow-ran").exists()) - self.assertEqual(len(prepare.observed_lifecycle(workspace)[0]), 1) - - -class FullLocalJsTests(unittest.TestCase): - """Actual full-source npm/Vite/Vitest under local denial; never official proof.""" - - def setUp(self): - self.assertIsNotNone(prepare, "should implement the release recipe") - if os.environ.get("TS_RELEASE_FULL_JS_TESTS") != "1": - self.skipTest("explicit full local recipe probe; hosted qualification remains pending") - self.assertTrue(hasattr(prepare, "local_js_pair"), "should orchestrate both actual independent installations") - - def test_two_actual_offline_builds_and_tests_match_all_tracked_modules(self): - self.node = pinned_node(self) - repository = RELEASE.parents[1] - temporary = tempfile.TemporaryDirectory(prefix="ts-release-js-local-", dir="/private/tmp") - self.addCleanup(temporary.cleanup) - root = Path(temporary.name).resolve() - files = {} - tracked = git(repository, "ls-tree", "-r", "--name-only", "HEAD").splitlines() - for name in tracked: - path = repository / name - if path.is_symlink(): - continue - files[name] = path.read_bytes() - for name in ("prepare.py", "build-js.sh", "tests/test_prepare.py", "tests/test_js_release.py"): - files["scripts/release/" + name] = (RELEASE / name).read_bytes() - # This transient root commit is explicitly synthetic, not release evidence. - fixture = root / "synthetic-source" - revision = source(fixture, files) - pool = root / "archives" - pool.mkdir() - lock = json.loads((fixture / prepare.LIB / "package-lock.json").read_bytes()) - native = json.loads(subprocess.run([str(self.node), "-e", "console.log(JSON.stringify([process.platform, process.arch]))"], - capture_output=True, check=True).stdout) - selected = prepare.required_npm_materials(lock, *native) - for record in selected.values(): - digest = base64.b64decode(record["integrity"][7:]).hex() - ambient = Path.home() / ".npm/_cacache/content-v2/sha512" / digest[:2] / digest[2:4] / digest[4:] - destination = pool / (digest + ".tgz") - if not destination.exists(): - destination.write_bytes(ambient.read_bytes()) - work = root / "private-pair" - published = root / "local-unqualified-output" - try: - result = prepare.local_js_pair(fixture, revision, pool, work, published, self.node) - except ValueError as error: - logs = [path for path in work.glob("*/**/*.log") if path.parent.name in {"A", "B"}] - tail = max(logs, key=lambda path: path.stat().st_mtime_ns).read_text()[-5000:] if logs else "" - self.fail(f"{error}\nPrivate local-fixture diagnostic:\n{tail}") - self.assertEqual(result["qualification"], "local-unqualified") - self.assertEqual(len(list((published / "bundles").glob("tsjs-*.js"))), 13) - summary = {"qualification": result["qualification"], "source_commit": revision, - "published_layout": ["bundles/", "tsjs-inventory.json"], - "inventory_module_count": len(json.loads((published / "tsjs-inventory.json").read_bytes())["modules"])} - for label in ("A", "B"): - workspace = work / label - test_log = re.sub(r"\x1b\[[0-9;]*m", "", (workspace / "tests.log").read_text()) - counts = re.search(r"Test Files\s+(\d+) passed.*?Tests\s+(\d+) passed.*?Type Errors\s+no errors", test_log, re.S) - self.assertIsNotNone(counts, "should prove complete Vitest and typecheck success for each build") - self.assertEqual(counts.groups(), ("51", "1253")) - summary[label] = {"test_files": int(counts[1]), "tests": int(counts[2]), "type_errors": 0, - "installed_packages": len(json.loads((workspace / "preparation.json").read_bytes())["packages"])} - self.assertEqual(len(prepare.observed_lifecycle(workspace)[0]), 2) - logging.info("Actual local fixture evidence: %s", json.dumps(summary, sort_keys=True)) - self.assertNotEqual(os.stat(work / "A/cache").st_ino, os.stat(work / "B/cache").st_ino) - # Remove every usable native binary, including esbuild's postinstall hardlink. - a = work / "A" - library = a / "source" / prepare.LIB - native_root = library / "node_modules/@esbuild" / "-".join(native) - shutil.rmtree(native_root) - for path in (library / "node_modules/esbuild/bin/esbuild",): - path.unlink(missing_ok=True) - for path in (library / "node_modules/esbuild/lib").glob("downloaded-*"): - path.unlink() - boundary = prepare.local_boundary(a, self.node, pool) - environment = prepare.local_environment(a, self.node, revision) - command = [str(self.node), "--input-type=module", "-e", - "import {build} from 'vite'; await build({configFile:false,envDir:false," - "build:{write:false,lib:{entry:'src/core/index.ts',formats:['iife'],name:'Synthetic'}}});"] - with self.assertRaisesRegex(ValueError, "failed"): - prepare.checked_stage(a / "source", revision, command, environment, - a / "missing-native.log", boundary=boundary, cwd=library) - with self.assertRaisesRegex(ValueError, "failed"): - prepare.checked_stage(a / "source", revision, [str(self.node), "node_modules/esbuild/install.js"], - environment, a / "missing-native-installer.log", boundary=boundary, cwd=library) - self.assertFalse(native_root.exists(), "should not fetch a substitute native package") - self.assertFalse((library / "node_modules/esbuild/bin/esbuild").exists()) - self.assertEqual(list((library / "node_modules/esbuild/lib").glob("downloaded-*")), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/release/tests/test_native.py b/scripts/release/tests/test_native.py deleted file mode 100644 index 2ed19e45ae..0000000000 --- a/scripts/release/tests/test_native.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Binary format negatives use bounded synthetic fixtures, never qualification.""" -import importlib.util -import struct -import sys -import tempfile -import unittest -from pathlib import Path - -RELEASE = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(RELEASE)) -SPEC = importlib.util.spec_from_file_location("inspect_native", RELEASE / "inspect-native.py") -native = None -if SPEC and SPEC.loader and Path(SPEC.origin).exists(): - native = importlib.util.module_from_spec(SPEC) - SPEC.loader.exec_module(native) - - -def elf(library="libc.so.6", version="GLIBC_2.34", rpath=None): - strings = b"\0" + library.encode() + b"\0" + version.encode() + b"\0" - version_offset = len(library) + 2 - entries = [(1, 1), (5, 0x400300), (10, len(strings)), (0x6ffffffe, 0x400400), (0x6fffffff, 1)] - if rpath is not None: - entries.insert(0, (29, len(strings))) - strings += rpath.encode() + b"\0" - entries = [(tag, len(strings) if tag == 10 else value) for tag, value in entries] - entries.append((0, 0)) - data = bytearray(0x500) - data[:16] = b"\x7fELF\x02\x01\x01" + b"\0" * 9 - struct.pack_into(" invalid +0000\n", - "committer Example 1 +0000\n" * 2): - data = f"tree {tree}\nauthor Example 1 +0000\n{committer}\nSynthetic\n" - result = subprocess.run(["/usr/bin/git", "-C", str(repo), "hash-object", "-t", "commit", - "--literally", "-w", "--stdin"], input=data.encode(), capture_output=True, check=True) - with self.subTest(committer=committer), self.assertRaisesRegex(ValueError, "timestamp"): - prepare.source_check(repo, result.stdout.decode().strip()) - - def test_source_export_only_allows_fixed_claude_link(self): - repo = self.root / "repository" - revision = source(repo, {"AGENTS.md": b"example"}) - (repo / "CLAUDE.md").symlink_to("AGENTS.md") - git(repo, "add", "CLAUDE.md") - git(repo, "-c", "user.name=Example", "-c", "user.email=example@example.com", - "commit", "-m", "Add fixed link") - revision = git(repo, "rev-parse", "HEAD") - prepare.materialize_source(repo, revision, self.root / "fixed") - (repo / "escape").symlink_to("../escape") - git(repo, "add", "escape") - git(repo, "-c", "user.name=Example", "-c", "user.email=example@example.com", - "commit", "-m", "Add prohibited link") - with self.assertRaisesRegex(ValueError, "link"): - prepare.materialize_source(repo, git(repo, "rev-parse", "HEAD"), self.root / "bad") - - def test_git_rejects_configuration_replacements_and_alternates(self): - for channel in ("filter", "replacement", "alternates"): - repo = self.root / channel - revision = source(repo, {"file": b"example"}) - if channel == "filter": - git(repo, "config", "filter.example.clean", "example-command") - elif channel == "replacement": - git(repo, "update-ref", "refs/replace/" + "a" * 40, revision) - else: - (repo / ".git/objects/info/alternates").write_text("/example/objects\n") - with self.subTest(channel=channel), self.assertRaises(ValueError): - prepare.verify_git_revision(repo, revision) - - def test_unreviewed_install_fetch_and_root_hooks_fail_before_execution(self): - for scripts in ({"postinstall": "curl https://example.com/tool"}, - {"prepare": "node fetch.js"}, {"install": "npm install example"}): - with self.subTest(scripts=scripts), self.assertRaisesRegex(ValueError, "review"): - prepare.review_lifecycle({"name": "example", "version": "1.0.0", "scripts": scripts}, - root=True) - with self.assertRaisesRegex(ValueError, "review"): - prepare.review_lifecycle({"name": "example", "version": "1.0.0", - "scripts": {"postinstall": "node fetch.js"}}) - with self.assertRaisesRegex(ValueError, "implicit"): - prepare.review_lifecycle(self.manifest, paths=["binding.gyp"]) - - def test_only_exact_pinned_registry_lifecycle_commands_are_reviewed(self): - prepare.review_lifecycle({"name": "esbuild", "version": "0.27.3", - "scripts": {"postinstall": "node install.js"}}) - for version, command in (("0.27.4", "node install.js"), ("0.27.3", "node fetch.js")): - with self.subTest(version=version), self.assertRaises(ValueError): - prepare.review_lifecycle({"name": "esbuild", "version": version, - "scripts": {"postinstall": command}}) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/release/tests/test_qualification.py b/scripts/release/tests/test_qualification.py deleted file mode 100644 index 51b07e18ea..0000000000 --- a/scripts/release/tests/test_qualification.py +++ /dev/null @@ -1,609 +0,0 @@ -"""Adversarial observational qualification fixtures; none confer release trust.""" - -import copy -import errno -import importlib.util -import json -import os -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -RELEASE = Path(__file__).resolve().parents[1] -ROOT = RELEASE.parents[1] -sys.path.insert(0, str(RELEASE / "qualification")) -sys.path.insert(0, str(RELEASE)) -if importlib.util.find_spec("qualify") is not None: - import qualify -else: - qualify = None - - -def observation(): - """Return fictional complete observations for semantic tests only.""" - digest = "a" * 64 - probes = [] - for depth in range(3): - for family in ("ipv4", "ipv6"): - for protocol in ("tcp", "udp"): - for operation in ("bind", "connect", "send"): - for destination in ("loopback", "external"): - probes.append({"depth": depth, "family": family, "protocol": protocol, - "operation": operation, "destination": destination, - "outcome": "denied", "errno": errno.EPERM, "phase": "socket"}) - probes.append({"depth": depth, "family": "unix", "operation": "exchange", - "destination": "private", "outcome": "allowed", "errno": 0, - "phase": "exchange", "protocol": "unix"}) - positive = copy.deepcopy(probes) - for probe in positive: - probe.update(outcome="allowed", errno=0, phase=probe["operation"]) - if probe["destination"] == "external": - probe.update(outcome="unavailable", errno=errno.ENETUNREACH) - return { - "schema_version": 1, "purpose": "disposable-rehearsal-observation", - "source_commit": "a" * 40, "closure_sha256": digest, - "selection_sha256": digest, "raw_tools_sha256": digest, - "dependency_sha256": digest, - "run": {"repository_id": "123", "owner_id": "456", "run_id": "789", - "attempt": 1, "job": "macos-arm", "runner": "github-hosted"}, - "host": {"environment_id": "macos15-aarch64", "kind": "github-hosted-macos", - "architecture": "aarch64", "kernel_architecture": "aarch64", - "os_version": "15.7", "translated": False, "runner_image_version": "20261001.1", - "oci_index_sha256": None, "oci_manifest_sha256": None, "glibc_version": None, - "identity_log_sha256": digest}, - "sdk": {"kind": "apple-sdk", "version": "15.5", "build_version": "24F74", - "tree_sha256": digest, "vendor_evidence_sha256": digest}, - "tools": [{"name": name, "version": "fixture", "file_sha256": digest, - "archive_sha256": digest, "tree_sha256": digest, - "authentication": {"method": "vendor-signature", "evidence_sha256": digest, - "trust_root_sha256": digest}} - for name in ("python", "git", "shell", "rustup", "rustc", "cargo", - "compiler", "linker", "archiver", "node", "npm")], - "recipe": {"id": "ts-aarch64-apple-darwin", "boundary_sha256": digest, - "preparation_log_sha256": digest, "compile_log_sha256": digest, - "native_log_sha256": digest, "exit_code": 0, "full_recipe": True, - "same_boundary": True, "executable_sha256": digest, - "native_materials_sha256": digest, "deployment_target": "13.0", - "glibc_symbols": [], "unconfined": positive, "confined": probes, - "download_attempts": {"cargo": "denied", "npm": "denied", - "missing_tool": "failed"}}, - "runtime": [{"environment_id": "macos15-aarch64", "architecture": "aarch64", - "os_version": "15.7", "outside_checkout": True, "exit_code": 0, - "kind": "cli-startup", "executable_sha256": digest, - "executable_after_sha256": digest, "log_sha256": digest, - "response_sha256": None}], - } - - -class RuntimeClosureTests(unittest.TestCase): - def test_runtime_gate_source_inputs_change_candidate_fingerprint(self): - tools = json.loads((RELEASE / "release-tools.json").read_text()) - for changed in ("trusted-server.example.toml", "edgezero.toml", "crates/trusted-server-core/src/integrations/registry.rs"): - with self.subTest(changed=changed), tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for name in (*qualify.SHARED_CLOSURE, *qualify.RUST_CLOSURE): - target = root / name - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes((ROOT / name).read_bytes()) - before = qualify.candidate_fingerprints(root, tools, "trusted-server-fastly.wasm") - target = root / changed - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes((ROOT / changed).read_bytes() + b"\n# Changed fixture input\n") - self.assertNotEqual(qualify.candidate_fingerprints(root, tools, "trusted-server-fastly.wasm"), before) - - -class QualificationTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(qualify, "should provide local qualification framework") - self.result = observation() - - def test_complete_observation_is_structurally_valid_without_qualifying(self): - qualify.validate_observation(self.result) - with self.assertRaisesRegex(ValueError, "authenticated.*hosted|pending"): - qualify.require_qualified(self.result) - - def test_rejects_wrong_architecture_and_translation(self): - for field, value in (("architecture", "x86_64"), ("kernel_architecture", "x86_64"), - ("translated", True), ("os_version", "26.0")): - with self.subTest(field=field): - changed = copy.deepcopy(self.result) - changed["host"][field] = value - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_rejects_missing_sdk_tool_and_authentication(self): - for change in ("sdk", "tool", "missing", "checksum"): - with self.subTest(change=change): - changed = copy.deepcopy(self.result) - if change == "sdk": - changed["sdk"]["tree_sha256"] = None - elif change == "tool": - changed["tools"][0]["file_sha256"] = None - elif change == "missing": - changed["tools"].pop() - else: - changed["tools"][0]["authentication"]["method"] = "raw-checksum" - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_rejects_failed_startup_changed_bytes_and_health_only(self): - for field, value in (("exit_code", 1), ("kind", "health"), - ("outside_checkout", False), ("executable_after_sha256", "b" * 64)): - with self.subTest(field=field): - changed = copy.deepcopy(self.result) - changed["runtime"][0][field] = value - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_rejects_invented_compatibility_and_missing_actual_runtime(self): - changed = copy.deepcopy(self.result) - changed["runtime"] = [] - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - self.result["runtime"][0]["os_version"] = "13.0" - with self.assertRaises(ValueError): - qualify.validate_observation(self.result) - - def test_rejects_permitted_confined_socket_and_missing_descendant(self): - for change in ("allowed", "timeout", "missing", "unix"): - with self.subTest(change=change): - changed = copy.deepcopy(self.result) - if change in ("allowed", "timeout"): - changed["recipe"]["confined"][0]["outcome"] = change - elif change == "missing": - changed["recipe"]["confined"] = [p for p in changed["recipe"]["confined"] - if p["depth"] != 2] - else: - changed["recipe"]["confined"][-1]["outcome"] = "denied" - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_rejects_failed_positive_control_and_incomplete_compilation(self): - for field, value in (("exit_code", 1), ("full_recipe", False), ("same_boundary", False)): - changed = copy.deepcopy(self.result) - changed["recipe"][field] = value - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - self.result["recipe"]["unconfined"][0].update(outcome="denied", errno=1) - with self.assertRaises(ValueError): - qualify.validate_observation(self.result) - - def test_closed_schema_rejects_unknown_commands_boolean_integers_and_status(self): - for changed in (dict(self.result, command="example-command"), - dict(self.result, qualification="passed"), - dict(self.result, schema_version=True)): - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - self.result["recipe"]["confined"][0]["depth"] = True - with self.assertRaises(ValueError): - qualify.validate_observation(self.result) - - def test_pending_catalog_distinguishes_sdk_and_runtime_identities(self): - import preflight - tools = preflight.fixed_tools() - qualify.validate_catalog(tools) - mac = next(e for e in tools["environments"] if e["id"] == "macos15-aarch64") - self.assertEqual(mac["kind"], "github-hosted-macos") - self.assertNotIn("image_sha256", mac, "should not label image-version hash as image bytes") - runtime = next(e for e in tools["environments"] if e["id"] == "debian12-x86_64") - self.assertEqual(runtime["role"], "compatibility-runtime") - self.assertNotIn("sdk_sha256", runtime) - - def test_hashes_do_not_self_reference_derived_result_pins(self): - import preflight - tools = preflight.fixed_tools() - first = qualify.selection_digest(tools) - changed = copy.deepcopy(tools) - changed["recipes"][0]["sha256"] = "a" * 64 - self.assertEqual(first, qualify.selection_digest(changed)) - changed["recipes"][0]["settings"]["release_debug"] = 2 - self.assertNotEqual(first, qualify.selection_digest(changed)) - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "helper.py" - path.write_bytes(b"fixture\n") - first = qualify.closure_digest(Path(directory), ("helper.py",), {"mode": "fixture"}) - path.write_bytes(b"changed\n") - self.assertNotEqual(first, qualify.closure_digest(Path(directory), ("helper.py",), - {"mode": "fixture"})) - - def test_real_unconfined_probe_covers_child_and_grandchild(self): - probe = RELEASE / "qualification/socket_probe.py" - with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: - result = subprocess.run([sys.executable, "-I", "-B", str(probe), "--private-root", directory], - check=False, capture_output=True, timeout=30) - self.assertEqual(result.returncode, 0, "should execute actual probes") - probes = json.loads(result.stdout) - self.assertEqual({p["depth"] for p in probes}, {0, 1, 2}) - self.assertEqual(len(probes), 75) - self.assertEqual({p["protocol"] for p in probes}, {"tcp", "udp", "unix"}) - self.assertTrue(all(p["outcome"] == "allowed" for p in probes - if p["destination"] in ("loopback", "private") and p["family"] != "ipv6")) - - def test_full_recipe_gate_stops_before_creating_work(self): - with tempfile.TemporaryDirectory() as directory: - output = Path(directory) / "work" - with self.assertRaisesRegex(ValueError, "Task 9|production recipe|authenticated candidate staging"): - qualify.check_execution_prerequisites(ROOT, output) - self.assertFalse(output.exists(), "should fail before materializing work") - - def test_linux_rejects_unknown_libc_and_above_floor_symbols(self): - result = copy.deepcopy(self.result) - result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", - architecture="x86_64", kernel_architecture="x86_64", - os_version="9.6", runner_image_version=None, - oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, - glibc_version="2.34") - result["sdk"]["kind"] = "gnu-sysroot" - result["recipe"].update(id="ts-x86_64-unknown-linux-gnu", deployment_target=None, - glibc_symbols=["GLIBC_2.34"]) - result["runtime"] = [dict(self.result["runtime"][0], environment_id=identity, - architecture="x86_64", os_version=version) - for identity, version in (("ubi9-x86_64", "9.6"), - ("ubuntu22.04-x86_64", "22.04"), - ("debian12-x86_64", "12.11"))] - qualify.validate_observation(result) - for version in ("2.35", "unknown", "2.34vendor"): - changed = copy.deepcopy(result) - changed["host"]["glibc_version"] = version - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - result["recipe"]["glibc_symbols"] = ["GLIBC_2.35"] - with self.assertRaises(ValueError): - qualify.validate_observation(result) - - def test_linux_rejects_claiming_new_runtime_as_old_baseline(self): - result = copy.deepcopy(self.result) - result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", - architecture="x86_64", kernel_architecture="x86_64", - os_version="9.6", runner_image_version=None, - oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, - glibc_version="2.34") - result["sdk"]["kind"] = "gnu-sysroot" - result["recipe"].update(id="ts-x86_64-unknown-linux-gnu", deployment_target=None, - glibc_symbols=["GLIBC_2.34"]) - result["runtime"] = [dict(self.result["runtime"][0], environment_id=identity, - architecture="x86_64", os_version="26.0") - for identity in ("ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64")] - with self.assertRaisesRegex(ValueError, "runtime|compatibility"): - qualify.validate_observation(result) - - def test_complete_fastly_publisher_observation_requires_runtime_tools_and_response(self): - result = copy.deepcopy(self.result) - result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", - architecture="x86_64", kernel_architecture="x86_64", - os_version="9.6", runner_image_version=None, - oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, - glibc_version="2.34") - result["sdk"]["kind"] = "gnu-sysroot" - result["recipe"].update(id="trusted-server-fastly.wasm", deployment_target=None) - result["tools"].extend(dict(result["tools"][0], name=name) for name in ("viceroy", "wasmtime")) - result["runtime"][0].update(environment_id="fastly-viceroy", architecture="wasm32", - os_version="fixture", kind="publisher-request", response_sha256="a" * 64) - qualify.validate_observation(result) - for field, value in (("kind", "cli-startup"), ("response_sha256", None)): - changed = copy.deepcopy(result) - changed["runtime"][0][field] = value - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_selection_digest_binds_acquisition_authentication_roots(self): - import preflight - tools = preflight.fixed_tools() - digest = qualify.selection_digest(tools) - tools["tools"][0]["acquisition"] = {"trust_root_sha256": "a" * 64} - self.assertNotEqual(digest, qualify.selection_digest(tools), - "should invalidate qualification when acquisition trust changes") - - def test_source_owned_complete_closure_refuses_missing_recipe_files(self): - self.assertTrue(hasattr(qualify, "candidate_fingerprints"), "should define reviewed closure") - import preflight - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex(ValueError, "Task 9|missing.*closure"): - qualify.candidate_fingerprints(Path(directory), preflight.fixed_tools(), "ts-aarch64-apple-darwin") - - def test_linux_profile_restricts_socket_domains_on_pinned_default_baseline(self): - self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") - import boundaries - profile = boundaries.linux_seccomp_profile() - self.assertEqual(profile["defaultAction"], "SCMP_ACT_ERRNO") - rules = [r for r in profile["syscalls"] if set(r["names"]) & {"socket", "socketpair", "socketcall"}] - self.assertEqual(len(rules), 1) - self.assertEqual(set(rules[0]["names"]), {"socket", "socketpair"}) - self.assertEqual(rules[0]["args"], [{"index": 0, "value": 1, "op": "SCMP_CMP_EQ"}]) - - def test_linux_boundary_drops_caps_and_uses_private_mounts(self): - self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") - import boundaries - with tempfile.TemporaryDirectory() as directory: - private = Path(directory) / "private" - readonly = Path(directory) / "readonly" - private.mkdir() - readonly.mkdir() - boundary = boundaries.LinuxBoundary(private, [readonly], - "example.com/builder@sha256:" + "a" * 64) - command = boundary.command(["/usr/bin/python3", "-I", "-B", "/work/probe.py"]) - self.assertIn("none", command) - self.assertIn("ALL", command) - self.assertIn("no-new-privileges=true", command) - self.assertIn("--read-only", command) - self.assertIn("--ipc=private", command) - self.assertIn("--pid=private", command) - self.assertNotIn("--privileged", command) - self.assertTrue(any("readonly" in arg for arg in command if arg.startswith("type=bind"))) - with self.assertRaises(ValueError): - boundaries.LinuxBoundary(private, [Path("/")], "example.com/builder:latest") - - def test_linux_control_uses_same_container_and_maps_private_root(self): - import boundaries - with tempfile.TemporaryDirectory() as directory: - private = Path(directory) / "private" - readonly = Path(directory) / "readonly" - private.mkdir() - readonly.mkdir() - boundary = boundaries.LinuxBoundary(private, [readonly], - "example.com/builder@sha256:" + "a" * 64) - commands = boundaries.probe_commands(boundary, private, Path("/usr/bin/python3")) - for phase in ("unconfined", "confined"): - self.assertEqual(commands[phase][0], "/usr/bin/docker") - self.assertIn(boundary.image, commands[phase]) - self.assertEqual(commands[phase][-1], "/work") - self.assertIn("none", commands["confined"]) - self.assertNotIn("none", commands["unconfined"]) - self.assertTrue(any("control-seccomp.json" in item for item in commands["unconfined"])) - - def test_closure_includes_transitive_source_owned_build_helpers(self): - required = { - "crates/trusted-server-js/build.rs", "crates/trusted-server-js/build/prebuilt.rs", - "crates/trusted-server-js/build/inventory.rs", "crates/trusted-server-js/build/bundle_set.rs", - "crates/trusted-server-core/build.rs", "crates/trusted-server-core/build_support/git_version.rs", - "crates/trusted-server-cli/build.rs", "crates/trusted-server-adapter-fastly/build.rs", - "crates/trusted-server-js/lib/build-all.mjs", "crates/trusted-server-js/lib/build-module-options.mjs", - "crates/trusted-server-js/lib/release-composition.mjs", "crates/trusted-server-js/lib/vitest.config.ts", - } - self.assertTrue(required.issubset(set(qualify.SHARED_CLOSURE)), - "should invalidate qualification when any source-owned build helper changes") - - def test_socket_failure_phase_and_errno_cannot_masquerade_as_denial(self): - for phase, outcome, error in (("exchange", "denied", errno.EPERM), - ("socket", "denied", errno.ENETUNREACH)): - changed = copy.deepcopy(self.result) - changed["recipe"]["confined"][0].update(phase=phase, outcome=outcome, errno=error) - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - self.result["recipe"]["unconfined"][1].update(outcome="unavailable", errno=0) - with self.assertRaises(ValueError): - qualify.validate_observation(self.result) - - def test_successful_socket_controls_must_complete_the_intended_operation(self): - for phase, index, wrong_phase in (("unconfined", 0, "exchange"), - ("unconfined", 2, "bind"), - ("unconfined", 4, "connect"), - ("unconfined", 24, "bind"), - ("confined", 24, "bind")): - with self.subTest(phase=phase, index=index, wrong_phase=wrong_phase): - changed = copy.deepcopy(self.result) - changed["recipe"][phase][index]["phase"] = wrong_phase - with self.assertRaisesRegex(ValueError, "operation|exchange"): - qualify.validate_observation(changed) - - def test_tcp_send_denial_can_record_an_earlier_actual_client_phase(self): - for phase in ("socket", "connect", "send"): - with self.subTest(phase=phase): - changed = copy.deepcopy(self.result) - changed["recipe"]["confined"][4]["phase"] = phase - qualify.validate_observation(changed) - - def test_major_debian_and_zero_suffixed_glibc_preserve_numeric_floors(self): - result = copy.deepcopy(self.result) - result["host"].update(environment_id="gnu-builder-x86_64", kind="oci-builder", - architecture="x86_64", kernel_architecture="x86_64", - os_version="9.6", runner_image_version=None, - oci_index_sha256="a" * 64, oci_manifest_sha256="a" * 64, - glibc_version="2.34.0") - result["sdk"]["kind"] = "gnu-sysroot" - result["recipe"].update(id="ts-x86_64-unknown-linux-gnu", deployment_target=None, - glibc_symbols=["GLIBC_2.34.0"]) - result["runtime"] = [dict(self.result["runtime"][0], environment_id=identity, - architecture="x86_64", os_version=version) - for identity, version in (("ubi9-x86_64", "9.6"), - ("ubuntu22.04-x86_64", "22.04"), - ("debian12-x86_64", "12"))] - for version in ("2.34", "2.34.0"): - with self.subTest(valid_glibc=version): - changed = copy.deepcopy(result) - changed["host"]["glibc_version"] = version - changed["recipe"]["glibc_symbols"] = [f"GLIBC_{version}"] - qualify.validate_observation(changed) - for value in ("2.34.1", "2.35.0", "unknown", "2.34vendor"): - with self.subTest(glibc=value): - changed = copy.deepcopy(result) - changed["host"]["glibc_version"] = value - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - for value in ("11", "13", "12vendor"): - with self.subTest(debian=value): - changed = copy.deepcopy(result) - changed["runtime"][-1]["os_version"] = value - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_rejects_integral_floats_for_integer_identities_and_statuses(self): - for selector in ("schema", "attempt", "depth", "errno", "compile", "runtime"): - with self.subTest(selector=selector): - changed = copy.deepcopy(self.result) - if selector == "schema": - changed["schema_version"] = 1.0 - elif selector == "attempt": - changed["run"]["attempt"] = 1.0 - elif selector in {"depth", "errno"}: - changed["recipe"]["confined"][0][selector] = 0.0 if selector == "depth" else 1.0 - elif selector == "compile": - changed["recipe"]["exit_code"] = 0.0 - else: - changed["runtime"][0]["exit_code"] = 0.0 - with self.assertRaises(ValueError): - qualify.validate_observation(changed) - - def test_container_mounts_reject_symlink_directory_escape(self): - import boundaries - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - private, readonly, outside = (root / name for name in ("private", "readonly", "outside")) - for path in (private, readonly, outside): - path.mkdir() - (readonly / "escape").symlink_to(outside, target_is_directory=True) - with self.assertRaisesRegex(ValueError, "symlink|escape"): - boundaries.LinuxBoundary(private, [readonly], "example.com/builder@sha256:" + "a" * 64) - - def test_recipe_closure_rejects_internal_symlink_parent(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - actual = root / "actual" - actual.mkdir() - (actual / "helper.py").write_text("fixture\n") - (root / "alias").symlink_to(actual, target_is_directory=True) - with self.assertRaises(ValueError): - qualify.closure_digest(root, ("alias/helper.py",), {"mode": "fixture"}) - - def test_macos_profile_rejects_shared_write_roots_and_has_private_unix_only(self): - self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") - import boundaries - with tempfile.TemporaryDirectory() as directory: - private = Path(directory) / "private" - readonly = Path(directory) / "readonly" - private.mkdir() - readonly.mkdir() - profile = boundaries.macos_profile(private, [readonly]) - self.assertIn("(deny default)", profile) - self.assertIn("(local unix-socket", profile) - self.assertIn("(remote unix-socket", profile) - self.assertNotIn("(allow network*)", profile) - self.assertNotIn("mach-lookup", profile) - self.assertNotIn("appleevent", profile) - with self.assertRaises(ValueError): - boundaries.macos_profile(private, [Path(directory)]) - - def test_macos_profile_protects_tracked_source_and_git_with_private_install_writes(self): - import boundaries - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - manifest = root / "package.json" - objects = root / "git-objects" - manifest.write_text("{}\n") - objects.mkdir() - profile = boundaries.macos_profile(root, [], immutable=[manifest, objects]) - self.assertIn(f'(deny file-write* (literal "{manifest}"))', profile) - self.assertIn(f'(deny file-write* (subpath "{objects}"))', profile) - - @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", - "explicit actual macOS ancestor alias protection probe") - def test_actual_immutable_ancestors_deny_rename_removal_and_hardlink_aliases(self): - import boundaries - with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: - root = Path(directory).resolve() - source = root / "crates/source" - source.mkdir(parents=True) - manifest = source / "Cargo.toml" - manifest.write_text("fixture\n") - objects = source / "git/objects" - objects.mkdir(parents=True) - (source / "node_modules").mkdir() - (source / "output").mkdir() - python = Path(sys.executable).resolve() - boundary = boundaries.MacBoundary(root, [python.parents[1], Path("/usr/lib"), - Path("/System/Library")], immutable=[manifest, objects]) - script = """import errno, os, pathlib, sys -root = pathlib.Path(sys.argv[1]) -source = root / 'crates/source' -for ancestor in (root / 'crates', source, source / 'git'): - moved = root / 'moved' - try: - ancestor.rename(moved) - except OSError as error: - if error.errno not in (errno.EACCES, errno.EPERM): - raise - else: - manifest = moved / ('source/Cargo.toml' if ancestor == root / 'crates' else 'Cargo.toml') - if manifest.is_file(): - manifest.write_text('mutation-via-ancestor-alias') - moved.rename(ancestor) - sys.exit('immutable ancestor rename was permitted') - try: - ancestor.rmdir() - except OSError as error: - if error.errno not in (errno.EACCES, errno.EPERM): - raise - else: - sys.exit('immutable ancestor removal was permitted') -try: - os.link(source / 'Cargo.toml', root / 'hardlink-alias') -except OSError as error: - if error.errno not in (errno.EACCES, errno.EPERM): - raise -else: - sys.exit('immutable hard-link alias was permitted') -(source / 'node_modules/generated').write_text('private-install-output') -(source / 'output/generated').write_text('private-build-output') -""" - result = subprocess.run(boundary.command([str(python), "-I", "-B", "-c", script, str(root)]), - env={"HOME": str(root), "TMPDIR": str(root)}, stdin=subprocess.DEVNULL, - capture_output=True, close_fds=True, timeout=30, check=False) - self.assertEqual(result.returncode, 0, - "should deny ancestor aliases/removal and permit private writes: " - + result.stderr.decode()) - self.assertEqual(manifest.read_text(), "fixture\n", "should retain original tracked source") - self.assertEqual((source / "node_modules/generated").read_text(), "private-install-output") - self.assertEqual((source / "output/generated").read_text(), "private-build-output") - - @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", - "explicit actual local macOS immutable source probe") - def test_actual_source_writes_denied_while_node_modules_writes_allowed(self): - import boundaries - with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: - root = Path(directory).resolve() - manifest = root / "package.json" - objects = root / "git-objects" - manifest.write_text("{}\n") - objects.mkdir() - node_modules = root / "node_modules" - node_modules.mkdir() - python = Path(sys.executable).resolve() - boundary = boundaries.MacBoundary(root, [python.parents[1], Path("/usr/lib"), - Path("/System/Library")], immutable=[manifest, objects]) - script = """import errno, pathlib, sys -root = pathlib.Path(sys.argv[1]) -for path in (root / 'package.json', root / 'git-objects' / 'new-object'): - try: - path.write_text('mutation') - except OSError as error: - if error.errno not in (errno.EACCES, errno.EPERM): - raise - else: - sys.exit('immutable source was writable') -(root / 'node_modules' / 'generated').write_text('private-install-output') -""" - result = subprocess.run(boundary.command([str(python), "-I", "-B", "-c", script, str(root)]), - env={"HOME": str(root), "TMPDIR": str(root)}, stdin=subprocess.DEVNULL, - capture_output=True, close_fds=True, timeout=30, check=False) - self.assertEqual(result.returncode, 0, "should permit only private installation writes") - self.assertEqual(manifest.read_text(), "{}\n") - self.assertEqual((node_modules / "generated").read_text(), "private-install-output") - - @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", - "explicit actual local macOS profile probe") - def test_actual_local_mac_profile_denies_descendants_and_permits_private_unix(self): - self.assertIsNotNone(importlib.util.find_spec("boundaries"), "should provide real profiles") - import boundaries - with tempfile.TemporaryDirectory(prefix="ts-q-") as directory: - root = Path(directory).resolve() - readonly = [RELEASE, Path(sys.executable).resolve().parents[1], - Path("/usr/lib"), Path("/System/Library")] - boundary = boundaries.MacBoundary(root, readonly) - probes = boundaries.probe_controls(boundary, root, Path(sys.executable).resolve()) - qualify.validate_socket_pair(probes["unconfined"], probes["confined"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/release/tests/test_recipe.py b/scripts/release/tests/test_recipe.py deleted file mode 100644 index 645c977458..0000000000 --- a/scripts/release/tests/test_recipe.py +++ /dev/null @@ -1,656 +0,0 @@ -"""Closed canonical recipe and honest role observation contract fixtures.""" -import copy -import contextlib -import io -import os -import platform -import importlib.util -import sys -import subprocess -import signal -import time -import tempfile -import unittest -from pathlib import Path - -RELEASE = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(RELEASE)) -recipe = None -if importlib.util.find_spec("rust_recipe") is not None: - import rust_recipe as recipe - - -class RecipeTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(recipe, "canonical Rust recipe is missing") - - def test_unknown_asset_and_variant(self): - for name in ("other.wasm", "trusted-server-fastly.wasm:reusable-sandbox"): - with self.assertRaisesRegex(ValueError, "asset"): - recipe.artifact_selection(name) - - def test_closed_arguments(self): - with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): - recipe.parse_request(["--variant", "default"]) - - def test_fresh_output(self): - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex(ValueError, "fresh"): - recipe.fresh_root(Path(directory)) - - def test_fixed_cargo_selection(self): - artifact = recipe.artifact_selection("trusted-server-fastly.wasm") - command = recipe.cargo_command(Path("/authenticated/cargo"), artifact, Path("/private/target"), Path("/private/config.toml")) - self.assertIn("--frozen", command) - self.assertIn("--message-format=json-render-diagnostics", command) - self.assertIn("--release", command) - self.assertNotIn("--features", command) - self.assertNotIn("sourcebuild", command) - - def test_role_specific_profiles(self): - a, b = recipe.ambient_profile("A"), recipe.ambient_profile("B") - self.assertEqual((a["umask"], b["umask"]), ("022", "027")) - self.assertEqual((a["jobs"], b["jobs"]), (4, 2)) - self.assertNotEqual(a["checkout_root"], b["checkout_root"]) - self.assertEqual(b["locale"], "qualification-selected-alternate") - - def observation(self, role): - return {"role": role, "checkout_root": "/external/" + role, - "umask": "022" if role == "A" else "027", "jobs": 4 if role == "A" else 2, - "locale": "C" if role == "A" else "en_US.UTF-8", "timezone": "UTC" if role == "A" else "Asia/Kolkata", - "start_time_ns": 1 if role == "A" else 2, "execution_user": "65532", - "user_variation": "unsupported", "normalized": {"locale": "C", "timezone": "UTC", "epoch": 123, "source_root": "/source"}} - - def test_missing_selected_b_variation(self): - a, b = self.observation("A"), self.observation("B") - b["locale"] = "C" - with self.assertRaisesRegex(ValueError, "variation"): - recipe.validate_pair_observations(a, b, {"locale": "en_US.UTF-8", "timezone": "Asia/Kolkata", "user_variation": False}) - - def test_normalized_values_are_not_reported_varied(self): - a, b = self.observation("A"), self.observation("B") - b["normalized"]["epoch"] = 456 - with self.assertRaisesRegex(ValueError, "normalized"): - recipe.validate_pair_observations(a, b, {"locale": "en_US.UTF-8", "timezone": "Asia/Kolkata", "user_variation": False}) - - def test_unsupported_user_variation_is_not_passed(self): - a, b = self.observation("A"), self.observation("B") - b["user_variation"] = "passed" - with self.assertRaisesRegex(ValueError, "user"): - recipe.validate_pair_observations(a, b, {"locale": "en_US.UTF-8", "timezone": "Asia/Kolkata", "user_variation": False}) - - def test_incorrect_observed_concurrency_and_umask(self): - for field, value in (("jobs", 4), ("umask", "022")): - a, b = self.observation("A"), self.observation("B") - b[field] = value - with self.subTest(field=field), self.assertRaisesRegex(ValueError, field): - recipe.validate_pair_observations(a, b, {"locale": "en_US.UTF-8", "timezone": "Asia/Kolkata", "user_variation": False}) - - def test_official_selector_refuses_pending(self): - with self.assertRaisesRegex(ValueError, "qualification pending"): - recipe.qualified_selection(recipe.artifact_selection("trusted-server-fastly.wasm")) - -class RecipeEvidenceTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(recipe, "canonical Rust recipe is missing") - - def test_separate_cargo_stdout_and_diagnostic_stderr(self): - with tempfile.TemporaryDirectory() as directory: - stdout, stderr = recipe.capture([sys.executable, "-I", "-c", "import sys;sys.stdout.write('json\\n');sys.stderr.write('diagnostic\\n')"], - {}, Path(directory), Path(directory) / "cargo", timeout=10) - self.assertEqual(stdout, b"json\n") - self.assertEqual(stderr, b"diagnostic\n") - self.assertEqual((Path(directory) / "cargo.stdout").read_bytes(), stdout) - - def test_capture_bound_rejects_output_without_hanging(self): - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex(ValueError, "bound"): - recipe.capture([sys.executable, "-I", "-c", "print('x'*2000)"], {}, Path(directory), Path(directory) / "cargo", timeout=10, maximum=1000) - - def test_mutated_material_tree_rejected(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "material").write_text("original") - expected = recipe.tree_digest(root) - (root / "material").write_text("mutated") - with self.assertRaisesRegex(ValueError, "material"): - recipe.check_tree(root, expected) - - def test_duplicate_or_escaped_cargo_output_rejected(self): - import json - artifact = recipe.artifact_selection("ts-x86_64-unknown-linux-gnu") - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - final = root / "x86_64-unknown-linux-gnu/release/ts" - final.parent.mkdir(parents=True) - final.write_bytes(b"fixture") - event = {"reason": "compiler-artifact", "package_id": "path+file:///source/crates/trusted-server-cli#0.1.0", "target": {"name": "ts", "kind": ["bin"]}, "features": [], "profile": {"test": False, "opt_level": "3", "debuginfo": 1}, "executable": str(final)} - stream = json.dumps(event).encode() + b"\n" - self.assertEqual(recipe.final_cargo_artifact(stream, artifact, root), final) - with self.assertRaisesRegex(ValueError, "one"): - recipe.final_cargo_artifact(stream + stream, artifact, root) - event["executable"] = str(root / "other") - with self.assertRaisesRegex(ValueError, "output"): - recipe.final_cargo_artifact(json.dumps(event).encode() + b"\n", artifact, root) - - def test_git_index_cannot_supply_commands(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - lock = b'version = 4\n[[package]]\nname="example"\nversion="1.0.0"\nsource="' + recipe.CARGO_REGISTRY_SOURCE.encode() + b'"\nchecksum="' + b'a' * 64 + b'"\n' - with self.assertRaisesRegex(ValueError, "index"): - recipe.verify_cargo_materials(lock, {"schema_version": 1, "registry": [], "git": [], "command": "echo example"}, root) - -class BoundaryContextTests(unittest.TestCase): - def test_linux_same_boundary_maps_cwd_environment_and_readonly_source(self): - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - work = root / "work" - work.mkdir() - source = work / "source" - source.mkdir() - tools = root / "tools" - tools.mkdir() - boundary = LinuxBoundary(work, [tools], "example/builder@sha256:" + "a" * 64, immutable=[source]) - command = boundary.command([str(tools / "cargo"), "build"], environment={"HOME": str(work / "home"), "RUSTFLAGS": "--remap-path-prefix=" + str(source) + "=/source"}, cwd=source) - self.assertIn("/work/source", command) - self.assertIn("HOME=/work/home", command) - self.assertIn("RUSTFLAGS=--remap-path-prefix=/work/source=/source", command) - self.assertIn(f"type=bind,src={source},dst=/work/source,readonly", command) - self.assertIn("--network", command) - self.assertEqual(command[command.index("--network") + 1], "none") - -class ProductionPathTests(unittest.TestCase): - def setUp(self): - self.assertIsNotNone(recipe, "canonical Rust recipe is missing") - - def test_private_candidate_selection_rejects_missing_authentication(self): - with self.assertRaisesRegex(ValueError, "candidate acquisition"): - recipe.candidate_selection("ts-x86_64-unknown-linux-gnu", "a" * 40) - - def test_recipe_vendor_config_matches_actual_boundary_namespace(self): - import tomllib - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary, MacBoundary - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - vendor = root / "vendor" - vendor.mkdir() - configuration = root / "cargo-release.toml" - configuration.touch() - linux = LinuxBoundary(root, [], "example/builder@sha256:" + "a" * 64, immutable=[vendor, configuration]) - lock = b'version=4\n[[package]]\nname="example"\nversion="1.0"\n' - recipe.write_vendor_configuration(lock, vendor, configuration, linux) - configured = tomllib.loads(configuration.read_text())["source"]["release-vendor"]["directory"] - self.assertEqual(configured, "/work/vendor") - argv = linux.command(["/selected/cargo", "build", "--config", str(configuration)]) - self.assertEqual(argv[-1], "/work/cargo-release.toml") - self.assertIn(f"type=bind,src={vendor},dst={configured},readonly", argv) - if sys.platform == "darwin": - mac = MacBoundary(root, [], immutable=[vendor, configuration]) - recipe.write_vendor_configuration(lock, vendor, configuration, mac) - self.assertEqual(tomllib.loads(configuration.read_text())["source"]["release-vendor"]["directory"], str(vendor)) - - def test_generated_config_is_source_owned_not_cargo_stdout(self): - lock = b'version=4\n[[package]]\nname="example"\nversion="1.0"\nsource="' + recipe.CARGO_REGISTRY_SOURCE.encode() + b'"\nchecksum="' + b'a' * 64 + b'"\n' - data = recipe.vendor_configuration(lock, Path("/private/vendor")) - import tomllib - config = tomllib.loads(data.decode()) - self.assertEqual(config["source"]["crates-io"]["replace-with"], "release-vendor") - self.assertEqual(config["source"]["release-vendor"]["directory"], "/private/vendor") - self.assertTrue(config["net"]["offline"]) - - def test_fastly_pair_requires_complete_equality(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - a, b = root / "A", root / "B" - a.write_bytes(b"example A") - b.write_bytes(b"example B") - with self.assertRaisesRegex(ValueError, "Fastly.*differ"): - recipe.compare_final_bytes(a, b, recipe.artifact_selection("trusted-server-fastly.wasm")) - measurement = recipe.compare_final_bytes(a, b, recipe.artifact_selection("ts-x86_64-unknown-linux-gnu")) - self.assertFalse(measurement["equal"]) - self.assertFalse(measurement["blocking"]) - -class ActualLocalCargoTests(unittest.TestCase): - @unittest.skipUnless(os.environ.get("TS_RELEASE_BOUNDARY_TESTS") == "1" and sys.platform == "darwin", "explicit actual local macOS Cargo confinement fixture only") - def test_actual_cargo_build_script_download_is_denied_in_compile_boundary(self): - self.assertIsNotNone(recipe, "canonical Rust recipe is missing") - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import MacBoundary - toolchain = Path.home() / ".rustup/toolchains" / ("1.95.0-" + ("aarch64" if platform.machine() == "arm64" else platform.machine()) + "-apple-darwin") - self.assertTrue(toolchain.is_dir(), "explicit local Rust fixture toolchain is required") - with tempfile.TemporaryDirectory() as directory: - workspace = Path(directory).resolve() - fixture = recipe.prepare_download_fixture(workspace) - for name in ("home", "cargo-home", "tmp"): - (workspace / name).mkdir() - readonly = [Path("/bin"), Path("/usr/bin"), Path("/usr/lib"), Path("/System"), - Path("/Library/Developer/CommandLineTools"), Path(sys.base_prefix), toolchain, Path("/private/etc/ssl/openssl.cnf")] - ssl_configuration = Path("/private/etc/ssl/openssl.cnf") - ssl_before = recipe.tool_file_sha256(ssl_configuration) - boundary = MacBoundary(workspace, readonly, immutable=[fixture]) - tools = {"cargo": str(toolchain / "bin/cargo"), "rustc": str(toolchain / "bin/rustc"), "python": str(Path(sys.executable).resolve())} - environment = {"HOME": str(workspace / "home"), "CARGO_HOME": str(workspace / "cargo-home"), - "TMPDIR": str(workspace / "tmp"), "PATH": str(toolchain / "bin") + ":/usr/bin:/bin", - "RUSTC": tools["rustc"], "CARGO_BUILD_JOBS": "4", "CC": "/Library/Developer/CommandLineTools/usr/bin/clang", - "CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER": "/Library/Developer/CommandLineTools/usr/bin/clang", - "DEVELOPER_DIR": "/Library/Developer/CommandLineTools", "LANG": "C", "LC_ALL": "C", "TZ": "UTC", - "SDKROOT": "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk", "MACOSX_DEPLOYMENT_TARGET": "13.0"} - observed = recipe.verify_download_fixture(boundary, workspace, tools, environment) - self.assertEqual(observed["unconfined"]["outcome"], "allowed") - self.assertEqual(observed["confined"]["outcome"], "denied") - self.assertIn(observed["confined"]["errno"], (1, 13)) - self.assertTrue(observed["actual_build_script"]) - self.assertEqual(observed["unconfined"]["jobs"], 4) - self.assertEqual(observed["confined"]["jobs"], 4) - self.assertEqual(recipe.tool_file_sha256(ssl_configuration), ssl_before) - (workspace / "local-fixture-inputs.json").write_bytes(recipe.deterministic_json_bytes({"purpose": "unqualified-local-confinement-fixture", "openssl_config_sha256": ssl_before, "boundary_sha256": boundary.sha256})) - -class RecordedContractTests(unittest.TestCase): - def test_catalog_records_source_owned_ambient_roles(self): - import preflight - self.assertEqual(preflight.recipe_settings()["ambient_profiles"], recipe.PROFILES) - - def test_every_executable_helper_is_in_qualified_closure(self): - sys.path.insert(0, str(RELEASE / "qualification")) - import qualify - self.assertIn("scripts/release/rust_recipe.py", qualify.RUST_CLOSURE) - -class EffectiveSettingsTests(unittest.TestCase): - def test_linux_execution_umask_applies_inside_container(self): - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - private, tools = root / "private", root / "tools" - private.mkdir() - tools.mkdir() - boundary = LinuxBoundary(private, [tools], "example/builder@sha256:" + "a" * 64, - python=tools / "python", execution_umask=0o027) - command = boundary.command([str(tools / "cargo"), "build"]) - image = command.index(boundary.image) - self.assertEqual(command[image + 1], str(tools / "python")) - self.assertIn("os.umask(23)", command[image + 5]) - self.assertEqual(command[-2:], [str(tools / "cargo"), "build"]) - - -class FinalSnapshotTests(unittest.TestCase): - def test_final_snapshot_retains_exact_bytes_and_checks_mutation(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - selected = root / "cargo-output" - selected.write_bytes(b"selected final example") - sealed = root / "accepted-final" - sealed.mkdir() - final = recipe.snapshot_final(selected, sealed, "ts") - selected.write_bytes(b"changed cargo output") - self.assertEqual(final.read_bytes(), b"selected final example") - with self.assertRaisesRegex(ValueError, "fresh"): - recipe.snapshot_final(selected, sealed, "ts") - - def test_whitespace_cannot_inject_compiler_path_flags(self): - with tempfile.TemporaryDirectory() as directory: - with self.assertRaisesRegex(ValueError, "path"): - recipe.fresh_root(Path(directory).resolve() / "material -Dexample") - -class BoundedBoundaryProcessTests(unittest.TestCase): - @unittest.skipUnless(os.name == "posix", "process-group cleanup uses POSIX semantics") - def test_timeout_kills_child_holding_pipes_after_leader_exits(self): - from boundaries import bounded_process - with tempfile.TemporaryDirectory() as directory: - identity = Path(directory) / "fixture-pids" - script = "import os,subprocess,sys;from pathlib import Path;child=subprocess.Popen([sys.executable,'-I','-c','import time;time.sleep(30)']);Path(sys.argv[1]).write_text(str(os.getpid())+' '+str(child.pid))" - leader = child = None - def alive(pid): - try: - os.kill(pid, 0) - return True - except ProcessLookupError: - return False - try: - started = time.monotonic() - with self.assertRaisesRegex(ValueError, "timeout"): - bounded_process([sys.executable, "-I", "-B", "-c", script, str(identity)], {}, timeout=0.5) - self.assertLess(time.monotonic() - started, 3, "timeout should remain bounded") - leader, child = map(int, identity.read_text().split()) - self.assertFalse(alive(leader), "direct child should be reaped") - deadline = time.monotonic() + 2 - while alive(child) and time.monotonic() < deadline: - time.sleep(0.01) - self.assertFalse(alive(child), "descendant holding pipes should terminate with its process group") - finally: - if identity.exists() and child is None: - leader, child = map(int, identity.read_text().split()) - if child is not None and alive(child): - self.assertEqual(os.getpgid(child), leader, "cleanup should target only this fixture's process group") - os.killpg(leader, signal.SIGKILL) - deadline = time.monotonic() + 5 - while alive(child) and time.monotonic() < deadline: - time.sleep(0.01) - self.assertFalse(alive(child), "fixture cleanup should leave no descendant running") - - def test_control_and_cleanup_capture_stop_live_output_overflow(self): - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import bounded_process - with self.assertRaisesRegex(ValueError, "output bound"): - bounded_process([sys.executable, "-I", "-c", "import sys,time;sys.stdout.write('x'*100000);sys.stdout.flush();time.sleep(30)"], {}, maximum=1024, timeout=5) - - -def native_fixture_source(root): - source = root / "source" - manifest = source / "crates/trusted-server-cli/Cargo.toml" - manifest.parent.mkdir(parents=True) - (source / "Cargo.toml").write_text('[workspace.package]\nversion="1.2.3"\n') - manifest.write_text('[package]\nname="trusted-server-cli"\nversion={workspace=true}\n') - scaffold = b'[publisher]\ndomain="publisher.example.com"\n' - (source / "trusted-server.example.toml").write_bytes(scaffold) - return source, scaffold - - -class NativeCommandContractTests(unittest.TestCase): - def test_source_owned_version_and_exact_embedded_scaffold_are_required(self): - with tempfile.TemporaryDirectory() as directory: - source, scaffold = native_fixture_source(Path(directory)) - contract = recipe.native_cli_contract(source, "a" * 40) - version = b"ts 1.2.3 (" + b"a" * 40 + b")\n" - help_output = b"Trusted Server CLI\nUsage: ts \n" - initialized = b"Initialized config at /work/output/trusted-server.toml\n" - result = recipe.native_cli_outcomes(contract, help_output, version, initialized, scaffold) - self.assertEqual(set(result), {"help", "version", "config_init"}) - self.assertTrue(all(command["exit_code"] == 0 for command in result.values())) - damaged = ((help_output, b"", initialized, scaffold), - (help_output, version.replace(b"a" * 40, b"b" * 40), initialized, scaffold), - (help_output, version.replace(b"1.2.3", b"1.2.4"), initialized, scaffold), - (help_output, version, b"", scaffold), - (help_output, version, initialized, b""), - (help_output, version, initialized, scaffold.replace(b"publisher", b"other"))) - for outputs in damaged: - with self.subTest(outputs=outputs), self.assertRaisesRegex(ValueError, "native.*version|native.*scaffold|native.*config"): - recipe.native_cli_outcomes(contract, *outputs) - with self.assertRaisesRegex(ValueError, "source"): - recipe.native_cli_contract(source, "unknown") - (source / "trusted-server.example.toml").unlink() - with self.assertRaises((ValueError, OSError)): - recipe.native_cli_contract(source, "a" * 40) - - -class LinuxLifecycleTests(unittest.TestCase): - def test_map_path_does_not_rewrite_sibling_prefix(self): - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - private, tools = root / "build", root / "build-tools" - private.mkdir() - tools.mkdir() - boundary = LinuxBoundary(private, [tools], "example/builder@sha256:" + "a" * 64) - self.assertEqual(boundary.map_path(tools / "cargo"), str(tools / "cargo")) - self.assertEqual(boundary.map_path("--remap-path-prefix=" + str(private / "source") + "=/source"), "--remap-path-prefix=/work/source=/source") - - def test_container_cleanup_identity_is_source_owned_unique_and_closed(self): - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - boundary = LinuxBoundary(root, [], "example/builder@sha256:" + "a" * 64) - one, two = boundary.command(["/usr/bin/true"]), boundary.command(["/usr/bin/true"]) - self.assertIn("--name", one) - self.assertNotEqual(one[one.index("--name") + 1], two[two.index("--name") + 1]) - self.assertEqual(boundary.cleanup_command(one)[-1], one[one.index("--name") + 1]) - changed = list(one) - changed[changed.index("--name") + 1] = "unselected-container" - with self.assertRaisesRegex(ValueError, "identity"): - boundary.cleanup_command(changed) - - def test_docker_context_is_explicit_and_empty_for_run_and_cleanup(self): - sys.path.insert(0, str(RELEASE / "qualification")) - from boundaries import LinuxBoundary - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - boundary = LinuxBoundary(root, [], "example/builder@sha256:" + "a" * 64) - argv = boundary.command(["/usr/bin/true"]) - prefix = ["/usr/bin/docker", "--host", "unix:///var/run/docker.sock", "--config", str(root / "docker-config")] - self.assertEqual(argv[:5], prefix) - self.assertEqual(boundary.cleanup_command(argv)[:5], prefix) - self.assertEqual((root / "docker-config/config.json").read_bytes(), b"{}\n") - self.assertIn(f"type=bind,src={root / 'docker-config'},dst=/work/docker-config,readonly", argv) - - def test_native_container_startup_uses_same_closed_cleanup_lifecycle(self): - from unittest import mock - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - final = root / "final" - final.write_bytes(b"example native fixture") - _, scaffold = native_fixture_source(root) - selection = {"source_commit": "a" * 40, "runtime": {name: "example/runtime@sha256:" + "a" * 64 for name in ("ubi9-x86_64", "ubuntu22.04-x86_64", "debian12-x86_64")}} - commands = b'Usage: ts\nTS_RELEASE_VERSION\nts 1.2.3 (' + b'a' * 40 + b')\nTS_RELEASE_INIT\nInitialized config at /work/output/trusted-server.toml\nTS_RELEASE_SCAFFOLD\n' + scaffold - outputs = [(b'ID=debian\nVERSION_ID="12"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\n' + commands, b""), - (b'ID=rhel\nVERSION_ID="9.7"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\n' + commands, b""), - (b'ID=ubuntu\nVERSION_ID="22.04"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\n' + commands, b"")] - with mock.patch.object(recipe, "capture", side_effect=outputs) as captured: - observed = recipe._native_startup(final, recipe.artifact_selection("ts-x86_64-unknown-linux-gnu"), selection, root, {}) - self.assertEqual(observed["runtime"][0]["os_version"], "12") - self.assertEqual(observed["runtime"][0]["architecture"], "x86_64") - self.assertEqual(observed["runtime"][0]["executable_sha256"], recipe.sha256_bytes(final.read_bytes())) - self.assertEqual(captured.call_count, 3) - self.assertEqual(set(observed["runtime"][0]["commands"]), {"help", "version", "config_init"}) - for call in captured.call_args_list: - self.assertIn("--name", call.args[0]) - self.assertIn("--config", call.args[0]) - self.assertIn("boundary", call.kwargs) - - def test_native_runtime_rejects_mismatched_os_and_architecture(self): - valid = b'ID=ubuntu\nVERSION_ID="22.04"\nTS_RELEASE_ARCH\nx86_64\nTS_RELEASE_HELP\nUsage: ts\n' - for changed in (valid.replace(b'22.04', b'24.04'), valid.replace(b'x86_64', b'aarch64'), - valid.replace(b'Usage: ts', b'healthy')): - with self.subTest(output=changed), self.assertRaisesRegex(ValueError, "runtime"): - recipe.native_runtime_identity(changed, "ubuntu22.04-x86_64") - - def test_fixed_uid_preparation_preserves_private_outer_root(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - private = root / "private" - private.mkdir(mode=0o700) - workspace = private / "role" - workspace.mkdir(mode=0o700) - writable = workspace / "target" - writable.mkdir(mode=0o700) - source = workspace / "source" - source.mkdir(mode=0o700) - (source / "input").write_text("example") - recipe.container_permissions(workspace, [writable], [source]) - self.assertEqual(private.stat().st_mode & 0o777, 0o700) - self.assertEqual(workspace.stat().st_mode & 0o777, 0o777) - self.assertEqual(writable.stat().st_mode & 0o777, 0o777) - self.assertEqual((source / "input").stat().st_mode & 0o777, 0o644) - - -class SafeLogTests(unittest.TestCase): - def test_capture_refuses_symlink_log_without_modifying_target(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - original = root / "original" - original.write_bytes(b"original example") - (root / "cargo.stdout").symlink_to(original) - with self.assertRaises((OSError, ValueError)): - recipe.capture([sys.executable, "-I", "-c", "print('changed')"], {}, root, root / "cargo", timeout=10) - self.assertEqual(original.read_bytes(), b"original example") - -class IsolatedQualificationImportTests(unittest.TestCase): - def test_direct_candidate_selection_uses_source_owned_boundary_in_fresh_python(self): - script = """ -import sys -sys.path.insert(0, sys.argv[1]) -import rust_recipe as recipe -names = {"python", "git", "cargo", "rustc", "cc", "cxx", "linker", "ar", "cmake", "shell", "docker"} -policy = {key: None for key in ("environment_id", "tools", "readonly", "trees", "tool_trees", "archive_pool", "cargo_seed", "rustup", "sdk", "variation", "image", "runtime")} -policy.update(environment_id="gnu-builder-x86_64", tools={name: {"path": "/example/selected-tool", "sha256": "a" * 64, "acquisition_sha256": "b" * 64} for name in names}) -recipe.CANDIDATE_ACQUISITIONS["gnu-builder-x86_64"] = policy -try: - recipe.candidate_selection("ts-x86_64-unknown-linux-gnu", "a" * 40) -except ValueError as error: - assert "actual source-owned Docker executable" in str(error), str(error) -else: - raise AssertionError("wrong Docker executable accepted") -import boundaries -assert recipe.LinuxBoundary is boundaries.LinuxBoundary -print("source-owned import closed") -""" - result = subprocess.run([sys.executable, "-I", "-B", "-c", script, str(RELEASE)], capture_output=True, timeout=30, check=False) - self.assertEqual(result.returncode, 0, result.stderr.decode()) - self.assertEqual(result.stdout.strip(), b"source-owned import closed") - - def test_preloaded_foreign_boundary_module_is_rejected(self): - script = """ -import sys, types -sys.path.insert(0, sys.argv[1]) -foreign = types.ModuleType("boundaries") -foreign.__file__ = "/example/foreign/boundaries.py" -sys.modules["boundaries"] = foreign -try: - import rust_recipe -except ValueError as error: - assert "source-owned qualification" in str(error), str(error) -else: - raise AssertionError("foreign boundary module accepted") -print("foreign module rejected") -""" - result = subprocess.run([sys.executable, "-I", "-B", "-c", script, str(RELEASE)], capture_output=True, timeout=30, check=False) - self.assertEqual(result.returncode, 0, result.stderr.decode()) - self.assertEqual(result.stdout.strip(), b"foreign module rejected") - - -class SourceContextTests(unittest.TestCase): - def test_actual_executing_source_commit_and_helpers_are_bound(self): - import subprocess - from test_prepare import source, git - names = ("common.py", "preflight.py", "prepare.py", "rust_recipe.py", "inspect-native.py", "validate-fastly.py", - "qualification/boundaries.py", "qualification/qualify.py", "qualification/socket_probe.py") - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() / "source" - commit = source(root, {"scripts/release/" + name: (RELEASE / name).read_bytes() for name in names}) - script = "import sys;from pathlib import Path;sys.path.insert(0,sys.argv[1]);import rust_recipe as r;r.executing_source_context(sys.argv[2]);print('bound')" - result = subprocess.run([sys.executable, "-I", "-B", "-c", script, str(root / "scripts/release"), commit], capture_output=True, timeout=30, check=False) - self.assertEqual(result.returncode, 0, result.stderr.decode()) - self.assertEqual(result.stdout.strip(), b"bound") - # A loaded helper cannot claim the later commit's changed bytes. - mutated = "import sys,subprocess;from pathlib import Path;sys.path.insert(0,sys.argv[1]);import rust_recipe as r;p=Path(sys.argv[1])/'common.py';p.write_text(p.read_text()+'\\n# Changed fixture helper\\n');root=Path(sys.argv[1]).parents[1];subprocess.run(['/usr/bin/git','-C',str(root),'add','.'],check=True);subprocess.run(['/usr/bin/git','-C',str(root),'commit','-qm','Change fixture helper'],check=True);commit=subprocess.check_output(['/usr/bin/git','-C',str(root),'rev-parse','HEAD']).decode().strip();r.executing_source_context(commit)" - result = subprocess.run([sys.executable, "-I", "-B", "-c", mutated, str(root / "scripts/release"), commit], capture_output=True, timeout=30, check=False) - self.assertNotEqual(result.returncode, 0) - self.assertIn(b"executing", result.stderr) - - def test_untracked_executing_helper_is_rejected(self): - import subprocess - from test_prepare import source - names = ("common.py", "preflight.py", "prepare.py", "rust_recipe.py", "inspect-native.py", "validate-fastly.py", - "qualification/boundaries.py", "qualification/qualify.py", "qualification/socket_probe.py") - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() / "source" - commit = source(root, {"scripts/release/" + name: (RELEASE / name).read_bytes() for name in names if name != "rust_recipe.py"}) - (root / "scripts/release/rust_recipe.py").write_bytes((RELEASE / "rust_recipe.py").read_bytes()) - script = "import sys;sys.path.insert(0,sys.argv[1]);import rust_recipe as r;r.executing_source_context(sys.argv[2])" - result = subprocess.run([sys.executable, "-I", "-B", "-c", script, str(root / "scripts/release"), commit], capture_output=True, timeout=30, check=False) - self.assertNotEqual(result.returncode, 0) - self.assertIn(b"unexpected source-only file", result.stderr) - - def test_wrong_commit_and_self_auth_json_are_rejected(self): - with self.assertRaisesRegex(ValueError, "source|Git|executing"): - recipe.executing_source_context("a" * 40) - with self.assertRaisesRegex(ValueError, "source commit"): - recipe.candidate_selection("trusted-server-fastly.wasm", {"approved": True, "commit": "a" * 40}) - - def test_acquisition_cannot_attest_a_different_docker_executable(self): - from unittest import mock - names = {"python", "git", "cargo", "rustc", "cc", "cxx", "linker", "ar", "cmake", "shell", "docker"} - policy = {key: None for key in ("environment_id", "tools", "readonly", "trees", "tool_trees", "archive_pool", "cargo_seed", "rustup", "sdk", "variation", "image", "runtime")} - policy.update(environment_id="gnu-builder-x86_64", tools={name: {"path": "/example/selected-tool", "sha256": "a" * 64, "acquisition_sha256": "b" * 64} for name in names}) - with mock.patch.dict(recipe.CANDIDATE_ACQUISITIONS, {"gnu-builder-x86_64": policy}): - with self.assertRaisesRegex(ValueError, "actual.*Docker|Docker.*executable"): - recipe.candidate_selection("ts-x86_64-unknown-linux-gnu", "a" * 40) - - def test_acquisition_policy_cannot_select_an_alternate_checkout(self): - from unittest import mock - with mock.patch.dict(recipe.CANDIDATE_ACQUISITIONS, {"gnu-builder-x86_64": {"source": "/alternate/example", "approved_commit": "a" * 40}}): - with self.assertRaisesRegex(ValueError, "acquisition.*policy"): - recipe.candidate_selection("trusted-server-fastly.wasm", "a" * 40) - -class ToolTreeTests(unittest.TestCase): - def test_tool_tree_preserves_authenticated_internal_alias(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - versions = root / "Framework/Versions" - (versions / "A").mkdir(parents=True) - (versions / "A/header.h").write_text("example header") - (versions / "Current").symlink_to("A", target_is_directory=True) - measured = recipe.tool_tree_digest(root, approved_roots=[root]) - (versions / "Current").unlink() - (versions / "Current").symlink_to("./A", target_is_directory=True) - self.assertNotEqual(recipe.tool_tree_digest(root, approved_roots=[root]), measured) - with self.assertRaisesRegex(ValueError, "link"): - recipe.tree_digest(root) - - def test_tool_tree_rejects_escape_cycle_and_unapproved_root(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - sdk, external = root / "sdk", root / "external" - sdk.mkdir() - external.mkdir() - (external / "header.h").write_text("example") - (sdk / "escape").symlink_to(external / "header.h") - with self.assertRaisesRegex(ValueError, "escape"): - recipe.tool_tree_digest(sdk, approved_roots=[sdk]) - (sdk / "escape").unlink() - (sdk / "a").symlink_to("b") - (sdk / "b").symlink_to("a") - with self.assertRaisesRegex(ValueError, "cycle|resolve"): - recipe.tool_tree_digest(sdk, approved_roots=[sdk]) - with self.assertRaisesRegex(ValueError, "root"): - recipe.tool_tree_digest(sdk, approved_roots=[external]) - -class CargoPreparationTests(unittest.TestCase): - def test_fresh_cargo_home_uses_verified_original_archives(self): - import hashlib - from test_prepare import archive - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - seed, pool, home = root / "seed", root / "pool", root / "cargo-home" - seed.mkdir() - pool.mkdir() - home.mkdir() - namespace = seed / "registry/index" / recipe.CARGO_REGISTRY_CACHE_NAME - namespace.mkdir(parents=True) - (namespace / "config.json").write_text('{"dl":"https://registry.example.com"}') - path = pool / "example.crate" - archive(path, [("example-1.0.0/Cargo.toml", b'[package]\nname="example"\nversion="1.0.0"\n')]) - checked = recipe.prepare.verify_archive(path, hashlib.sha256(path.read_bytes()).hexdigest(), algorithm="sha256", expected_root="example-1.0.0") - index = {"git": []} - archives = {("example", "1.0.0", recipe.CARGO_REGISTRY_SOURCE): checked} - recipe.prepare_cargo_home(seed, home, archives, index, pool) - self.assertEqual((home / "registry/cache" / recipe.CARGO_REGISTRY_CACHE_NAME / "example-1.0.0.crate").read_bytes(), path.read_bytes()) - self.assertFalse((home / "registry/src").exists()) - - def test_restored_sources_and_unknown_seed_channels_are_rejected(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - seed, home = root / "seed", root / "home" - seed.mkdir() - home.mkdir() - (seed / "registry/src").mkdir(parents=True) - with self.assertRaisesRegex(ValueError, "seed|source"): - recipe.prepare_cargo_home(seed, home, {}, {"git": []}, root) - -class SharedJsAuthorityTests(unittest.TestCase): - def test_self_consistent_inventory_does_not_authenticate_its_producer(self): - from types import SimpleNamespace - with tempfile.TemporaryDirectory() as directory: - root = Path(directory).resolve() - bundles = root / "bundles" - bundles.mkdir() - (bundles / "tsjs-core.js").write_bytes(b"example bundle") - inventory = root / "inventory.json" - inventory.write_bytes(recipe.deterministic_json_bytes({"source_commit": "a" * 40, "authenticated": True})) - request = SimpleNamespace(source_commit="a" * 40, js_dir=bundles, js_inventory=inventory) - with self.assertRaisesRegex(ValueError, "producer.*binding|binding.*pending"): - recipe.verified_shared_js_input(request) diff --git a/scripts/release/validate-fastly.py b/scripts/release/validate-fastly.py deleted file mode 100755 index 800ace43bf..0000000000 --- a/scripts/release/validate-fastly.py +++ /dev/null @@ -1,405 +0,0 @@ -#!/usr/bin/env python3 -"""Accept exact final core Wasm only after maintained validation and Viceroy. - -Runtime starts final bytes directly, performs publisher-origin/injection/script -requests and hashes before/after. It never invokes Cargo, Fastly or a Wasm rewrite. -""" -import argparse -import http.client -import http.server -import json -import logging -import os -import re -import signal -import socket -import subprocess -import sys -import threading -import time -import tomllib -from pathlib import Path - -sys.dont_write_bytecode = True -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from common import deterministic_json_bytes, sha256_bytes -from prepare import _regular_bytes - -MAX_EXECUTABLE = 536870912 -MAX_RESPONSE = 33554432 -MAX_REQUEST_SECONDS = 5 -MAX_RUNTIME_LOG = 16777216 -ORIGIN_MARKER = b"release-origin-example" -HTML = b"Release fixturerelease-origin-example" - - -def _leb(data, offset): - result = 0 - for index in range(5): - if offset >= len(data): - raise ValueError("truncated Wasm integer") - byte = data[offset] - offset += 1 - result |= (byte & 127) << (index * 7) - if byte < 128: - if result > 0xffffffff: - raise ValueError("Wasm integer overflow") - return result, offset - raise ValueError("Wasm integer bound exceeded") - - -def _name(data, offset): - size, offset = _leb(data, offset) - if size > 4096 or offset + size > len(data): - raise ValueError("Wasm name bound exceeded") - try: - return data[offset:offset + size].decode("utf-8"), offset + size - except UnicodeError as error: - raise ValueError("invalid Wasm name") from error - - -def core_wasm(data): - """Require core module framing and default Fastly/WASI function ABI. - - This bounded framing check supplements the selected maintained Wasmtime - validator; it deliberately does not claim to validate instruction semantics. - """ - if data[:8] != b"\0asm\x01\0\0\0": - raise ValueError("final core Wasm format required; components forbidden") - offset, imported, exports, seen = 8, [], [], set() - while offset < len(data): - identifier = data[offset] - size, start = _leb(data, offset + 1) - offset = start + size - if offset > len(data) or identifier > 12 or (identifier and identifier in seen): - raise ValueError("invalid or repeated Wasm section") - seen.add(identifier) - body = data[start:offset] - if identifier not in {2, 7}: - continue - count, cursor = _leb(body, 0) - if count > 10000: - raise ValueError("Wasm ABI member bound exceeded") - for _ in range(count): - module, cursor = _name(body, cursor) - if identifier == 2: - name, cursor = _name(body, cursor) - if cursor >= len(body) or body[cursor] != 0: - raise ValueError("Fastly ABI imports must be functions") - _, cursor = _leb(body, cursor + 1) - if module != "wasi_snapshot_preview1" and not re.fullmatch(r"fastly_[a-z_]+", module): - raise ValueError("unselected Fastly ABI import module") - imported.append((module, name)) - else: - if cursor >= len(body): - raise ValueError("truncated Wasm export ABI") - kind = body[cursor] - _, cursor = _leb(body, cursor + 1) - exports.append((module, kind)) - if cursor != len(body): - raise ValueError("Wasm ABI section trailing bytes") - if (("_start", 0) not in exports or not any(module.startswith("fastly_") for module, _ in imported) - or not any(module == "wasi_snapshot_preview1" for module, _ in imported)): - raise ValueError("default Fastly WASI request ABI required") - return {"format": "core-wasm", "abi": "wasip1-fastly-default", "imports": imported, "exports": exports} - - -def publisher_response(status, body, headers): - """Require origin marker, complete HTML and injected main script path.""" - if status != 200 or ORIGIN_MARKER not in body or "text/html" not in headers.get("content-type", ""): - raise ValueError("positive publisher-origin fixture failed") - if b"" not in body.lower() or b"" not in body.lower(): - raise ValueError("publisher response must retain complete HTML") - tags = re.findall(rb"]*>", body, re.I) - selected = [tag for tag in tags if re.search(rb'\bid=["\']trustedserver-js["\']', tag)] - if len(selected) != 1: - raise ValueError("publisher main script injection missing or repeated") - match = re.search(rb'\bsrc=["\']([^"\']+)["\']', selected[0]) - if match is None: - raise ValueError("publisher script injection src missing") - path = match[1].decode("ascii") - if not re.fullmatch(r"/static/tsjs=tsjs-unified\.min\.js\?v=[a-f0-9]{64}", path): - raise ValueError("publisher script injection path mismatch") - injected = re.search(re.escape(selected[0]) + rb"\s*", body, re.I) - if injected is None or body[:injected.start()] + body[injected.end():] != HTML: - raise ValueError("publisher response must preserve complete HTML origin fixture") - return path - - -def _port(): - with socket.socket() as selected: - selected.bind(("127.0.0.1", 0)) - return selected.getsockname()[1] - - -def _request(port, path, *, deadline=None, logs=None): - """Bound wall time as well as response bytes, including slow headers/body.""" - deadline = min(deadline if deadline is not None else time.monotonic() + MAX_REQUEST_SECONDS, - time.monotonic() + MAX_REQUEST_SECONDS) - connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2) - reply, active_socket = [], [] - def read(): - try: - connection.connect() - active_socket.append(connection.sock) - connection.request("GET", path, headers={"Host": "publisher.example.com"}) - response = connection.getresponse() - body = bytearray() - while True: - chunk = response.read1(65536) - if not chunk: - break - body.extend(chunk) - if len(body) > MAX_RESPONSE: - raise ValueError("runtime response bound exceeded") - reply.append((response.status, bytes(body), {key.lower(): value for key, value in response.getheaders()})) - except Exception as error: - reply.append(error) - finally: - connection.close() - worker = threading.Thread(target=read, daemon=True) - worker.start() - try: - while worker.is_alive(): - if logs is not None: - logs.check() - if time.monotonic() >= deadline: - raise ValueError("runtime request wall-time bound exceeded") - worker.join(timeout=min(0.05, deadline - time.monotonic())) - if not reply: - raise ValueError("runtime request did not produce an outcome") - if isinstance(reply[0], Exception): - raise reply[0] - return reply[0] - finally: - for selected in active_socket: - try: - selected.shutdown(socket.SHUT_RDWR) - except OSError: - pass - selected.close() - connection.close() - worker.join(timeout=1) - - -class RuntimeLogs: - """Drain bounded live runtime logs through parent-owned descriptors.""" - - def __init__(self, process, prefix, *, maximum=MAX_RUNTIME_LOG): - self.process, self.maximum = process, maximum - self.failure = threading.Event() - self.files, self.threads = [], [] - try: - for name in ("stdout", "stderr"): - output = Path(str(prefix) + "." + name).open("xb") - self.files.append(output) - thread = threading.Thread(target=self._drain, args=(getattr(process, name), output), daemon=True) - self.threads.append(thread) - thread.start() - except Exception: - self.close() - raise - - def _drain(self, stream, output): - total = 0 - try: - while chunk := os.read(stream.fileno(), 65536): - remaining = self.maximum - total - output.write(chunk[:remaining]) - output.flush() - total += len(chunk) - if total > self.maximum: - self.failure.set() - return - except (OSError, ValueError): - self.failure.set() - - def check(self): - if self.failure.is_set(): - raise ValueError("runtime log bound/drain failed") - - def close(self): - try: - os.killpg(self.process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - self.process.wait(timeout=5) - for thread in self.threads: - thread.join(timeout=5) - if thread.is_alive(): - raise ValueError("runtime log drain completion bound exceeded") - for stream in (self.process.stdout, self.process.stderr): - if stream is not None: - stream.close() - for output in self.files: - output.close() - - -def expected_javascript(source, js_dir): - """Derive exact core/always module bytes for the disabled-integration fixture.""" - registry = _regular_bytes(Path(source) / "crates/trusted-server-core/src/integrations/registry.rs", 33554432).decode() - match = re.search(r'const JS_ALWAYS: &\[&str\] = &\[([^]]*)\];', registry) - if match is None or match[1] != '"creative"': - raise ValueError("source-owned always-JS selection changed; review runtime fixture") - return b";\n".join(_regular_bytes(Path(js_dir) / ("tsjs-" + name + ".js"), MAX_RESPONSE) for name in ("core", "creative")) - - -def javascript_response(status, body, headers, script, expected): - """Require fetched exact prepared JS bytes and its actual injected version.""" - if status != 200 or not body or "javascript" not in headers.get("content-type", "") or body != expected: - raise ValueError("actual injected JavaScript bytes mismatch") - digest = sha256_bytes(expected) - if script != "/static/tsjs=tsjs-unified.min.js?v=" + digest: - raise ValueError("injected JavaScript version hash mismatch") - return digest - - -class _Origin(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.server.requests.append(self.path) - if len(self.server.requests) > 100: - self.server.failure.set() - self.send_error(507) - return - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(HTML))) - self.end_headers() - self.wfile.write(HTML) - - def log_message(self, format, *args): - return - - -def runtime_config(source, destination, origin_port, variant): - """Create fictional integrity-checked runtime configuration without a build.""" - config_root = Path(source) / "crates/trusted-server-integration-tests/fixtures/configs" - data = tomllib.loads(_regular_bytes(config_root / "trusted-server.integration.toml", 1048576).decode()) - data["publisher"].update(domain="publisher.example.com", cookie_domain="publisher.example.com", origin_url=f"http://127.0.0.1:{origin_port}") - data["response_headers"] = {"x-release-runtime": variant} - for integration in data["integrations"].values(): - integration["enabled"] = False - # This fixed fixture contains only integers, strings, booleans and arrays; - # Python's compact sorted JSON agrees with the selected EdgeZero canonical - # v1 rules for this subset. Floats must not silently gain another spelling. - def no_float(value): - if isinstance(value, float): - raise ValueError("runtime fixture canonical float unsupported") - if isinstance(value, dict): - for child in value.values(): - no_float(child) - elif isinstance(value, list): - for child in value: - no_float(child) - no_float(data) - canonical = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() - envelope = json.dumps({"data": data, "sha256": sha256_bytes(canonical), "version": 1, - "generated_at": "2026-01-01T00:00:00Z"}, ensure_ascii=False, separators=(",", ":")) - manifest = tomllib.loads(_regular_bytes(Path(source) / "edgezero.toml", 1048576).decode()) - store = manifest["stores"]["config"]["default"] - if not isinstance(store, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+", store) or "'''" in envelope: - raise ValueError("reviewed runtime store identity changed") - template = _regular_bytes(config_root / "viceroy-template.toml", 1048576).decode() - marker = "# GENERATED_TRUSTED_SERVER_CONFIG_STORES" - if template.count(marker) != 1: - raise ValueError("runtime config marker mismatch") - block = f"[local_server.config_stores.{store}]\nformat = \"inline-toml\"\n[local_server.config_stores.{store}.contents]\n{store} = '''{envelope}'''" - destination.write_text(template.replace(marker, block)) - # Parse the exact generated TOML before invoking the runtime. - tomllib.loads(destination.read_text()) - return sha256_bytes(destination.read_bytes()) - - -def publisher_runtime(wasm, source, workspace, viceroy, environment, expected): - """Exercise publisher/injection/JS exact bytes; this alone is no acceptance. - - A local development-artifact diagnostic may call this with explicitly - unqualified tools/bytes. Official acceptance calls it only after maintained - validation and authenticated source/input/tool selection. - """ - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Origin) - server.requests, server.failure = [], threading.Event() - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - observations = [] - try: - for variant in ("A", "B"): - origin_start = len(server.requests) - configuration = workspace / f"viceroy-{variant}.toml" - config_hash = runtime_config(source, configuration, server.server_port, variant) - port = _port() - process = subprocess.Popen([str(viceroy), str(wasm), "-C", str(configuration), "--addr", f"127.0.0.1:{port}"], - env=environment, cwd=workspace, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - close_fds=True, start_new_session=True) - logs = RuntimeLogs(process, workspace / f"viceroy-{variant}") - try: - deadline = time.monotonic() + 60 - while True: - logs.check() - if process.poll() is not None: - raise ValueError("exact Wasm Viceroy exits before publisher fixture") - try: - status, html, headers = _request(port, "/release-fixture", deadline=deadline, logs=logs) - break - except (OSError, http.client.HTTPException): - if time.monotonic() >= deadline: - raise ValueError("exact Wasm publisher runtime timeout") - time.sleep(0.05) - script = publisher_response(status, html, headers) - if headers.get("x-release-runtime") != variant: - raise ValueError("runtime config cannot change independently of build") - status, javascript, script_headers = _request(port, script, deadline=deadline, logs=logs) - js_hash = javascript_response(status, javascript, script_headers, script, expected) - origin = server.requests[origin_start:] - if "/release-fixture" not in origin or server.failure.is_set(): - raise ValueError("publisher variant did not actually reach bounded origin fixture") - observations.append({"variant": variant, "publisher_status": 200, "script_status": 200, - "html_sha256": sha256_bytes(html), "javascript_sha256": js_hash, - "config_sha256": config_hash, "script_path": script, "origin_requests": origin}) - finally: - logs.close() - logs.check() - finally: - server.shutdown() - server.server_close() - thread.join(timeout=5) - if observations[0]["config_sha256"] == observations[1]["config_sha256"]: - raise ValueError("independent runtime config variants did not change") - return observations - - -def validate_final(wasm, source, workspace, tools, environment, js_dir): - """Accept unchanged final bytes after maintained validation and actual runtime.""" - from rust_recipe import capture - wasm = Path(wasm).resolve(strict=True) - workspace = Path(workspace).resolve(strict=True) - before = sha256_bytes(_regular_bytes(wasm, MAX_EXECUTABLE)) - abi = core_wasm(_regular_bytes(wasm, MAX_EXECUTABLE)) - if Path(source).resolve() == workspace or workspace.is_relative_to(Path(source).resolve()): - raise ValueError("runtime fixture must run outside checkout") - expected = expected_javascript(source, js_dir) - capture([tools["wasmtime"], "compile", str(wasm), "--output", str(workspace / "validated.cwasm")], - environment, workspace, workspace / "wasmtime", timeout=300) - observations = publisher_runtime(wasm, source, workspace, tools["viceroy"], environment, expected) - after = sha256_bytes(_regular_bytes(wasm, MAX_EXECUTABLE)) - if before != after: - raise ValueError("final Wasm changed during runtime acceptance") - result = {"sha256_before": before, "sha256_after": after, "abi": abi, "runtime": observations, - "outside_checkout": True, "exact_final_bytes": True, "maintained_validation": True} - (workspace / "runtime-outcome.json").write_bytes(deterministic_json_bytes(result)) - return result - - -def main(): - parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) - parser.add_argument("--wasm", required=True, type=Path) - parser.parse_args() - raise ValueError("release qualification pending: authenticated Wasmtime/Viceroy selection required") - - -if __name__ == "__main__": - try: - main() - except (ValueError, OSError, subprocess.SubprocessError) as error: - logging.error("Final Fastly acceptance refuses: %s", error) - sys.exit(1) From 6a07ccc03278af1a3e07b30e25a1cd46efb2f57b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 14 Oct 2026 23:17:09 +0530 Subject: [PATCH 040/153] Align crate documentation with release build guards --- crates/trusted-server-cli/README.md | 5 ++- crates/trusted-server-js/README.md | 52 ++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-cli/README.md b/crates/trusted-server-cli/README.md index f3aa9f925c..dbff696426 100644 --- a/crates/trusted-server-cli/README.md +++ b/crates/trusted-server-cli/README.md @@ -22,7 +22,10 @@ two-platform command inventory and the [EdgeZero guide](../../docs/guide/edgezero.md) for configuration lifecycle and store semantics. -`ts --version` reports the CLI package version and compiled source identity. +`ts --version` labels four compiled facts: +`ts package= source= target= variant=default`. +The external release tag belongs to the signed release index and is distinct +from the owning Cargo package version. Release builds set `TRUSTED_SERVER__RELEASE_BUILD=1` and require `TRUSTED_SERVER__GIT_VERSION` to contain exactly 40 lowercase hexadecimal commit characters. Any other present release control fails the build. Without that diff --git a/crates/trusted-server-js/README.md b/crates/trusted-server-js/README.md index 574daaa6e2..64793da1ef 100644 --- a/crates/trusted-server-js/README.md +++ b/crates/trusted-server-js/README.md @@ -14,11 +14,15 @@ built separately and is not the deferred Trusted Server Prebid shim. With `TRUSTED_SERVER__RELEASE_BUILD=1`, Rust builds require `TRUSTED_SERVER__GIT_VERSION` as the approved 40-character lowercase commit, -plus absolute `TSJS_PREBUILT_DIR` and `TSJS_PREBUILT_INVENTORY` paths. They run -no npm. Any presence of `TSJS_SKIP_BUILD` fails in all modes. Development -still supports fresh Node builds and prebuilt bundles without an inventory; -when an inventory is supplied, its bytes are checked, and an explicitly -supplied source must match. +plus absolute `TSJS_PREBUILT_DIR` and +`TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY` paths. They run no npm. Any presence +of `TSJS_SKIP_BUILD` fails in Rust Cargo builds, including empty, `0` and `1`; +Node development builds and Vitest allow it when release control is absent. +Development still supports fresh Node builds and prebuilt bundles without an +inventory. When an inventory is supplied, its source commit syntax and bytes +are checked; matching the approved source is required only in release mode. +The development override/tag/branch display label is not compared with the +inventory commit. The version-1 inventory has exactly `schema_version`, `source_commit`, `modules`, `tools`, and `materials`. Each module has exactly `id`, `filename`, @@ -26,15 +30,19 @@ The version-1 inventory has exactly `schema_version`, `source_commit`, must match the discovered set; filenames are `tsjs-.js`, sizes are positive integers, and digests are lowercase SHA-256. The inventory must be outside the bundle directory, is capped at 1 MiB, and uses strict UTF-8 JSON with -unique keys, finite numbers, and at most 64 container levels. Producers must -honor the 32 MiB input limit per module. The required contribution, tool, and +unique keys, finite numbers, and at most 64 container levels. Integer-valued +numbers, including exponent notation and numbers inside opaque arrays, must +stay within the JavaScript safe-integer range; lexical integer overflow also +fails. Producers must honor the 32 MiB input limit per module. The required contribution, tool, and material arrays contain strictly parsed, opaque JSON here; composition validation belongs to the release producer. -Copying checks the exact source set through held file handles, rejects links -and nonregular files, and checks source identities and private copied bytes -before generating Rust. Cargo build-script messages expose -`TSJS_INPUT_INVENTORY_SHA256` through `cargo:rustc-env`, hashing the exact raw +Inventory-backed copying checks the exact source set through held file handles, +rejects links, hard links and nonregular files, and checks source identities and +private copied bytes before generating Rust. Inventory-absent development +keeps the existing prebuilt path, including unrelated non-bundle extras and +supported directory/file aliases. Cargo build-script messages expose +`TRUSTED_SERVER__TSJS_INVENTORY_SHA256` through `cargo:rustc-env`, hashing the exact raw inventory bytes for later build evidence. These are prepared input checks; they do not establish which modules survive linking into a final executable. @@ -48,7 +56,27 @@ npm run format npm run build ``` -Rust target checks include this crate through `cargo test-fastly`. See +The default development suite skips only the two slow Git collector fixtures. +Run the full registered suite and type checks, as CI does, with +`TRUSTED_SERVER__RELEASE_JS_TESTS=1 npx vitest run`. Both future release JS A/B +test processes must receive that opt-in unconditionally. + +`cargo test-fastly` covers WASM tests but selects zero native prebuilt tests. +Run those checks from the repository root with the actual host target: + +```bash +set -eu +HOST_TARGET=$(rustc -vV | sed -n 's/^host: //p') +test -n "$HOST_TARGET" +TEST_LIST=$(mktemp) +trap 'rm -f "$TEST_LIST"' EXIT +cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt -- --list > "$TEST_LIST" +awk '/: test$/ { count++ } END { if (count == 0) { print "No native prebuilt tests selected"; exit 1 } }' "$TEST_LIST" +cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt +cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features -- -D warnings +``` + +These source-built tests do not establish release qualification. See [Trusted Server JavaScript](../../docs/guide/tsjs.md) for the 12 integration entries, 13 emitted bundles, and immediate, deferred, and standalone loading modes. From 90a87c35ac73c1f1a137740c48df5af41cc7df90 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 09:06:08 +0530 Subject: [PATCH 041/153] Define fixed Rust release inputs and preflight policy Compile the phase-one cohort contract, strictly parse the tracked cohort, tool and consumer policy inputs, and keep production tool selection closed behind uninhabited authenticated acquisition receipts. Audit the inherited environment before clearing it and build allowlisted child environments. --- .../trusted-server-release/src/documents.rs | 22 +- crates/trusted-server-release/src/error.rs | 8 +- crates/trusted-server-release/src/main.rs | 7 +- crates/trusted-server-release/src/policy.rs | 547 ++++++++++++++++++ .../src/policy/tests.rs | 546 +++++++++++++++++ .../src/policy/tools.rs | 487 ++++++++++++++++ .../trusted-server-release/src/preflight.rs | 278 +++++++++ .../src/preflight/tests.rs | 418 +++++++++++++ 8 files changed, 2303 insertions(+), 10 deletions(-) create mode 100644 crates/trusted-server-release/src/policy.rs create mode 100644 crates/trusted-server-release/src/policy/tests.rs create mode 100644 crates/trusted-server-release/src/policy/tools.rs create mode 100644 crates/trusted-server-release/src/preflight.rs create mode 100644 crates/trusted-server-release/src/preflight/tests.rs diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs index 42a10e14b8..5b6c4948bc 100644 --- a/crates/trusted-server-release/src/documents.rs +++ b/crates/trusted-server-release/src/documents.rs @@ -16,8 +16,9 @@ use sha2::{Digest as _, Sha256}; use crate::error::ReleaseError; +/// Byte-limit class selected before a document is read or parsed. #[derive(Clone, Copy, Debug)] -enum Role { +pub(crate) enum Role { Metadata, Companion, Bundle, @@ -36,7 +37,8 @@ impl Role { } } -fn parse_json(bytes: &[u8], role: Role) -> Result> { +/// Parses bounded strict JSON with unique keys and finite numeric tokens. +pub(crate) fn parse_json(bytes: &[u8], role: Role) -> Result> { if bytes.len() as u64 > role.limit() { return Err(invalid("document exceeds role byte limit")); } @@ -53,12 +55,14 @@ fn parse_json(bytes: &[u8], role: Role) -> Result> { Ok(value) } -struct RawBytes { - bytes: Vec, - sha256: String, +/// Exact bytes read from one held descriptor and their SHA-256. +pub(crate) struct RawBytes { + pub(crate) bytes: Vec, + pub(crate) sha256: String, } -fn read_bounded(path: &Path, role: Role) -> Result> { +/// Reads one unlinked regular file without following a leaf symlink. +pub(crate) fn read_bounded(path: &Path, role: Role) -> Result> { let file = OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) @@ -187,7 +191,8 @@ impl Serialize for Canonical<'_> { } } -fn canonical_json(value: &Value, role: Role) -> Result, Report> { +/// Serializes JSON with sorted keys and a trailing newline. +pub(crate) fn canonical_json(value: &Value, role: Role) -> Result, Report> { let mut output = LimitedBytes { bytes: Vec::new(), limit: role.limit(), @@ -300,7 +305,8 @@ impl FileRecord { } } -fn lower_hex(value: &str, length: usize) -> bool { +/// Returns whether `value` is exactly `length` lowercase hexadecimal digits. +pub(crate) fn lower_hex(value: &str, length: usize) -> bool { value.len() == length && value .bytes() diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 7356392be7..3fac212fc8 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -1,4 +1,4 @@ -/// Reports a closed release execution gate or invalid document input. +/// Reports a closed release execution gate or invalid release input. #[derive(Debug, derive_more::Display)] pub(crate) enum ReleaseError { #[display( @@ -7,6 +7,12 @@ pub(crate) enum ReleaseError { Pending, #[display("Invalid release document")] Document, + #[display("Release input policy rejects the candidate inputs")] + Policy, + #[display("Inherited release environment is rejected")] + Environment, + #[display("Production release selection is not qualified")] + Unqualified, } 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 11a9ec52c7..8a3d1acfbf 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -1,7 +1,12 @@ -// Task 2 utilities remain private until Task 3 wires authenticated preflight. +// Document, policy and environment utilities stay unwired until source +// authentication lets preflight dispatch them. #[allow(dead_code)] mod documents; mod error; +#[allow(dead_code)] +mod policy; +#[allow(dead_code)] +mod preflight; use error::ReleaseError; use error_stack::Report; diff --git a/crates/trusted-server-release/src/policy.rs b/crates/trusted-server-release/src/policy.rs new file mode 100644 index 0000000000..5cf0b4f11f --- /dev/null +++ b/crates/trusted-server-release/src/policy.rs @@ -0,0 +1,547 @@ +//! Compiled-in release cohort contract and strict parsing of tracked release inputs. +//! +//! The JSON inputs are compared against this source-owned contract; they never +//! supply commands, packages, targets or authority. Pending or JSON-qualified +//! pins remain diagnostics until authenticated acquisition receipts exist. + +use std::collections::BTreeSet; +use std::path::Path; + +use error_stack::Report; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{Role, parse_json, read_bounded}; +use crate::error::ReleaseError; +use tools::{BUILDER_ENVIRONMENT, ToolsDocument, check_tools}; + +mod tools; + +/// Source-relative path of the tracked cohort selection. +pub(crate) const COHORT_PATH: &str = "scripts/release/release-cohort.json"; +/// Source-relative path of the tracked tool, environment and recipe pins. +pub(crate) const TOOLS_PATH: &str = "scripts/release/release-tools.json"; +/// Source-relative path of the tracked consumer release policy. +pub(crate) const CLI_POLICY_PATH: &str = "crates/trusted-server-cli/release-policy.json"; +/// Branches whose tips may supply release sources; candidates cannot extend it. +pub(crate) const ELIGIBLE_BRANCHES: [&str; 1] = ["main"]; + +const COHORT_ID: &str = "phase1-fastly-cli"; +const SCHEMA_VERSION: u64 = 1; +const GH_VERSION: &str = "2.97.0"; +const ATTEST_VERSION: &str = "4.2.2"; +const ATTEST_REVISION: &str = "1e69f48acb82d1966a394da916b4c1698aa569d6"; +const READONLY_ACTIONS: [&str; 1] = ["readonly-verify"]; +const CLI_POLICY_FIELDS: [&str; 13] = [ + "artifacts", + "bounds", + "cohort", + "github_host", + "historical_producer_policies", + "identity", + "policy_version", + "predicates", + "public_actions", + "repository", + "repository_id", + "repository_owner_id", + "tool_versions", +]; +const NOT_ESTABLISHED: [&str; 2] = [ + "authenticated acquisition receipts (Task 8)", + "remote branch-tip ancestry and ruleset validation (Task 14)", +]; + +/// Target triples the compiled cohort contract can build. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReleaseTarget { + Wasm32Wasip1, + Aarch64AppleDarwin, + X86_64AppleDarwin, + X86_64UnknownLinuxGnu, +} + +impl ReleaseTarget { + const ALL: [Self; 4] = [ + Self::Wasm32Wasip1, + Self::Aarch64AppleDarwin, + Self::X86_64AppleDarwin, + Self::X86_64UnknownLinuxGnu, + ]; + + /// Returns the Rust target triple. + pub(crate) fn triple(self) -> &'static str { + match self { + Self::Wasm32Wasip1 => "wasm32-wasip1", + Self::Aarch64AppleDarwin => "aarch64-apple-darwin", + Self::X86_64AppleDarwin => "x86_64-apple-darwin", + Self::X86_64UnknownLinuxGnu => "x86_64-unknown-linux-gnu", + } + } + + /// Returns whether the target needs the explicit macOS loader floor. + pub(crate) fn is_darwin(self) -> bool { + matches!(self, Self::Aarch64AppleDarwin | Self::X86_64AppleDarwin) + } + + fn from_triple(triple: &str) -> Option { + Self::ALL + .into_iter() + .find(|target| target.triple() == triple) + } +} + +/// One source-owned asset identity; JSON can only restate it. +pub(crate) struct FixedArtifact { + pub(crate) name: &'static str, + pub(crate) package: &'static str, + pub(crate) bin: &'static str, + pub(crate) target: ReleaseTarget, + kind: &'static str, + role: &'static str, + pub(crate) assurance_profile: &'static str, + capability: &'static str, + architecture: &'static str, + minimum_version: &'static str, + runtime: &'static str, + recipe_environment: &'static str, +} + +/// The complete phase-one cohort. Every asset uses default features only. +pub(crate) const FIXED_ARTIFACTS: [FixedArtifact; 4] = [ + FixedArtifact { + name: "trusted-server-fastly.wasm", + package: "trusted-server-adapter-fastly", + bin: "trusted-server-adapter-fastly", + target: ReleaseTarget::Wasm32Wasip1, + kind: "wasm-core", + role: "server", + assurance_profile: "provenance-sbom-repeatable", + capability: "fastly-production", + architecture: "wasm32", + minimum_version: "wasip1", + runtime: "fastly-compute", + recipe_environment: BUILDER_ENVIRONMENT, + }, + FixedArtifact { + name: "ts-aarch64-apple-darwin", + package: "trusted-server-cli", + bin: "ts", + target: ReleaseTarget::Aarch64AppleDarwin, + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capability: "cli-execute", + architecture: "aarch64", + minimum_version: "13.0", + runtime: "macos", + recipe_environment: "macos15-aarch64", + }, + FixedArtifact { + name: "ts-x86_64-apple-darwin", + package: "trusted-server-cli", + bin: "ts", + target: ReleaseTarget::X86_64AppleDarwin, + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capability: "cli-execute", + architecture: "x86_64", + minimum_version: "13.0", + runtime: "macos", + recipe_environment: "macos15-x86_64", + }, + FixedArtifact { + name: "ts-x86_64-unknown-linux-gnu", + package: "trusted-server-cli", + bin: "ts", + target: ReleaseTarget::X86_64UnknownLinuxGnu, + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capability: "cli-execute", + architecture: "x86_64", + minimum_version: "glibc-2.34", + runtime: "linux-gnu", + recipe_environment: BUILDER_ENVIRONMENT, + }, +]; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CohortDocument { + artifacts: Vec, + cohort: String, + eligible_branches: Vec, + schema_version: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CohortArtifact { + assurance_profile: String, + bin: String, + capabilities: Vec, + compatibility: Compatibility, + default_features: bool, + features: Vec, + kind: String, + name: String, + package: String, + role: String, + target: String, + variant: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Compatibility { + architecture: String, + minimum_version: String, + runtime: String, +} + +/// Raw-byte identity of one tracked input file. +#[derive(Clone)] +struct InputDigest { + path: &'static str, + sha256: String, +} + +/// Parsed tracked release inputs that match the compiled contract. +/// +/// This is not authorization: pins may still be pending or unauthenticated. +pub(crate) struct ReleaseInputs { + digests: [InputDigest; 3], + tools: ToolsDocument, +} + +impl ReleaseInputs { + /// Returns the source-relative path and raw SHA-256 of each input file. + pub(crate) fn input_digests(&self) -> [(&'static str, String); 3] { + self.digests + .clone() + .map(|digest| (digest.path, digest.sha256)) + } + + fn pending_items(&self) -> Vec { + self.tools.pending_items() + } +} + +/// An authenticated acquisition and measured-pin receipt. +/// +/// No receipt verifier exists yet (Task 8), so this type is uninhabited and +/// production selection cannot succeed. +pub(crate) enum AcquisitionReceipt {} + +/// Production tool selection; constructed only by [`select_qualified`]. +pub(crate) struct QualifiedSelection { + receipts: Vec, + inputs: [(&'static str, String); 3], +} + +impl QualifiedSelection { + /// Summarizes the selected inputs for the caller's diagnostics. + pub(crate) fn summary(&self) -> String { + format!( + "selected {} authenticated pins from {} inputs", + self.receipts.len(), + self.inputs.len() + ) + } +} + +/// Selects production pins only when every pin is qualified and authenticated. +/// +/// # Errors +/// +/// Fails on any pending qualification or null pin, and otherwise fails because +/// authenticated acquisition receipts cannot be constructed yet. +pub(crate) fn select_qualified( + inputs: &ReleaseInputs, + receipts: &[AcquisitionReceipt], +) -> Result> { + let pending = inputs.pending_items(); + if !pending.is_empty() { + return Err(Report::new(ReleaseError::Unqualified).attach(format!( + "{} release pins are pending or null; production selection requires every pin qualified: {}", + pending.len(), + pending.join("; ") + ))); + } + let verified: Vec = receipts.iter().map(|receipt| match *receipt {}).collect(); + if verified.is_empty() { + return Err(Report::new(ReleaseError::Unqualified).attach( + "qualified pins require authenticated acquisition receipts (Task 8); \ + production selection remains closed", + )); + } + Ok(QualifiedSelection { + receipts: verified, + inputs: inputs.input_digests(), + }) +} + +/// Diagnostic description of incomplete inputs. It authorizes nothing. +pub(crate) struct InputAudit { + inputs: [(&'static str, String); 3], + pending: Vec, +} + +impl InputAudit { + /// Returns whether every JSON pin and external authentication is present. + pub(crate) fn is_complete(&self) -> bool { + self.pending.is_empty() && NOT_ESTABLISHED.is_empty() + } + + /// Renders the deterministic diagnostic report. + pub(crate) fn to_json(&self) -> Value { + json!({ + "inputs": self + .inputs + .iter() + .map(|(path, sha256)| json!({"path": path, "sha256": sha256})) + .collect::>(), + "not_established": NOT_ESTABLISHED, + "pending": self.pending, + "report": "release-input-audit", + "status": if self.is_complete() { "complete" } else { "incomplete" }, + }) + } +} + +/// Lists pending and null items for diagnostics without constructing authority. +pub(crate) fn audit_inputs(inputs: &ReleaseInputs) -> InputAudit { + InputAudit { + inputs: inputs.input_digests(), + pending: inputs.pending_items(), + } +} + +/// Reads the three tracked inputs below `root` and checks them. +/// +/// # Errors +/// +/// Fails when any input cannot be read under its byte bound or violates the +/// compiled contract. +pub(crate) fn load_inputs(root: &Path) -> Result> { + let cohort = read_bounded(&root.join(COHORT_PATH), Role::Metadata)?; + let tools = read_bounded(&root.join(TOOLS_PATH), Role::Metadata)?; + let cli_policy = read_bounded(&root.join(CLI_POLICY_PATH), Role::Metadata)?; + check_inputs(&cohort.bytes, &tools.bytes, &cli_policy.bytes) +} + +/// Parses and checks exact input bytes against the compiled contract. +/// +/// # Errors +/// +/// Fails on malformed JSON, unknown or missing fields, or any deviation from +/// the compiled cohort, tool pins, recipes or consumer policy join. +pub(crate) fn check_inputs( + cohort: &[u8], + tools: &[u8], + cli_policy: &[u8], +) -> Result> { + let cohort_value = parse_json(cohort, Role::Metadata)?; + let cohort_document: CohortDocument = typed(&cohort_value, COHORT_PATH)?; + check_cohort(&cohort_document)?; + let tools_document: ToolsDocument = typed(&parse_json(tools, Role::Metadata)?, TOOLS_PATH)?; + check_tools(&tools_document)?; + check_cli_policy(&parse_json(cli_policy, Role::Metadata)?, &cohort_value)?; + let digest = |path, bytes: &[u8]| InputDigest { + path, + sha256: hex::encode(Sha256::digest(bytes)), + }; + Ok(ReleaseInputs { + digests: [ + digest(COHORT_PATH, cohort), + digest(TOOLS_PATH, tools), + digest(CLI_POLICY_PATH, cli_policy), + ], + tools: tools_document, + }) +} + +fn typed Deserialize<'de>>( + value: &Value, + path: &str, +) -> Result> { + T::deserialize(value).map_err(|error| rejected(format!("{path}: {error}"))) +} + +fn rejected(message: impl Into) -> Report { + Report::new(ReleaseError::Policy).attach(message.into()) +} + +fn check_cohort(cohort: &CohortDocument) -> Result<(), Report> { + if cohort.schema_version != SCHEMA_VERSION { + return Err(rejected("cohort schema_version must be 1")); + } + if cohort.cohort != COHORT_ID { + return Err(rejected(format!("cohort must be {COHORT_ID}"))); + } + if cohort.eligible_branches != ELIGIBLE_BRANCHES { + return Err(rejected( + "eligible_branches must be exactly [\"main\"]; a candidate cohort cannot \ + approve its own branch without independent review", + )); + } + let mut seen = BTreeSet::new(); + for artifact in &cohort.artifacts { + if !seen.insert(artifact.name.as_str()) { + return Err(rejected(format!("duplicate asset {}", artifact.name))); + } + let fixed = FIXED_ARTIFACTS + .iter() + .find(|fixed| fixed.name == artifact.name) + .ok_or_else(|| rejected(format!("unknown asset {}", artifact.name)))?; + check_artifact(fixed, artifact)?; + } + if let Some(missing) = FIXED_ARTIFACTS + .iter() + .find(|fixed| !seen.contains(fixed.name)) + { + return Err(rejected(format!("missing asset {}", missing.name))); + } + Ok(()) +} + +fn check_artifact( + fixed: &FixedArtifact, + artifact: &CohortArtifact, +) -> Result<(), Report> { + let name = fixed.name; + if ReleaseTarget::from_triple(&artifact.target).is_none() { + return Err(rejected(format!( + "asset {name} selects unknown target {}", + artifact.target + ))); + } + if !artifact.features.is_empty() { + return Err(rejected(format!( + "asset {name} requests unsupported features {:?}; only the default variant \ + with reusable-sandbox disabled is attested", + artifact.features + ))); + } + if !artifact.default_features || artifact.variant != "default" { + return Err(rejected(format!( + "asset {name} must keep default_features true and variant default" + ))); + } + let compatibility = &artifact.compatibility; + let comparisons = [ + ("package", fixed.package, artifact.package.as_str()), + ("bin", fixed.bin, artifact.bin.as_str()), + ("target", fixed.target.triple(), artifact.target.as_str()), + ("kind", fixed.kind, artifact.kind.as_str()), + ("role", fixed.role, artifact.role.as_str()), + ( + "assurance_profile", + fixed.assurance_profile, + artifact.assurance_profile.as_str(), + ), + ( + "compatibility.architecture", + fixed.architecture, + compatibility.architecture.as_str(), + ), + ( + "compatibility.minimum_version", + fixed.minimum_version, + compatibility.minimum_version.as_str(), + ), + ( + "compatibility.runtime", + fixed.runtime, + compatibility.runtime.as_str(), + ), + ]; + for (field, expected, actual) in comparisons { + if expected != actual { + return Err(rejected(format!( + "asset {name} changes {field} from {expected} to {actual}" + ))); + } + } + if artifact.capabilities != [fixed.capability] { + return Err(rejected(format!( + "asset {name} changes capabilities from [{}]", + fixed.capability + ))); + } + Ok(()) +} + +fn check_cli_policy(policy: &Value, cohort: &Value) -> Result<(), Report> { + let object = policy + .as_object() + .ok_or_else(|| rejected("release-policy must be an object"))?; + let keys: BTreeSet<&str> = object.keys().map(String::as_str).collect(); + if keys != BTreeSet::from(CLI_POLICY_FIELDS) { + return Err(rejected( + "release-policy must contain exactly the reviewed top-level fields", + )); + } + if object["cohort"] != COHORT_ID || object["policy_version"] != 1 { + return Err(rejected(format!( + "release-policy cohort must be {COHORT_ID} with policy_version 1" + ))); + } + if object["public_actions"] != json!(READONLY_ACTIONS) { + return Err(rejected( + "release-policy public_actions must be readonly-verify", + )); + } + if object["tool_versions"] + != json!({ + "actions_attest": ATTEST_VERSION, + "actions_attest_revision": ATTEST_REVISION, + "gh": GH_VERSION, + }) + { + return Err(rejected( + "release-policy tool_versions must match the compiled gh and attest pins", + )); + } + if object["bounds"]["cohort_executables"] != FIXED_ARTIFACTS.len() { + return Err(rejected( + "release-policy bounds.cohort_executables must equal the cohort size", + )); + } + let policy_artifacts = object["artifacts"] + .as_array() + .ok_or_else(|| rejected("release-policy artifacts must be an array"))?; + let cohort_artifacts = cohort["artifacts"] + .as_array() + .ok_or_else(|| rejected("cohort artifacts must be an array"))?; + if policy_artifacts.len() != cohort_artifacts.len() { + return Err(rejected( + "release-policy artifacts must list exactly the cohort artifacts", + )); + } + for (policy_artifact, cohort_artifact) in policy_artifacts.iter().zip(cohort_artifacts) { + let mut remainder = policy_artifact + .as_object() + .cloned() + .ok_or_else(|| rejected("release-policy artifact must be an object"))?; + let actions = remainder.remove("actions"); + if actions != Some(json!(READONLY_ACTIONS)) { + return Err(rejected( + "release-policy artifact actions must be exactly [\"readonly-verify\"]", + )); + } + if Value::Object(remainder) != *cohort_artifact { + return Err(rejected(format!( + "release-policy artifact {} differs from the cohort artifact", + cohort_artifact["name"] + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/policy/tests.rs b/crates/trusted-server-release/src/policy/tests.rs new file mode 100644 index 0000000000..b614c15eb5 --- /dev/null +++ b/crates/trusted-server-release/src/policy/tests.rs @@ -0,0 +1,546 @@ +use serde_json::json; +use sha2::{Digest as _, Sha256}; + +use super::*; + +const COHORT: &[u8] = include_bytes!("../../../../scripts/release/release-cohort.json"); +const TOOLS: &[u8] = include_bytes!("../../../../scripts/release/release-tools.json"); +const CLI_POLICY: &[u8] = + include_bytes!("../../../../crates/trusted-server-cli/release-policy.json"); + +fn value(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).expect("should parse tracked fixture input") +} + +fn encode(value: &Value) -> Vec { + serde_json::to_vec(value).expect("should encode mutated fixture input") +} + +fn report_text(report: &Report) -> String { + format!("{report:?}") +} + +fn cohort_with(mutate: impl FnOnce(&mut Value)) -> Result> { + let mut cohort = value(COHORT); + mutate(&mut cohort); + check_inputs(&encode(&cohort), TOOLS, CLI_POLICY) +} + +fn tools_with(mutate: impl FnOnce(&mut Value)) -> Result> { + let mut tools = value(TOOLS); + mutate(&mut tools); + check_inputs(COHORT, &encode(&tools), CLI_POLICY) +} + +fn policy_with(mutate: impl FnOnce(&mut Value)) -> Result> { + let mut policy = value(CLI_POLICY); + mutate(&mut policy); + check_inputs(COHORT, TOOLS, &encode(&policy)) +} + +fn artifact_index(document: &Value, name: &str) -> usize { + document["artifacts"] + .as_array() + .expect("should contain artifacts") + .iter() + .position(|artifact| artifact["name"] == name) + .expect("should contain named artifact") +} + +fn expect_rejected(result: Result>, needle: &str) { + let report = result.err().expect("should reject mutated release input"); + let text = report_text(&report); + assert!( + text.contains(needle), + "should explain rejection with `{needle}`, got: {text}" + ); +} + +#[test] +fn accepts_tracked_inputs_and_preserves_raw_digests() { + let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); + let digests = inputs.input_digests(); + assert_eq!( + digests, + [ + (COHORT_PATH, hex::encode(Sha256::digest(COHORT))), + (TOOLS_PATH, hex::encode(Sha256::digest(TOOLS))), + (CLI_POLICY_PATH, hex::encode(Sha256::digest(CLI_POLICY))), + ], + "should preserve exact raw-byte digests of each input" + ); +} + +#[test] +fn compiled_contract_maps_exactly_four_fixed_assets() { + let names: Vec<_> = FIXED_ARTIFACTS.iter().map(|fixed| fixed.name).collect(); + assert_eq!( + names, + [ + "trusted-server-fastly.wasm", + "ts-aarch64-apple-darwin", + "ts-x86_64-apple-darwin", + "ts-x86_64-unknown-linux-gnu", + ], + "should compile exactly the phase-one asset identities" + ); + let fastly = &FIXED_ARTIFACTS[0]; + assert_eq!(fastly.package, "trusted-server-adapter-fastly"); + assert_eq!(fastly.bin, "trusted-server-adapter-fastly"); + assert_eq!(fastly.target, ReleaseTarget::Wasm32Wasip1); + assert_eq!(fastly.assurance_profile, "provenance-sbom-repeatable"); + for cli in &FIXED_ARTIFACTS[1..] { + assert_eq!(cli.package, "trusted-server-cli", "should build the CLI"); + assert_eq!(cli.bin, "ts", "should build the ts binary"); + assert_eq!(cli.assurance_profile, "provenance-sbom"); + } + assert_eq!(ELIGIBLE_BRANCHES, ["main"], "should only allow main"); +} + +#[test] +fn rejects_missing_artifact() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-apple-darwin"); + cohort["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .remove(index); + }), + "missing asset ts-x86_64-apple-darwin", + ); +} + +#[test] +fn rejects_extra_artifact() { + expect_rejected( + cohort_with(|cohort| { + let mut extra = cohort["artifacts"][0].clone(); + extra["name"] = json!("trusted-server-fastly-reuse.wasm"); + cohort["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .push(extra); + }), + "unknown asset trusted-server-fastly-reuse.wasm", + ); +} + +#[test] +fn rejects_duplicate_artifact() { + expect_rejected( + cohort_with(|cohort| { + let duplicate = cohort["artifacts"][0].clone(); + cohort["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .push(duplicate); + }), + "duplicate asset", + ); +} + +#[test] +fn rejects_profile_downgraded_after_failure() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "trusted-server-fastly.wasm"); + cohort["artifacts"][index]["assurance_profile"] = json!("provenance-sbom"); + }), + "assurance_profile", + ); +} + +#[test] +fn rejects_unknown_target() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-unknown-linux-gnu"); + cohort["artifacts"][index]["target"] = json!("x86_64-unknown-linux-musl"); + }), + "unknown target x86_64-unknown-linux-musl", + ); +} + +#[test] +fn rejects_reassigned_known_target() { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-apple-darwin"); + cohort["artifacts"][index]["target"] = json!("aarch64-apple-darwin"); + }), + "target", + ); +} + +#[test] +fn rejects_unknown_or_reuse_feature() { + for feature in ["reusable-sandbox", "fictional-feature"] { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "trusted-server-fastly.wasm"); + cohort["artifacts"][index]["features"] = json!([feature]); + }), + "unsupported features", + ); + } +} + +#[test] +fn rejects_disabled_default_features_and_changed_variant() { + expect_rejected( + cohort_with(|cohort| { + cohort["artifacts"][0]["default_features"] = json!(false); + }), + "default_features", + ); + expect_rejected( + cohort_with(|cohort| { + cohort["artifacts"][0]["variant"] = json!("reuse"); + }), + "variant", + ); +} + +#[test] +fn rejects_changed_package_bin_capability_and_compatibility() { + for (field, replacement) in [ + ("package", json!("trusted-server-adapter-axum")), + ("bin", json!("other")), + ("capabilities", json!(["cli-execute"])), + ("kind", json!("native-executable")), + ("role", json!("operator-cli")), + ] { + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "trusted-server-fastly.wasm"); + cohort["artifacts"][index][field] = replacement; + }), + field, + ); + } + expect_rejected( + cohort_with(|cohort| { + let index = artifact_index(cohort, "ts-x86_64-unknown-linux-gnu"); + cohort["artifacts"][index]["compatibility"]["minimum_version"] = json!("glibc-2.39"); + }), + "compatibility", + ); +} + +#[test] +fn rejects_candidate_cohort_listing_its_own_rc_branch() { + for branches in [ + json!(["main", "rc/202610"]), + json!(["rc/202610"]), + json!([]), + ] { + expect_rejected( + cohort_with(|cohort| { + cohort["eligible_branches"] = branches; + }), + "eligible_branches", + ); + } +} + +#[test] +fn rejects_changed_cohort_identity_and_schema_version() { + expect_rejected( + cohort_with(|cohort| cohort["cohort"] = json!("phase2")), + "cohort", + ); + expect_rejected( + cohort_with(|cohort| cohort["schema_version"] = json!(2)), + "schema_version", + ); +} + +#[test] +fn rejects_unknown_and_missing_cohort_fields() { + assert!( + cohort_with(|cohort| cohort["commands"] = json!(["cargo build"])).is_err(), + "should reject JSON-supplied commands" + ); + assert!( + cohort_with(|cohort| cohort["artifacts"][0]["command"] = json!("sh -c true")).is_err(), + "should reject artifact-level commands" + ); + assert!( + cohort_with(|cohort| { + cohort["artifacts"][0] + .as_object_mut() + .expect("should be an artifact object") + .remove("variant"); + }) + .is_err(), + "should reject a missing artifact field" + ); + assert!( + cohort_with(|cohort| { + cohort + .as_object_mut() + .expect("should be a cohort object") + .remove("eligible_branches"); + }) + .is_err(), + "should reject a missing cohort field" + ); +} + +#[test] +fn rejects_duplicate_keys_through_strict_parser() { + let duplicated = String::from_utf8(COHORT.to_vec()) + .expect("should be UTF-8") + .replacen( + "\"cohort\": \"phase1-fastly-cli\"", + "\"cohort\": \"phase1-fastly-cli\", \"cohort\": \"x\"", + 1, + ); + assert!( + check_inputs(duplicated.as_bytes(), TOOLS, CLI_POLICY).is_err(), + "should reject duplicate keys before semantic checks" + ); +} + +#[test] +fn joins_cli_policy_artifacts_to_cohort_with_readonly_actions() { + expect_rejected( + policy_with(|policy| { + policy["artifacts"][0]["actions"] = json!(["readonly-verify", "deploy"]) + }), + "actions", + ); + expect_rejected( + policy_with(|policy| { + policy["artifacts"][1] + .as_object_mut() + .expect("should be a policy artifact") + .remove("actions"); + }), + "actions", + ); + expect_rejected( + policy_with(|policy| policy["artifacts"][1]["assurance_profile"] = json!("provenance")), + "release-policy", + ); + expect_rejected( + policy_with(|policy| { + policy["artifacts"] + .as_array_mut() + .expect("should contain artifacts") + .pop(); + }), + "release-policy", + ); + assert!( + policy_with(|policy| policy["override_url"] = json!("https://example.com/policy.json")) + .is_err(), + "should reject unknown policy fields" + ); + expect_rejected( + policy_with(|policy| policy["tool_versions"]["gh"] = json!("2.96.0")), + "tool_versions", + ); + expect_rejected( + policy_with(|policy| policy["cohort"] = json!("phase2")), + "cohort", + ); +} + +#[test] +fn preserves_exact_tool_versions_and_attest_revision() { + expect_rejected( + tools_with(|tools| { + let tool = tools["tools"] + .as_array_mut() + .expect("should contain tools") + .iter_mut() + .find(|tool| tool["name"] == "gh:x86_64-unknown-linux-gnu") + .expect("should contain gh"); + tool["version"] = json!("2.96.0"); + }), + "gh:x86_64-unknown-linux-gnu", + ); + expect_rejected( + tools_with(|tools| { + tools["tools"][0]["revision"] = json!("0".repeat(40)); + }), + "actions/attest:github-action", + ); + expect_rejected( + tools_with(|tools| { + tools["tools"] + .as_array_mut() + .expect("should contain tools") + .retain(|tool| tool["name"] != "rust:x86_64-apple-darwin"); + }), + "missing tool rust:x86_64-apple-darwin", + ); + expect_rejected( + tools_with(|tools| { + let mut extra = tools["tools"][1].clone(); + extra["name"] = json!("fastly:aarch64-unknown-linux-gnu"); + tools["tools"] + .as_array_mut() + .expect("should contain tools") + .push(extra); + }), + "unknown tool fastly:aarch64-unknown-linux-gnu", + ); +} + +#[test] +fn rejects_absent_nullable_pin_fields_and_unknown_tool_fields() { + assert!( + tools_with(|tools| { + tools["tools"][1] + .as_object_mut() + .expect("should be a tool object") + .remove("sha256"); + }) + .is_err(), + "should treat an absent digest as invalid rather than null" + ); + assert!( + tools_with(|tools| tools["tools"][1]["qualified"] = json!(true)).is_err(), + "should reject serialized authority booleans" + ); + assert!( + tools_with(|tools| tools["environments"][0]["authenticated"] = json!(true)).is_err(), + "should reject unknown environment fields" + ); + assert!( + tools_with(|tools| tools["tools"][1]["qualification"] = json!("approved")).is_err(), + "should reject unknown qualification states" + ); + assert!( + tools_with(|tools| tools["tools"][1]["sha256"] = json!("not-a-digest")).is_err(), + "should reject malformed digests" + ); +} + +#[test] +fn rejects_recipe_script_and_environment_changes() { + expect_rejected( + tools_with(|tools| tools["recipes"][0]["script"] = json!("scripts/release/other.sh")), + "script", + ); + expect_rejected( + tools_with(|tools| tools["recipes"][1]["environment_id"] = json!("gnu-builder-x86_64")), + "environment_id", + ); + expect_rejected( + tools_with(|tools| tools["recipes"][0]["settings"]["aws_lc_use_system"] = json!(true)), + "settings", + ); + expect_rejected( + tools_with(|tools| tools["recipes"][0]["settings"]["rust_toolchain"] = json!("1.96.0")), + "settings", + ); + expect_rejected( + tools_with(|tools| { + tools["environments"][1]["glibc_maximum"] = json!("2.39"); + }), + "gnu-builder-x86_64", + ); +} + +#[test] +fn production_selector_rejects_tracked_pending_pins() { + let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); + let report = select_qualified(&inputs, &[]) + .err() + .expect("should reject pending pins"); + let text = report_text(&report); + assert!(text.contains("pending"), "should name pending pins: {text}"); +} + +fn qualified_looking_tools() -> Value { + let digest = "ab".repeat(32); + let mut tools = value(TOOLS); + for section in ["environments", "recipes", "tools"] { + for entry in tools[section] + .as_array_mut() + .expect("should contain entries") + { + let object = entry.as_object_mut().expect("should be an object"); + for (key, field) in object.iter_mut() { + if key == "qualification" { + *field = json!("qualified"); + } else if field.is_null() && key == "revision" { + continue; + } else if field.is_null() && key.ends_with("sha256") { + *field = json!(digest); + } else if field.is_null() { + *field = json!("fictional.example.com/qualified"); + } + } + } + } + tools +} + +#[test] +fn production_selector_rejects_qualified_looking_json_with_fake_digests() { + let inputs = check_inputs(COHORT, &encode(&qualified_looking_tools()), CLI_POLICY) + .expect("should parse structurally complete fictional pins"); + let report = select_qualified(&inputs, &[]) + .err() + .expect("should refuse JSON qualification without authenticated receipts"); + let text = report_text(&report); + assert!( + text.contains("authenticated acquisition receipts") + && text.contains("production selection remains closed"), + "should explain the closed selector: {text}" + ); +} + +#[test] +fn audit_lists_pending_items_without_authorization() { + let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); + let audit = audit_inputs(&inputs); + assert!(!audit.is_complete(), "should report incomplete inputs"); + let report = audit.to_json(); + let pending = report["pending"] + .as_array() + .expect("should list pending items"); + assert!( + pending.contains(&json!( + "tool gh:x86_64-unknown-linux-gnu: qualification pending" + )), + "should list pending tool qualification" + ); + assert!( + pending.contains(&json!("tool gh:x86_64-unknown-linux-gnu: sha256 is null")), + "should list null tool digest" + ); + assert!( + pending.contains(&json!("environment macos15-aarch64: sdk_sha256 is null")), + "should list null environment SDK digest" + ); + assert_eq!( + report["inputs"][0], + json!({"path": COHORT_PATH, "sha256": hex::encode(Sha256::digest(COHORT))}), + "should report raw input digests" + ); + assert_eq!(report["status"], json!("incomplete")); +} + +#[test] +fn audit_stays_incomplete_for_qualified_looking_json() { + let inputs = check_inputs(COHORT, &encode(&qualified_looking_tools()), CLI_POLICY) + .expect("should parse structurally complete fictional pins"); + let audit = audit_inputs(&inputs); + assert!( + audit.to_json()["pending"] + .as_array() + .expect("should list pending items") + .is_empty(), + "should find no pending JSON fields" + ); + assert!( + !audit.is_complete(), + "should remain incomplete without authenticated receipts" + ); +} diff --git a/crates/trusted-server-release/src/policy/tools.rs b/crates/trusted-server-release/src/policy/tools.rs new file mode 100644 index 0000000000..6812b09a30 --- /dev/null +++ b/crates/trusted-server-release/src/policy/tools.rs @@ -0,0 +1,487 @@ +//! Strict parsing and compiled-pin comparison of `release-tools.json`. +//! +//! Qualification states and digests parsed here are diagnostics only; they +//! never construct production authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Value, json}; + +use super::{ + ATTEST_REVISION, ATTEST_VERSION, FIXED_ARTIFACTS, GH_VERSION, SCHEMA_VERSION, rejected, +}; +use crate::documents::lower_hex; +use crate::error::ReleaseError; + +const RUST_VERSION: &str = "1.95.0"; +const NODE_VERSION: &str = "24.12.0"; +const NPM_VERSION: &str = "11.6.2"; +const ATTEST_NAME: &str = "actions/attest:github-action"; +const ARTIFACT_SCRIPT: &str = "scripts/release/build.sh"; +const JS_RECIPE: &str = "tsjs"; +const JS_SCRIPT: &str = "scripts/release/build-js.sh"; +/// Builder environment shared by the Fastly, Linux CLI and JS recipes. +pub(super) const BUILDER_ENVIRONMENT: &str = "gnu-builder-x86_64"; +const TOOL_HOSTS: [&str; 3] = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +]; +const PER_HOST_TOOLS: [(&str, &str); 7] = [ + ("fastly", "15.1.0"), + ("gh", GH_VERSION), + ("node", NODE_VERSION), + ("npm", NPM_VERSION), + ("rust", RUST_VERSION), + ("viceroy", "0.17.0"), + ("wasmtime", "44.0.1"), +]; + +/// Fixed environment identity: id, kind, role and kind-specific constant. +const FIXED_ENVIRONMENTS: [(&str, &str, &str, &str); 6] = [ + ( + "debian12-x86_64", + "oci-runtime", + "compatibility-runtime", + "", + ), + (BUILDER_ENVIRONMENT, "oci-builder", "compilation", "2.34"), + ( + "macos15-aarch64", + "github-hosted-macos", + "compilation", + "macos-15", + ), + ( + "macos15-x86_64", + "github-hosted-macos", + "compilation", + "macos-15-intel", + ), + ("ubi9-x86_64", "oci-runtime", "compatibility-runtime", ""), + ( + "ubuntu22.04-x86_64", + "oci-runtime", + "compatibility-runtime", + "", + ), +]; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum Qualification { + Pending, + Qualified, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +/// Parsed `release-tools.json` with closed per-kind field sets. +pub(super) struct ToolsDocument { + environments: Vec, + recipes: Vec, + schema_version: u64, + tools: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ToolPin { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + name: String, + qualification: Qualification, + #[serde(deserialize_with = "nullable")] + revision: Option, + #[serde(deserialize_with = "nullable")] + sha256: Option, + version: String, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", deny_unknown_fields)] +enum Environment { + #[serde(rename = "oci-runtime")] + OciRuntime { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + id: String, + #[serde(deserialize_with = "nullable")] + image_reference: Option, + #[serde(deserialize_with = "nullable")] + oci_index_sha256: Option, + #[serde(deserialize_with = "nullable")] + oci_manifest_sha256: Option, + qualification: Qualification, + role: String, + }, + #[serde(rename = "oci-builder")] + OciBuilder { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + glibc_maximum: String, + id: String, + #[serde(deserialize_with = "nullable")] + image_reference: Option, + #[serde(deserialize_with = "nullable")] + oci_index_sha256: Option, + #[serde(deserialize_with = "nullable")] + oci_manifest_sha256: Option, + qualification: Qualification, + role: String, + #[serde(deserialize_with = "nullable")] + sdk_sha256: Option, + }, + #[serde(rename = "github-hosted-macos")] + HostedMacos { + #[serde(deserialize_with = "nullable")] + acquisition: Option, + id: String, + qualification: Qualification, + role: String, + #[serde(deserialize_with = "nullable")] + runner_image_version: Option, + runner_label: String, + #[serde(deserialize_with = "nullable")] + sdk_sha256: Option, + }, +} + +/// A nullable pin field: present-or-null, never absent. +struct PinField<'a> { + name: &'static str, + value: Option<&'a str>, + digest: bool, +} + +impl<'a> PinField<'a> { + fn new(name: &'static str, value: &'a Option, digest: bool) -> Self { + Self { + name, + value: value.as_deref(), + digest, + } + } +} + +impl Environment { + fn identity(&self) -> (&str, &'static str, &str, &str) { + match self { + Self::OciRuntime { id, role, .. } => (id, "oci-runtime", role, ""), + Self::OciBuilder { + id, + role, + glibc_maximum, + .. + } => (id, "oci-builder", role, glibc_maximum), + Self::HostedMacos { + id, + role, + runner_label, + .. + } => (id, "github-hosted-macos", role, runner_label), + } + } + + fn qualification(&self) -> Qualification { + match self { + Self::OciRuntime { qualification, .. } + | Self::OciBuilder { qualification, .. } + | Self::HostedMacos { qualification, .. } => *qualification, + } + } + + fn acquisition(&self) -> Option<&Value> { + match self { + Self::OciRuntime { acquisition, .. } + | Self::OciBuilder { acquisition, .. } + | Self::HostedMacos { acquisition, .. } => acquisition.as_ref(), + } + } + + fn pins(&self) -> Vec> { + let field = PinField::new; + match self { + Self::OciRuntime { + image_reference, + oci_index_sha256, + oci_manifest_sha256, + .. + } => vec![ + field("image_reference", image_reference, false), + field("oci_index_sha256", oci_index_sha256, true), + field("oci_manifest_sha256", oci_manifest_sha256, true), + ], + Self::OciBuilder { + image_reference, + oci_index_sha256, + oci_manifest_sha256, + sdk_sha256, + .. + } => vec![ + field("image_reference", image_reference, false), + field("oci_index_sha256", oci_index_sha256, true), + field("oci_manifest_sha256", oci_manifest_sha256, true), + field("sdk_sha256", sdk_sha256, true), + ], + Self::HostedMacos { + runner_image_version, + sdk_sha256, + .. + } => vec![ + field("runner_image_version", runner_image_version, false), + field("sdk_sha256", sdk_sha256, true), + ], + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Recipe { + environment_id: String, + id: String, + qualification: Qualification, + script: String, + settings: Map, + #[serde(deserialize_with = "nullable")] + sha256: Option, +} + +fn nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +impl ToolsDocument { + /// Lists pending qualifications and null pins in document order. + pub(super) fn pending_items(&self) -> Vec { + let mut items = Vec::new(); + for environment in &self.environments { + note_pending( + &mut items, + &format!("environment {}", environment.identity().0), + environment.qualification(), + Some(environment.acquisition()), + &environment.pins(), + ); + } + for recipe in &self.recipes { + let digest = PinField::new("sha256", &recipe.sha256, true); + note_pending( + &mut items, + &format!("recipe {}", recipe.id), + recipe.qualification, + None, + &[digest], + ); + } + for tool in &self.tools { + let digest = PinField::new("sha256", &tool.sha256, true); + note_pending( + &mut items, + &format!("tool {}", tool.name), + tool.qualification, + Some(tool.acquisition.as_ref()), + &[digest], + ); + } + items + } +} + +/// Appends pending diagnostics; `acquisition` is `None` when the record has no such field. +fn note_pending( + items: &mut Vec, + subject: &str, + qualification: Qualification, + acquisition: Option>, + pins: &[PinField<'_>], +) { + if qualification != Qualification::Qualified { + items.push(format!("{subject}: qualification pending")); + } + if acquisition.is_some_and(|value| value.is_none()) { + items.push(format!("{subject}: acquisition is null")); + } + for pin in pins.iter().filter(|pin| pin.value.is_none()) { + items.push(format!("{subject}: {} is null", pin.name)); + } +} + +fn expected_tools() -> BTreeMap)> { + let mut expected = BTreeMap::new(); + expected.insert( + ATTEST_NAME.to_owned(), + (ATTEST_VERSION, Some(ATTEST_REVISION)), + ); + for (tool, version) in PER_HOST_TOOLS { + for host in TOOL_HOSTS { + expected.insert(format!("{tool}:{host}"), (version, None)); + } + } + expected +} + +/// Compares parsed pins, environments and recipes with the compiled contract. +pub(super) fn check_tools(tools: &ToolsDocument) -> Result<(), Report> { + if tools.schema_version != SCHEMA_VERSION { + return Err(rejected("tools schema_version must be 1")); + } + let fixed = expected_tools(); + let mut seen = BTreeSet::new(); + for tool in &tools.tools { + if !seen.insert(tool.name.as_str()) { + return Err(rejected(format!("duplicate tool {}", tool.name))); + } + let (version, revision) = *fixed + .get(&tool.name) + .ok_or_else(|| rejected(format!("unknown tool {}", tool.name)))?; + if tool.version != version || tool.revision.as_deref() != revision { + return Err(rejected(format!( + "tool {} must pin version {version} and revision {revision:?}", + tool.name + ))); + } + check_digest( + &format!("tool {}", tool.name), + "sha256", + tool.sha256.as_deref(), + )?; + } + if let Some(missing) = fixed.keys().find(|name| !seen.contains(name.as_str())) { + return Err(rejected(format!("missing tool {missing}"))); + } + check_environments(&tools.environments)?; + check_recipes(&tools.recipes) +} + +fn check_digest( + subject: &str, + field: &str, + value: Option<&str>, +) -> Result<(), Report> { + match value { + Some(digest) if !lower_hex(digest, 64) => Err(rejected(format!( + "{subject}: {field} must be 64 lowercase hex digits or null" + ))), + _ => Ok(()), + } +} + +fn check_environments(environments: &[Environment]) -> Result<(), Report> { + let mut seen = BTreeSet::new(); + for environment in environments { + let identity = environment.identity(); + let id = identity.0; + if !seen.insert(id) { + return Err(rejected(format!("duplicate environment {id}"))); + } + let fixed = FIXED_ENVIRONMENTS + .iter() + .find(|fixed| fixed.0 == id) + .ok_or_else(|| rejected(format!("unknown environment {id}")))?; + if (fixed.1, fixed.2, fixed.3) != (identity.1, identity.2, identity.3) { + return Err(rejected(format!( + "environment {id} changes its fixed kind, role or platform constant" + ))); + } + for pin in environment.pins().iter().filter(|pin| pin.digest) { + check_digest(&format!("environment {id}"), pin.name, pin.value)?; + } + } + if let Some(missing) = FIXED_ENVIRONMENTS + .iter() + .find(|fixed| !seen.contains(fixed.0)) + { + return Err(rejected(format!("missing environment {}", missing.0))); + } + Ok(()) +} + +/// One source-owned recipe identity and the settings it must restate. +struct FixedRecipe { + id: &'static str, + environment: &'static str, + script: &'static str, + settings: Vec<(&'static str, Value)>, +} + +fn expected_recipes() -> Vec { + let artifact_settings = || { + vec![ + ("aws_lc_use_system", json!(false)), + ("cargo_config", json!(".cargo/config.toml")), + ("cargo_frozen", json!(true)), + ("cargo_locked", json!(true)), + ("rust_toolchain", json!(RUST_VERSION)), + ("sqlite_use_pkg_config", json!(false)), + ("vite_mode", json!("production")), + ] + }; + let mut recipes: Vec<_> = FIXED_ARTIFACTS + .iter() + .map(|fixed| FixedRecipe { + id: fixed.name, + environment: fixed.recipe_environment, + script: ARTIFACT_SCRIPT, + settings: artifact_settings(), + }) + .collect(); + recipes.push(FixedRecipe { + id: JS_RECIPE, + environment: BUILDER_ENVIRONMENT, + script: JS_SCRIPT, + settings: vec![ + ("node_version", json!(NODE_VERSION)), + ("npm_version", json!(NPM_VERSION)), + ("vite_mode", json!("production")), + ], + }); + recipes +} + +fn check_recipes(recipes: &[Recipe]) -> Result<(), Report> { + let expected = expected_recipes(); + let mut seen = BTreeSet::new(); + for recipe in recipes { + let id = recipe.id.as_str(); + if !seen.insert(id) { + return Err(rejected(format!("duplicate recipe {id}"))); + } + let FixedRecipe { + environment, + script, + settings, + .. + } = expected + .iter() + .find(|fixed| fixed.id == id) + .ok_or_else(|| rejected(format!("unknown recipe {id}")))?; + if recipe.environment_id != *environment { + return Err(rejected(format!( + "recipe {id} environment_id must be {environment}" + ))); + } + if recipe.script != *script { + return Err(rejected(format!("recipe {id} script must be {script}"))); + } + for (key, value) in settings { + if recipe.settings.get(*key) != Some(value) { + return Err(rejected(format!( + "recipe {id} settings.{key} must be {value}" + ))); + } + } + check_digest(&format!("recipe {id}"), "sha256", recipe.sha256.as_deref())?; + } + if let Some(missing) = expected.iter().find(|fixed| !seen.contains(fixed.id)) { + return Err(rejected(format!("missing recipe {}", missing.id))); + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/preflight.rs b/crates/trusted-server-release/src/preflight.rs new file mode 100644 index 0000000000..f93b535270 --- /dev/null +++ b/crates/trusted-server-release/src/preflight.rs @@ -0,0 +1,278 @@ +//! Inherited-environment audit and allowlisted child-environment construction. +//! +//! The audit runs over the complete inherited snapshot before anything is +//! cleared, so an empty starting environment cannot hide a rejected channel. +//! Errors name variables but never print their values, which may be secrets. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use crate::documents::lower_hex; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; + +const RELEASE_BUILD: &str = "TRUSTED_SERVER__RELEASE_BUILD"; +const REMOVED_PREFIXES: [&str; 2] = ["TRUSTED_SERVER__", "EDGEZERO__"]; +const SKIP_BUILD: &str = "TSJS_SKIP_BUILD"; +const MACOS_DEPLOYMENT_TARGET: &str = "13.0"; + +/// Exact (uppercase) names that select compilers, flags, loaders, sources or credentials. +const REJECTED_EXACT: [&str; 26] = [ + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RUNTIME_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "CARGO", + "DEVELOPER_DIR", + "GH_TOKEN", + "GITHUB_TOKEN", + "MACOSX_DEPLOYMENT_TARGET", + "NODE_OPTIONS", + "NODE_PATH", + "RUSTC", + "RUSTDOC", + "RUSTDOCFLAGS", + "RUSTFLAGS", + "SDKROOT", + "SOURCE_DATE_EPOCH", + "TSJS_SKIP_BUILD", + "VCPKG_INSTALLATION_ROOT", + "VCPKG_ROOT", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "PKG_CONFIG", +]; + +/// Uppercase prefixes whose whole family is recipe-selected or forbidden. +const REJECTED_PREFIXES: [&str; 17] = [ + "AWS_LC_FIPS_SYS_", + "AWS_LC_SYS_", + "BINDGEN_", + "CARGO_", + "DYLD_", + "FASTLY_", + "GIT_", + "LD_", + "LIBSQLITE3_SYS_", + "NPM_CONFIG_", + "PKG_CONFIG_", + "RUSTC_", + "RUSTDOC_", + "RUSTUP_", + "SIGSTORE_", + "SQLITE3_", + "VITE_", +]; + +/// Native tool selectors; `_`, `-`, `TARGET_` and `HOST_` variants are rejected too. +const NATIVE_SELECTORS: [&str; 10] = [ + "AR", "ARFLAGS", "CC", "CFLAGS", "CPPFLAGS", "CXX", "CXXFLAGS", "LD", "LDFLAGS", "RANLIB", +]; + +/// A hosted setup export accepted only for an exact value and resolved path identity. +pub(crate) struct ReviewedException { + name: &'static str, + value: &'static str, + path_identity: Option<&'static str>, +} + +/// Reviewed hosted setup values. Empty until values and paths are qualified. +pub(crate) const REVIEWED_SETUP_EXCEPTIONS: [ReviewedException; 0] = []; + +/// Proof that the inherited environment snapshot passed the release audit. +pub(crate) struct AuditedEnvironment { + removed: Vec, + search_path: Option, +} + +impl AuditedEnvironment { + /// Names of inherited operator variables removed rather than passed on. + pub(crate) fn removed_names(&self) -> &[String] { + &self.removed + } + + /// Inherited `PATH`, used only to locate preflight helpers such as Git. + pub(crate) fn search_path(&self) -> Option<&str> { + self.search_path.as_deref() + } +} + +/// Audits an inherited environment snapshot collected before any clearing. +/// +/// # Errors +/// +/// Fails when any variable is non-UTF-8, selects a rejected channel, or sets +/// `TRUSTED_SERVER__RELEASE_BUILD` to a value other than `1`. The error lists +/// every rejected name and never a value. +pub(crate) fn audit_environment( + snapshot: &[(OsString, OsString)], +) -> Result> { + let mut rejected = BTreeSet::new(); + let mut removed = BTreeSet::new(); + let mut search_path = None; + for (name, value) in snapshot { + let Some(name) = name.to_str() else { + rejected.insert(format!("{} (non-UTF-8 name)", name.to_string_lossy())); + continue; + }; + let Some(value) = value.to_str() else { + rejected.insert(format!("{name} (non-UTF-8 value)")); + continue; + }; + if name == RELEASE_BUILD && value != "1" { + rejected.insert(format!("{name} (must be exactly 1 when present)")); + } else if REMOVED_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) + { + removed.insert(name.to_owned()); + } else if is_rejected(name) && !is_reviewed(name, value) { + rejected.insert(name.to_owned()); + } else if name == "PATH" { + search_path = Some(value.to_owned()); + } + } + if !rejected.is_empty() { + return Err(Report::new(ReleaseError::Environment).attach(format!( + "rejected inherited variables: {}", + rejected.into_iter().collect::>().join(", ") + ))); + } + Ok(AuditedEnvironment { + removed: removed.into_iter().collect(), + search_path, + }) +} + +fn is_rejected(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + upper == SKIP_BUILD + || REJECTED_EXACT.contains(&upper.as_str()) + || REJECTED_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix)) + || NATIVE_SELECTORS + .iter() + .any(|selector| is_native_variant(&upper, selector)) +} + +fn is_native_variant(upper: &str, selector: &str) -> bool { + let base = upper + .strip_prefix("TARGET_") + .or_else(|| upper.strip_prefix("HOST_")) + .unwrap_or(upper); + base == selector + || upper + .strip_prefix(selector) + .is_some_and(|suffix| suffix.starts_with(['_', '-'])) +} + +fn is_reviewed(name: &str, value: &str) -> bool { + REVIEWED_SETUP_EXCEPTIONS.iter().any(|exception| { + exception.name == name + && exception.value == value + && exception.path_identity.is_none_or(|identity| { + std::fs::canonicalize(value).is_ok_and(|path| path == Path::new(identity)) + }) + }) +} + +/// A tool directory produced by authenticated acquisition (Task 8). +pub(crate) struct ToolDirectory(PathBuf); + +/// Recipe-selected private locations for one child build. +pub(crate) struct ChildRequest { + pub(crate) target: ReleaseTarget, + pub(crate) home: PathBuf, + pub(crate) cargo_home: PathBuf, + pub(crate) tool_directories: Vec, +} + +/// An allowlisted child environment constructed from scratch. +pub(crate) struct ChildEnvironment { + variables: BTreeMap, + removed: Vec, +} + +impl ChildEnvironment { + /// Every variable the child receives; nothing is inherited. + pub(crate) fn variables(&self) -> &BTreeMap { + &self.variables + } + + /// Sorted names injected by the recipe, recorded as input facts. + pub(crate) fn injected_names(&self) -> Vec<&str> { + self.variables.keys().map(String::as_str).collect() + } + + /// Inherited operator names removed during the audit. + pub(crate) fn removed_names(&self) -> &[String] { + &self.removed + } +} + +fn absolute_text<'a>(label: &str, path: &'a Path) -> Result<&'a str, Report> { + path.to_str() + .filter(|text| path.is_absolute() && !text.contains([':', '\0'])) + .ok_or_else(|| { + Report::new(ReleaseError::Environment).attach(format!( + "{label} must be an absolute UTF-8 path without ':' or NUL" + )) + }) +} + +fn build_child( + audited: &AuditedEnvironment, + commit: &str, + epoch: u64, + request: &ChildRequest, +) -> Result> { + if !lower_hex(commit, 40) { + return Err(Report::new(ReleaseError::Environment) + .attach("approved commit must be 40 lowercase hex digits")); + } + let mut variables = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), + ( + "CARGO_HOME".to_owned(), + absolute_text("CARGO_HOME", &request.cargo_home)?.to_owned(), + ), + ( + "HOME".to_owned(), + absolute_text("HOME", &request.home)?.to_owned(), + ), + ("LC_ALL".to_owned(), "C".to_owned()), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), + ("SOURCE_DATE_EPOCH".to_owned(), epoch.to_string()), + ("TRUSTED_SERVER__GIT_VERSION".to_owned(), commit.to_owned()), + (RELEASE_BUILD.to_owned(), "1".to_owned()), + ("TZ".to_owned(), "UTC".to_owned()), + ]); + if !request.tool_directories.is_empty() { + let entries = request + .tool_directories + .iter() + .map(|directory| absolute_text("tool directory", &directory.0)) + .collect::, _>>()?; + variables.insert("PATH".to_owned(), entries.join(":")); + } + if request.target.is_darwin() { + variables.insert( + "MACOSX_DEPLOYMENT_TARGET".to_owned(), + MACOS_DEPLOYMENT_TARGET.to_owned(), + ); + } + Ok(ChildEnvironment { + variables, + removed: audited.removed.clone(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/preflight/tests.rs b/crates/trusted-server-release/src/preflight/tests.rs new file mode 100644 index 0000000000..b28bb3918c --- /dev/null +++ b/crates/trusted-server-release/src/preflight/tests.rs @@ -0,0 +1,418 @@ +use std::os::unix::ffi::OsStringExt as _; + +use super::*; + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn snapshot(entries: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + entries + .iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect() +} + +fn rejection(entries: &[(&str, &str)]) -> String { + let report = audit_environment(&snapshot(entries)) + .err() + .expect("should reject inherited environment"); + format!("{report:?}") +} + +fn request(target: ReleaseTarget) -> ChildRequest { + ChildRequest { + target, + home: PathBuf::from("/private/release/home"), + cargo_home: PathBuf::from("/private/release/cargo-home"), + tool_directories: vec![ + ToolDirectory(PathBuf::from("/private/release/tools/rust/bin")), + ToolDirectory(PathBuf::from("/private/release/tools/node/bin")), + ], + } +} + +#[test] +fn accepts_benign_environment_and_keeps_search_path() { + let audited = audit_environment(&snapshot(&[ + ("PATH", "/usr/bin:/bin"), + ("HOME", "/home/fictional"), + ("LANG", "en_US.UTF-8"), + ("TERM", "xterm"), + ])) + .expect("should accept benign variables"); + assert_eq!(audited.search_path(), Some("/usr/bin:/bin")); + assert!(audited.removed_names().is_empty(), "should remove nothing"); +} + +#[test] +fn rejects_tsjs_skip_build_with_empty_value() { + let text = rejection(&[("TSJS_SKIP_BUILD", "")]); + assert!(text.contains("TSJS_SKIP_BUILD"), "should name skip flag"); +} + +#[test] +fn rejects_tsjs_skip_build_with_zero_value() { + let text = rejection(&[("TSJS_SKIP_BUILD", "0")]); + assert!(text.contains("TSJS_SKIP_BUILD"), "should name skip flag"); +} + +#[test] +fn rejects_tsjs_skip_build_with_one_value() { + let text = rejection(&[("TSJS_SKIP_BUILD", "1")]); + assert!(text.contains("TSJS_SKIP_BUILD"), "should name skip flag"); +} + +fn assert_each_rejected(class: &str, names: &[&str]) { + for name in names { + let text = rejection(&[("PATH", "/usr/bin"), (name, "fictional-value")]); + assert!( + text.contains(name), + "should reject {class} variable {name}: {text}" + ); + assert!( + !text.contains("fictional-value"), + "should never print {class} values" + ); + } +} + +#[test] +fn rejects_compiler_wrappers() { + assert_each_rejected( + "wrapper", + &[ + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "RUSTC", + "RUSTDOC", + ], + ); +} + +#[test] +fn rejects_compiler_and_profile_flags() { + assert_each_rejected( + "flag", + &[ + "RUSTFLAGS", + "RUSTDOCFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_ENCODED_RUSTDOCFLAGS", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_TARGET_WASM32_WASIP1_LINKER", + "CARGO_TARGET_DIR", + "CARGO_PROFILE_RELEASE_DEBUG", + ], + ); +} + +#[test] +fn rejects_cargo_and_rustup_configuration_layers() { + assert_each_rejected( + "cargo-config", + &[ + "CARGO", + "CARGO_HOME", + "CARGO_NET_OFFLINE", + "CARGO_MANIFEST_DIR", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + ], + ); +} + +#[test] +fn rejects_node_npm_and_vite_overrides() { + assert_each_rejected( + "node", + &[ + "NODE_OPTIONS", + "NODE_PATH", + "npm_config_registry", + "NPM_CONFIG_CACHE", + "Npm_Config_Ignore_Scripts", + "VITE_MODE", + ], + ); +} + +#[test] +fn rejects_native_build_selectors_and_target_variants() { + assert_each_rejected( + "native", + &[ + "AWS_LC_SYS_USE_SYSTEM", + "AWS_LC_SYS_CMAKE_BUILDER", + "AWS_LC_SYS_PREBUILT_NASM_x86_64_unknown_linux_gnu", + "AWS_LC_FIPS_SYS_STATIC", + "LIBSQLITE3_SYS_USE_PKG_CONFIG", + "SQLITE3_LIB_DIR", + "CC", + "CXX", + "AR", + "LD", + "CFLAGS", + "CXXFLAGS", + "LDFLAGS", + "CPPFLAGS", + "CC_x86_64_unknown_linux_gnu", + "CFLAGS_aarch64-apple-darwin", + "TARGET_CC", + "HOST_CFLAGS", + "PKG_CONFIG", + "PKG_CONFIG_PATH", + "BINDGEN_EXTRA_CLANG_ARGS", + "SDKROOT", + "DEVELOPER_DIR", + "MACOSX_DEPLOYMENT_TARGET", + "VCPKG_INSTALLATION_ROOT", + "VCPKG_ROOT", + ], + ); +} + +#[test] +fn rejects_loader_injection() { + assert_each_rejected( + "loader", + &[ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + ], + ); +} + +#[test] +fn rejects_git_channels_and_source_date_epoch() { + assert_each_rejected( + "git", + &[ + "GIT_DIR", + "GIT_CONFIG_PARAMETERS", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "SOURCE_DATE_EPOCH", + ], + ); +} + +#[test] +fn rejects_credentials_and_signing_material() { + assert_each_rejected( + "credential", + &[ + "GH_TOKEN", + "GITHUB_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RUNTIME_TOKEN", + "FASTLY_API_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "SIGSTORE_ID_TOKEN", + ], + ); +} + +#[test] +fn rejects_unreviewed_setup_exports_even_when_empty() { + assert!( + REVIEWED_SETUP_EXCEPTIONS.is_empty(), + "should have no reviewed hosted setup values yet" + ); + for (name, value) in [ + ("RUSTFLAGS", ""), + ("VCPKG_INSTALLATION_ROOT", "/usr/local/share/vcpkg"), + ] { + let text = rejection(&[(name, value)]); + assert!(text.contains(name), "should reject unreviewed {name}"); + } +} + +#[test] +fn rejects_non_utf8_names_and_values() { + let bad_value = vec![( + OsString::from("FICTIONAL_SETTING"), + OsString::from_vec(vec![0x66, 0xff, 0x6f]), + )]; + let text = format!( + "{:?}", + audit_environment(&bad_value) + .err() + .expect("should reject non-UTF-8 value") + ); + assert!(text.contains("FICTIONAL_SETTING"), "should name variable"); + let bad_name = vec![( + OsString::from_vec(vec![0x46, 0xff, 0x4f]), + OsString::from("value"), + )]; + assert!( + audit_environment(&bad_name).is_err(), + "should reject non-UTF-8 name" + ); +} + +#[test] +fn rejects_release_build_flag_other_than_one() { + for value in ["0", "", "true", "11"] { + let text = rejection(&[("TRUSTED_SERVER__RELEASE_BUILD", value)]); + assert!( + text.contains("TRUSTED_SERVER__RELEASE_BUILD"), + "should reject release flag value" + ); + } +} + +#[test] +fn removes_operator_prefixes_by_full_prefix() { + let audited = audit_environment(&snapshot(&[ + ("TRUSTED_SERVER__RELEASE_BUILD", "1"), + ("TRUSTED_SERVER__PUBLISHER__DOMAIN", "publisher.example.com"), + ("EDGEZERO__STORES__CONFIG__ID", "fictional"), + ("MY_TRUSTED_SERVER__SETTING", "kept-out"), + ("TRUSTED_SERVERX", "kept-out"), + ("trusted_server__lowercase", "kept-out"), + ])) + .expect("should accept removable operator variables"); + assert_eq!( + audited.removed_names(), + [ + "EDGEZERO__STORES__CONFIG__ID", + "TRUSTED_SERVER__PUBLISHER__DOMAIN", + "TRUSTED_SERVER__RELEASE_BUILD", + ], + "should record exactly the case-exact prefixed names" + ); +} + +#[test] +fn reports_every_rejected_name_without_secret_values() { + let text = rejection(&[ + ("GH_TOKEN", "ghp_fictionalsecretvalue"), + ("AWS_SECRET_ACCESS_KEY", "fictional/secret/access/key"), + ("RUSTFLAGS", "-C link-arg=fictional-secret-flag"), + ("TSJS_SKIP_BUILD", "1"), + ]); + for name in [ + "GH_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "RUSTFLAGS", + "TSJS_SKIP_BUILD", + ] { + assert!(text.contains(name), "should name {name}"); + } + for secret in [ + "ghp_fictionalsecretvalue", + "fictional/secret/access/key", + "fictional-secret-flag", + ] { + assert!(!text.contains(secret), "should never print variable values"); + } +} + +#[test] +fn child_environment_contains_exactly_expected_linux_keys() { + let audited = audit_environment(&snapshot(&[ + ("PATH", "/usr/bin"), + ("TRUSTED_SERVER__PROXY__SECRET", "fictional"), + ("LANG", "en_US.UTF-8"), + ])) + .expect("should audit benign environment"); + let child = build_child( + &audited, + COMMIT, + 1_700_000_000, + &request(ReleaseTarget::X86_64UnknownLinuxGnu), + ) + .expect("should build child environment"); + let expected = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM", "0"), + ("CARGO_HOME", "/private/release/cargo-home"), + ("HOME", "/private/release/home"), + ("LC_ALL", "C"), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG", "0"), + ( + "PATH", + "/private/release/tools/rust/bin:/private/release/tools/node/bin", + ), + ("SOURCE_DATE_EPOCH", "1700000000"), + ("TRUSTED_SERVER__GIT_VERSION", COMMIT), + ("TRUSTED_SERVER__RELEASE_BUILD", "1"), + ("TZ", "UTC"), + ]); + let actual: BTreeMap<&str, &str> = child + .variables() + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + assert_eq!( + actual, expected, + "should construct the allowlist from scratch" + ); + assert_eq!( + child.injected_names(), + expected.keys().copied().collect::>(), + "should record injected names as input facts" + ); + assert_eq!( + child.removed_names(), + ["TRUSTED_SERVER__PROXY__SECRET"], + "should record removed operator names" + ); +} + +#[test] +fn child_environment_sets_macos_floor_only_for_darwin() { + let audited = audit_environment(&[]).expect("should audit empty snapshot"); + for target in [ + ReleaseTarget::Aarch64AppleDarwin, + ReleaseTarget::X86_64AppleDarwin, + ] { + let child = + build_child(&audited, COMMIT, 1, &request(target)).expect("should build darwin child"); + assert_eq!( + child + .variables() + .get("MACOSX_DEPLOYMENT_TARGET") + .map(String::as_str), + Some("13.0"), + "should set loader floor for {target:?}" + ); + } + for target in [ + ReleaseTarget::Wasm32Wasip1, + ReleaseTarget::X86_64UnknownLinuxGnu, + ] { + let child = build_child(&audited, COMMIT, 1, &request(target)) + .expect("should build non-darwin child"); + assert!( + !child.variables().contains_key("MACOSX_DEPLOYMENT_TARGET"), + "should not set macOS floor for {target:?}" + ); + } +} + +#[test] +fn child_environment_rejects_unsafe_paths_and_commits() { + let audited = audit_environment(&[]).expect("should audit empty snapshot"); + let mut relative = request(ReleaseTarget::Wasm32Wasip1); + relative.home = PathBuf::from("home"); + assert!( + build_child(&audited, COMMIT, 1, &relative).is_err(), + "should reject relative HOME" + ); + let mut colon = request(ReleaseTarget::Wasm32Wasip1); + colon.tool_directories = vec![ToolDirectory(PathBuf::from("/tools:/usr/bin"))]; + assert!( + build_child(&audited, COMMIT, 1, &colon).is_err(), + "should reject PATH separator inside a tool directory" + ); + assert!( + build_child(&audited, "ABC", 1, &request(ReleaseTarget::Wasm32Wasip1)).is_err(), + "should reject a malformed commit" + ); +} From 917620d2e383471e804d19205f235412ebede83d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 09:18:58 +0530 Subject: [PATCH 042/153] Authenticate release source and helper closure in preflight Inspect the repository layout and allowlisted Git configuration before running read-only plumbing under a cleared environment, compare every tracked path and reject untracked ones, derive the committer epoch, and fingerprint the helper-source closure. Wire preflight and audit-inputs dispatch so preflight always ends at the closed qualified selector, and reject an extensionless rust-toolchain in the closed bootstrap. --- Cargo.lock | 2 + crates/trusted-server-release/Cargo.toml | 2 + crates/trusted-server-release/src/error.rs | 4 + crates/trusted-server-release/src/main.rs | 159 +++- crates/trusted-server-release/src/policy.rs | 1 + .../trusted-server-release/src/preflight.rs | 48 +- .../src/preflight/tests.rs | 96 +++ crates/trusted-server-release/src/source.rs | 708 ++++++++++++++++++ .../trusted-server-release/src/source/git.rs | 268 +++++++ .../src/source/tests.rs | 675 +++++++++++++++++ .../trusted-server-release/tests/commands.rs | 379 +++++++++- .../tests/fixtures/README.md | 4 +- scripts/release/bootstrap.sh | 6 + 13 files changed, 2325 insertions(+), 27 deletions(-) create mode 100644 crates/trusted-server-release/src/source.rs create mode 100644 crates/trusted-server-release/src/source/git.rs create mode 100644 crates/trusted-server-release/src/source/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 73b71fcced..28bdd528b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6923,6 +6923,7 @@ dependencies = [ name = "trusted-server-release" version = "0.1.0" dependencies = [ + "clap", "derive_more", "error-stack", "hex", @@ -6932,6 +6933,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", + "toml", ] [[package]] diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index ac2f69aedb..59e2ef28eb 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true rust-version = "1.95" [dependencies] +clap.workspace = true derive_more.workspace = true error-stack.workspace = true hex.workspace = true @@ -16,6 +17,7 @@ serde.workspace = true serde_json.workspace = true sha2.workspace = true tempfile.workspace = true +toml.workspace = true [lints] workspace = true diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 3fac212fc8..2d1e7b9c73 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -11,8 +11,12 @@ pub(crate) enum ReleaseError { Policy, #[display("Inherited release environment is rejected")] Environment, + #[display("Release source is not authenticated")] + Source, #[display("Production release selection is not qualified")] Unqualified, + #[display("Release inputs are incomplete")] + Incomplete, } 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 8a3d1acfbf..dbde3a216f 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -1,16 +1,163 @@ -// Document, policy and environment utilities stay unwired until source -// authentication lets preflight dispatch them. +// Atomic output and schema helpers are consumed by later producer stages. #[allow(dead_code)] mod documents; mod error; -#[allow(dead_code)] mod policy; -#[allow(dead_code)] mod preflight; +mod source; + +use std::ffi::OsString; +use std::io::Write as _; +use std::path::{Component, Path, PathBuf}; +use std::process::ExitCode; +use clap::{Parser, Subcommand}; use error::ReleaseError; use error_stack::Report; +use error_stack::fmt::ColorMode; + +/// Exit status of `audit-inputs` when any input is incomplete. +const INCOMPLETE_STATUS: u8 = 3; + +#[derive(Parser)] +#[command( + name = "trusted-server-release", + disable_help_flag = true, + disable_help_subcommand = true, + disable_version_flag = true +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Fail-closed release preflight over the current-directory source root. + Preflight { + #[arg(long)] + cohort: PathBuf, + #[arg(long = "source-commit")] + source_commit: String, + #[arg(long = "require-qualified")] + require_qualified: bool, + }, + /// Diagnostic report of pending inputs; constructs no authorization. + AuditInputs, +} + +fn main() -> ExitCode { + let inherited: Vec<(OsString, OsString)> = std::env::vars_os().collect(); + Report::set_color_mode(ColorMode::None); + let arguments: Vec = std::env::args_os().collect(); + match run(&inherited, &arguments) { + Ok(status) => status, + Err(report) => { + let _ = writeln!(std::io::stderr(), "{report:?}"); + ExitCode::FAILURE + } + } +} + +fn run( + inherited: &[(OsString, OsString)], + arguments: &[OsString], +) -> Result> { + let cli = Cli::try_parse_from(arguments).map_err(|_| Report::new(ReleaseError::Pending))?; + match cli.command { + Command::Preflight { + cohort, + source_commit, + require_qualified, + } => preflight(inherited, &cohort, &source_commit, require_qualified), + Command::AuditInputs => audit_inputs(), + } +} + +fn current_root() -> Result> { + std::env::current_dir().map_err(|error| { + Report::new(ReleaseError::Source).attach(format!("cannot resolve source root: {error}")) + }) +} + +fn names_cohort(root: &Path, cohort: &Path) -> bool { + !cohort.as_os_str().is_empty() + && !cohort + .components() + .any(|component| matches!(component, Component::ParentDir)) + && root + .join(cohort) + .components() + .eq(root.join(policy::COHORT_PATH).components()) +} + +fn preflight( + inherited: &[(OsString, OsString)], + cohort: &Path, + commit: &str, + require_qualified: bool, +) -> Result> { + let audited = preflight::audit_environment(inherited)?; + if !audited.removed_names().is_empty() { + let _ = writeln!( + std::io::stderr(), + "removed inherited operator variables: {}", + audited.removed_names().join(", ") + ); + } + let root = current_root()?; + if !names_cohort(&root, cohort) { + return Err(Report::new(ReleaseError::Policy).attach(format!( + "--cohort must name {} below the current source root", + policy::COHORT_PATH + ))); + } + let source = source::authenticate(&root, commit, &audited)?; + let inputs = policy::load_inputs(&root)?; + for (path, sha256) in inputs.input_digests() { + if source.tracked_sha256(path) != Some(sha256.as_str()) { + return Err(Report::new(ReleaseError::Source) + .attach(format!("{path} does not match the approved commit"))); + } + } + let mut stderr = std::io::stderr(); + let _ = writeln!( + stderr, + "source {} authenticated locally (epoch {}, snapshot {}, helper closure {} over {} paths); \ + remote branch-tip ancestry and ruleset validation are not established locally (Task 14)", + source.commit(), + source.epoch(), + source.snapshot().fingerprint(), + source.closure_digest(), + source.closure_paths().len() + ); + if !require_qualified { + let _ = writeln!( + stderr, + "qualified selection is required whether or not --require-qualified is passed" + ); + } + let selection = policy::select_qualified(&inputs, &[])?; + source.snapshot().verify_unchanged()?; + let _ = writeln!(stderr, "{}", selection.summary()); + Ok(ExitCode::SUCCESS) +} -fn main() -> Result<(), Report> { - Err(Report::new(ReleaseError::Pending)) +fn audit_inputs() -> Result> { + let root = current_root()?; + let inputs = policy::load_inputs(&root)?; + let audit = policy::audit_inputs(&inputs); + let bytes = documents::canonical_json(&audit.to_json(), documents::Role::Metadata)?; + std::io::stdout().write_all(&bytes).map_err(|error| { + Report::new(ReleaseError::Incomplete).attach(format!("cannot write audit report: {error}")) + })?; + if audit.is_complete() { + return Ok(ExitCode::SUCCESS); + } + let _ = writeln!( + std::io::stderr(), + "{}: release inputs are incomplete; this diagnostic authorizes nothing", + ReleaseError::Incomplete + ); + Ok(ExitCode::from(INCOMPLETE_STATUS)) } diff --git a/crates/trusted-server-release/src/policy.rs b/crates/trusted-server-release/src/policy.rs index 5cf0b4f11f..c5de48a98c 100644 --- a/crates/trusted-server-release/src/policy.rs +++ b/crates/trusted-server-release/src/policy.rs @@ -81,6 +81,7 @@ impl ReleaseTarget { } /// Returns whether the target needs the explicit macOS loader floor. + #[allow(dead_code)] pub(crate) fn is_darwin(self) -> bool { matches!(self, Self::Aarch64AppleDarwin | Self::X86_64AppleDarwin) } diff --git a/crates/trusted-server-release/src/preflight.rs b/crates/trusted-server-release/src/preflight.rs index f93b535270..400e06e467 100644 --- a/crates/trusted-server-release/src/preflight.rs +++ b/crates/trusted-server-release/src/preflight.rs @@ -13,14 +13,18 @@ use error_stack::Report; use crate::documents::lower_hex; use crate::error::ReleaseError; use crate::policy::ReleaseTarget; +use crate::source::ApprovedSource; const RELEASE_BUILD: &str = "TRUSTED_SERVER__RELEASE_BUILD"; const REMOVED_PREFIXES: [&str; 2] = ["TRUSTED_SERVER__", "EDGEZERO__"]; const SKIP_BUILD: &str = "TSJS_SKIP_BUILD"; +// The child-environment builder below is consumed by recipe execution (Task 5); +// until then only tests and the typed preflight interface reference it. +#[allow(dead_code)] const MACOS_DEPLOYMENT_TARGET: &str = "13.0"; /// Exact (uppercase) names that select compilers, flags, loaders, sources or credentials. -const REJECTED_EXACT: [&str; 26] = [ +const REJECTED_EXACT: [&str; 27] = [ "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_RUNTIME_TOKEN", @@ -28,6 +32,7 @@ const REJECTED_EXACT: [&str; 26] = [ "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "CARGO", + "CRATE_CC_NO_DEFAULTS", "DEVELOPER_DIR", "GH_TOKEN", "GITHUB_TOKEN", @@ -50,23 +55,29 @@ const REJECTED_EXACT: [&str; 26] = [ ]; /// Uppercase prefixes whose whole family is recipe-selected or forbidden. -const REJECTED_PREFIXES: [&str; 17] = [ +const REJECTED_PREFIXES: [&str; 23] = [ "AWS_LC_FIPS_SYS_", "AWS_LC_SYS_", "BINDGEN_", "CARGO_", + "CMAKE", "DYLD_", "FASTLY_", "GIT_", "LD_", - "LIBSQLITE3_SYS_", + "LIBSQLITE3_", "NPM_CONFIG_", + "OPENSSL_", "PKG_CONFIG_", + "RING_", "RUSTC_", "RUSTDOC_", "RUSTUP_", "SIGSTORE_", + "SQLCIPHER_", "SQLITE3_", + "SQLITE_", + "VCPKGRS_", "VITE_", ]; @@ -184,9 +195,11 @@ fn is_reviewed(name: &str, value: &str) -> bool { } /// A tool directory produced by authenticated acquisition (Task 8). +#[allow(dead_code)] pub(crate) struct ToolDirectory(PathBuf); /// Recipe-selected private locations for one child build. +#[allow(dead_code)] pub(crate) struct ChildRequest { pub(crate) target: ReleaseTarget, pub(crate) home: PathBuf, @@ -195,11 +208,13 @@ pub(crate) struct ChildRequest { } /// An allowlisted child environment constructed from scratch. +#[allow(dead_code)] pub(crate) struct ChildEnvironment { variables: BTreeMap, removed: Vec, } +#[allow(dead_code)] impl ChildEnvironment { /// Every variable the child receives; nothing is inherited. pub(crate) fn variables(&self) -> &BTreeMap { @@ -217,6 +232,7 @@ impl ChildEnvironment { } } +#[allow(dead_code)] fn absolute_text<'a>(label: &str, path: &'a Path) -> Result<&'a str, Report> { path.to_str() .filter(|text| path.is_absolute() && !text.contains([':', '\0'])) @@ -227,6 +243,7 @@ fn absolute_text<'a>(label: &str, path: &'a Path) -> Result<&'a str, Report Result> { + build_child(audited, source.commit(), source.epoch(), request) +} + #[cfg(test)] mod tests; diff --git a/crates/trusted-server-release/src/preflight/tests.rs b/crates/trusted-server-release/src/preflight/tests.rs index b28bb3918c..def196a376 100644 --- a/crates/trusted-server-release/src/preflight/tests.rs +++ b/crates/trusted-server-release/src/preflight/tests.rs @@ -172,6 +172,65 @@ fn rejects_native_build_selectors_and_target_variants() { ); } +#[test] +fn rejects_sqlite_selectors() { + assert_each_rejected( + "sqlite", + &[ + "LIBSQLITE3_FLAGS", + "SQLITE_MAX_VARIABLE_NUMBER", + "SQLCIPHER_LIB_DIR", + ], + ); +} + +#[test] +fn rejects_ring_and_openssl_selectors() { + assert_each_rejected( + "crypto", + &["RING_PREGENERATE_ASM", "OPENSSL_DIR", "OPENSSL_STATIC"], + ); +} + +#[test] +fn rejects_cmake_selectors() { + assert_each_rejected( + "cmake", + &["CMAKE", "CMAKE_TOOLCHAIN_FILE", "CMAKE_GENERATOR"], + ); +} + +#[test] +fn rejects_vcpkg_rs_selectors() { + assert_each_rejected("vcpkg", &["VCPKGRS_DYNAMIC", "VCPKGRS_TRIPLET"]); +} + +#[test] +fn rejects_cc_crate_controls() { + assert_each_rejected( + "cc-crate", + &[ + "CRATE_CC_NO_DEFAULTS", + "CC_ENABLE_DEBUG_OUTPUT", + "CC_FORCE_DISABLE", + "CC_SHELL_ESCAPED_FLAGS", + ], + ); +} + +#[test] +fn rejects_lowercase_target_suffixed_native_flags() { + assert_each_rejected( + "target-suffixed", + &[ + "CFLAGS_aarch64_apple_darwin", + "CXXFLAGS_x86_64_unknown_linux_gnu", + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + "AWS_LC_SYS_STATIC_x86_64_apple_darwin", + ], + ); +} + #[test] fn rejects_loader_injection() { assert_each_rejected( @@ -331,6 +390,7 @@ fn child_environment_contains_exactly_expected_linux_keys() { .expect("should build child environment"); let expected = BTreeMap::from([ ("AWS_LC_SYS_USE_SYSTEM", "0"), + ("AWS_LC_SYS_USE_SYSTEM_x86_64_unknown_linux_gnu", "0"), ("CARGO_HOME", "/private/release/cargo-home"), ("HOME", "/private/release/home"), ("LC_ALL", "C"), @@ -416,3 +476,39 @@ fn child_environment_rejects_unsafe_paths_and_commits() { "should reject a malformed commit" ); } + +#[test] +fn child_environment_sets_target_suffixed_aws_lc_selector() { + let audited = audit_environment(&[]).expect("should audit empty snapshot"); + for (target, name) in [ + ( + ReleaseTarget::Wasm32Wasip1, + "AWS_LC_SYS_USE_SYSTEM_wasm32_wasip1", + ), + ( + ReleaseTarget::Aarch64AppleDarwin, + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + ), + ( + ReleaseTarget::X86_64AppleDarwin, + "AWS_LC_SYS_USE_SYSTEM_x86_64_apple_darwin", + ), + ] { + let child = build_child(&audited, COMMIT, 1, &request(target)) + .expect("should build child environment"); + assert_eq!( + child.variables().get(name).map(String::as_str), + Some("0"), + "should pin the target-suffixed selector for {target:?}" + ); + assert_eq!( + child + .variables() + .keys() + .filter(|key| key.starts_with("AWS_LC_SYS_USE_SYSTEM")) + .count(), + 2, + "should set only the generic and selected-target selectors" + ); + } +} diff --git a/crates/trusted-server-release/src/source.rs b/crates/trusted-server-release/src/source.rs new file mode 100644 index 0000000000..b633096a42 --- /dev/null +++ b/crates/trusted-server-release/src/source.rs @@ -0,0 +1,708 @@ +//! Local authentication of the approved release source checkout. +//! +//! The repository layout and configuration are inspected with bounded direct +//! reads before Git runs, because Git itself would execute or redirect through +//! several configuration channels. Only read-only plumbing then runs under a +//! cleared environment. Remote branch-tip ancestry and ruleset validation are +//! hosted operations (Task 14) and are not established here. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::documents::{Role, lower_hex, parse_json, read_bounded}; +use crate::error::ReleaseError; +use crate::preflight::AuditedEnvironment; +use git::Git; + +mod git; + +const PROBLEMS_SHOWN: usize = 20; +const RELEASE_CRATE_PREFIX: &str = "crates/trusted-server-release/"; + +/// Repository channels that rewrite history, objects or checkout attributes. +const FORBIDDEN_GIT_PATHS: [&str; 7] = [ + "commondir", + "objects/info/alternates", + "objects/info/http-alternates", + "info/grafts", + "info/attributes", + "shallow", + "refs/replace", +]; + +/// Tracked symlinks reviewed for release sources, with their exact targets. +const REVIEWED_SYMLINKS: [(&str, &str); 1] = [("CLAUDE.md", "AGENTS.md")]; + +/// Untracked coordination paths reviewed for release sources. Empty for now. +const REVIEWED_UNTRACKED: [&str; 0] = []; + +/// Root files always included in the helper-source closure. +const CLOSURE_FILES: [&str; 6] = [ + ".cargo/config.toml", + ".tool-versions", + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", + "scripts/release/bootstrap.sh", +]; + +/// Path-dependency manifests that must be listed as workspace members. +const REQUIRED_MEMBER_MANIFESTS: [&str; 1] = ["crates/trusted-server-openrtb/Cargo.toml"]; + +fn source_error(message: impl Into) -> Report { + Report::new(ReleaseError::Source).attach(message.into()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + Regular, + Executable, + Symlink, +} + +impl Mode { + fn label(self) -> &'static str { + match self { + Self::Regular => "100644", + Self::Executable => "100755", + Self::Symlink => "120000", + } + } +} + +/// Tracked path records: mode and SHA-256 of content or symlink target. +type Records = BTreeMap; + +/// Fingerprint of every tracked worktree path at the approved commit. +pub(crate) struct SourceSnapshot { + root: PathBuf, + records: Records, + fingerprint: String, +} + +impl SourceSnapshot { + /// SHA-256 over sorted path, mode and content-digest records. + pub(crate) fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// Rescans the worktree and fails on any tracked or untracked change. + /// + /// # Errors + /// + /// Fails on changed content, modes, file types or hard-link counts, + /// missing tracked paths, or any new untracked path. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + compare_worktree(&self.root, &self.records) + } +} + +/// A locally authenticated source checkout; constructed only by [`authenticate`]. +pub(crate) struct ApprovedSource { + commit: String, + epoch: u64, + snapshot: SourceSnapshot, + closure_digest: String, + closure_paths: Vec, +} + +impl ApprovedSource { + /// The approved 40-hex source commit. + pub(crate) fn commit(&self) -> &str { + &self.commit + } + + /// `SOURCE_DATE_EPOCH` derived from the approved committer timestamp. + pub(crate) fn epoch(&self) -> u64 { + self.epoch + } + + /// The tracked worktree snapshot for before/after mutation checks. + pub(crate) fn snapshot(&self) -> &SourceSnapshot { + &self.snapshot + } + + /// Audit fingerprint of the helper-source closure. It is not authority. + pub(crate) fn closure_digest(&self) -> &str { + &self.closure_digest + } + + /// Sorted paths covered by [`Self::closure_digest`]. + pub(crate) fn closure_paths(&self) -> &[String] { + &self.closure_paths + } + + /// SHA-256 of a tracked path's content at the approved commit. + pub(crate) fn tracked_sha256(&self, path: &str) -> Option<&str> { + self.snapshot + .records + .get(path) + .map(|(_, sha256)| sha256.as_str()) + } +} + +/// Authenticates the checkout at `root` as exactly the approved commit. +/// +/// # Errors +/// +/// Fails on an extensionless `rust-toolchain`, malformed commit, unsupported +/// repository layout or configuration, a HEAD other than the commit, any +/// tracked-path difference, untracked path, or incomplete helper closure. +pub(crate) fn authenticate( + root: &Path, + commit: &str, + audited: &AuditedEnvironment, +) -> Result> { + match fs::symlink_metadata(root.join("rust-toolchain")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + _ => { + return Err(source_error( + "extensionless rust-toolchain is rejected before toolchain selection", + )); + } + } + if !lower_hex(commit, 40) { + return Err(source_error( + "approved source commit must be exactly 40 lowercase hex digits", + )); + } + check_layout(root)?; + let git = Git::locate(root, audited)?; + let object_type = git.run(&["cat-file", "-t", commit])?; + if object_type != b"commit\n" { + return Err(source_error(format!("{commit} is not a commit object"))); + } + let head = git.run(&["rev-parse", "--verify", "HEAD"])?; + if head.strip_suffix(b"\n") != Some(commit.as_bytes()) { + return Err(source_error("HEAD does not resolve to the approved commit")); + } + let epoch = committer_epoch(&git.run(&["cat-file", "commit", commit])?)?; + let entries = parse_tree(&git.run(&["ls-tree", "-r", "-z", "--full-tree", commit])?)?; + let (records, manifest) = tracked_records(&git, &entries)?; + compare_worktree(root, &records)?; + let (closure_digest, closure_paths) = helper_closure(&records, &manifest)?; + let fingerprint = digest_records(records.iter()); + Ok(ApprovedSource { + commit: commit.to_owned(), + epoch, + snapshot: SourceSnapshot { + root: root.to_path_buf(), + records, + fingerprint, + }, + closure_digest, + closure_paths, + }) +} + +fn check_layout(root: &Path) -> Result<(), Report> { + let git_dir = root.join(".git"); + let metadata = fs::symlink_metadata(&git_dir) + .map_err(|error| source_error(format!(".git cannot be inspected: {error}")))?; + if !metadata.is_dir() { + return Err(source_error( + ".git must be a real directory; gitfile, symlink and linked-worktree \ + indirections are unsupported", + )); + } + for relative in FORBIDDEN_GIT_PATHS { + match fs::symlink_metadata(git_dir.join(relative)) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Ok(_) => { + return Err(source_error(format!( + ".git/{relative} is present; repository indirection or rewriting is unsupported" + ))); + } + Err(error) => { + return Err(source_error(format!( + ".git/{relative} cannot be inspected: {error}" + ))); + } + } + } + let packed = git_dir.join("packed-refs"); + if fs::symlink_metadata(&packed).is_ok() { + let bytes = read_bounded(&packed, Role::Metadata)?.bytes; + let replaced = bytes.split(|byte| *byte == b'\n').any(|line| { + line.splitn(2, |byte| *byte == b' ') + .nth(1) + .is_some_and(|name| name.starts_with(b"refs/replace/")) + }); + if replaced { + return Err(source_error( + ".git/packed-refs lists refs/replace references", + )); + } + } + let config = read_bounded(&git_dir.join("config"), Role::Metadata)?; + let text = + std::str::from_utf8(&config.bytes).map_err(|_| source_error("git config must be UTF-8"))?; + check_git_config(text) +} + +/// Accepts only allowlisted repository configuration keys and values. +/// +/// # Errors +/// +/// Fails on any key outside the allowlist, escapes or continuations, unknown +/// section syntax, or unreviewed values. Values are never echoed. +fn check_git_config(text: &str) -> Result<(), Report> { + let mut section: Option<(String, bool)> = None; + for (index, raw) in text.lines().enumerate() { + let line = raw.trim(); + let number = index + 1; + if line.is_empty() || line.starts_with(['#', ';']) { + continue; + } + if line.contains('\\') { + return Err(source_error(format!( + "git config line {number} uses escapes or continuations" + ))); + } + if let Some(header) = line.strip_prefix('[') { + section = Some(config_section(header, number)?); + continue; + } + let (name, subsection) = section.as_ref().ok_or_else(|| { + source_error(format!("git config line {number} is outside a section")) + })?; + let (key, value) = match line.split_once('=') { + Some((key, value)) => (key.trim().to_ascii_lowercase(), Some(value.trim())), + None => (line.to_ascii_lowercase(), None), + }; + if !config_value_allowed(name, *subsection, &key, value) { + return Err(source_error(format!( + "git config key {name}.{key} on line {number} is not allowlisted or has an unreviewed value" + ))); + } + } + Ok(()) +} + +fn config_section(header: &str, number: usize) -> Result<(String, bool), Report> { + let invalid = || { + source_error(format!( + "git config line {number} has an unsupported section" + )) + }; + let header = header.strip_suffix(']').ok_or_else(invalid)?; + let (name, subsection) = match header.split_once(' ') { + Some((name, quoted)) => { + let inner = quoted + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .ok_or_else(invalid)?; + if inner.contains('"') { + return Err(invalid()); + } + (name, true) + } + None => (header, false), + }; + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(invalid()); + } + Ok((name.to_ascii_lowercase(), subsection)) +} + +fn config_value_allowed(section: &str, subsection: bool, key: &str, value: Option<&str>) -> bool { + let boolean = matches!(value, Some("true" | "false")); + let plain = value.is_some_and(|value| !value.is_empty() && !value.contains(['"', '#', ';'])); + match (section, subsection, key) { + ("core", false, "repositoryformatversion") => value == Some("0"), + ("core", false, "bare") => value == Some("false"), + ( + "core", + false, + "filemode" | "logallrefupdates" | "ignorecase" | "precomposeunicode" | "symlinks", + ) => boolean, + ("remote", true, "url" | "fetch") | ("branch", true, "remote" | "merge") => plain, + _ => false, + } +} + +/// Parses the committer timestamp from a raw commit object header. +/// +/// # Errors +/// +/// Fails unless the header has exactly one well-formed committer line. +fn committer_epoch(raw: &[u8]) -> Result> { + let end = raw + .windows(2) + .position(|window| window == b"\n\n") + .unwrap_or(raw.len()); + let header = std::str::from_utf8(&raw[..end]) + .map_err(|_| source_error("commit header must be UTF-8"))?; + let committers: Vec<&str> = header + .lines() + .filter_map(|line| line.strip_prefix("committer ")) + .collect(); + let [committer] = committers.as_slice() else { + return Err(source_error("commit must have exactly one committer line")); + }; + let invalid = || source_error("committer line must end with `> <+|-HHMM>`"); + let (_, stamp) = committer.rsplit_once('>').ok_or_else(invalid)?; + let (epoch, zone) = stamp + .strip_prefix(' ') + .and_then(|stamp| stamp.split_once(' ')) + .ok_or_else(invalid)?; + let zone_valid = zone.len() == 5 + && zone.starts_with(['+', '-']) + && zone[1..].bytes().all(|byte| byte.is_ascii_digit()); + if epoch.is_empty() || !epoch.bytes().all(|byte| byte.is_ascii_digit()) || !zone_valid { + return Err(invalid()); + } + epoch.parse().map_err(|_| invalid()) +} + +struct TreeEntry { + path: String, + mode: Mode, + oid: String, +} + +fn parse_tree(output: &[u8]) -> Result, Report> { + let mut entries = Vec::new(); + for record in output + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + { + let text = + std::str::from_utf8(record).map_err(|_| source_error("tracked paths must be UTF-8"))?; + let (meta, path) = text + .split_once('\t') + .ok_or_else(|| source_error("git ls-tree record is malformed"))?; + check_tracked_path(path)?; + let fields: Vec<&str> = meta.split(' ').collect(); + let [mode, kind, oid] = fields.as_slice() else { + return Err(source_error("git ls-tree record is malformed")); + }; + let mode = match (*mode, *kind) { + ("100644", "blob") => Mode::Regular, + ("100755", "blob") => Mode::Executable, + ("120000", "blob") => Mode::Symlink, + ("160000", _) => { + return Err(source_error(format!( + "tracked submodule {path} is not reviewed" + ))); + } + _ => { + return Err(source_error(format!( + "tracked path {path} has unsupported mode {mode}" + ))); + } + }; + if !lower_hex(oid, 40) { + return Err(source_error("git ls-tree object id is malformed")); + } + if mode == Mode::Symlink && !REVIEWED_SYMLINKS.iter().any(|(link, _)| *link == path) { + return Err(source_error(format!( + "tracked symlink {path} is not reviewed" + ))); + } + entries.push(TreeEntry { + path: path.to_owned(), + mode, + oid: (*oid).to_owned(), + }); + } + Ok(entries) +} + +fn check_tracked_path(path: &str) -> Result<(), Report> { + let unsafe_component = path.split('/').any(|component| { + component.is_empty() + || component == "." + || component == ".." + || component.eq_ignore_ascii_case(".git") + }); + if unsafe_component { + return Err(source_error("tracked path has an unsafe component")); + } + Ok(()) +} + +fn tracked_records( + git: &Git, + entries: &[TreeEntry], +) -> Result<(Records, Vec), Report> { + let mut records = Records::new(); + let mut manifest = None; + git.read_blobs(entries, |entry, bytes| { + if entry.mode == Mode::Symlink { + let reviewed = REVIEWED_SYMLINKS + .iter() + .any(|(link, target)| *link == entry.path && target.as_bytes() == bytes); + if !reviewed { + return Err(source_error(format!( + "tracked symlink {} has an unreviewed target", + entry.path + ))); + } + } + if entry.path == "Cargo.toml" { + manifest = Some(bytes.to_vec()); + } + records.insert( + entry.path.clone(), + (entry.mode, hex::encode(Sha256::digest(bytes))), + ); + Ok(()) + })?; + let manifest = manifest.ok_or_else(|| source_error("root Cargo.toml is not tracked"))?; + Ok((records, manifest)) +} + +fn digest_records<'a>(records: impl Iterator) -> String { + let mut hasher = Sha256::new(); + for (path, (mode, sha256)) in records { + hasher.update(path.as_bytes()); + hasher.update(b"\0"); + hasher.update(mode.label().as_bytes()); + hasher.update(b"\0"); + hasher.update(sha256.as_bytes()); + hasher.update(b"\n"); + } + hex::encode(hasher.finalize()) +} + +fn compare_worktree(root: &Path, expected: &Records) -> Result<(), Report> { + let mut ancestors = BTreeSet::new(); + for path in expected.keys() { + let mut prefix = path.as_str(); + while let Some((parent, _)) = prefix.rsplit_once('/') { + ancestors.insert(parent); + prefix = parent; + } + } + let mut problems = Vec::new(); + let mut seen = BTreeSet::new(); + let mut directories = vec![String::new()]; + while let Some(directory) = directories.pop() { + let absolute = root.join(&directory); + let mut names = fs::read_dir(&absolute) + .and_then(|entries| { + entries + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>() + }) + .map_err(|error| source_error(format!("cannot list {directory:?}: {error}")))?; + names.sort(); + for name in names { + let Some(name) = name.to_str() else { + problems.push(format!( + "untracked non-UTF-8 path {}", + name.to_string_lossy() + )); + continue; + }; + let relative = if directory.is_empty() { + name.to_owned() + } else { + format!("{directory}/{name}") + }; + if relative == ".git" { + continue; + } + observe_path( + root, + &relative, + expected, + &ancestors, + &mut directories, + &mut problems, + ); + seen.insert(relative); + } + } + for path in expected.keys().filter(|path| !seen.contains(*path)) { + problems.push(format!("tracked path {path} is missing")); + } + if problems.is_empty() { + return Ok(()); + } + let count = problems.len(); + problems.truncate(PROBLEMS_SHOWN); + Err(source_error(format!( + "worktree differs from the approved commit ({count} problems): {}", + problems.join("; ") + ))) +} + +fn observe_path( + root: &Path, + relative: &str, + expected: &Records, + ancestors: &BTreeSet<&str>, + directories: &mut Vec, + problems: &mut Vec, +) { + let path = root.join(relative); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + problems.push(format!("{relative} cannot be inspected: {error}")); + return; + } + }; + if metadata.is_dir() && ancestors.contains(relative) { + directories.push(relative.to_owned()); + return; + } + let Some(record) = expected.get(relative) else { + if !REVIEWED_UNTRACKED.contains(&relative) { + problems.push(format!("untracked path {relative}")); + } + return; + }; + let observed = if metadata.file_type().is_symlink() { + fs::read_link(&path) + .map(|target| { + ( + Mode::Symlink, + hex::encode(Sha256::digest(target.as_os_str().as_bytes())), + ) + }) + .map_err(|error| error.to_string()) + } else if metadata.is_file() && metadata.nlink() != 1 { + Err(format!("has {} hard links", metadata.nlink())) + } else if metadata.is_file() { + let mode = if metadata.permissions().mode() & 0o100 == 0 { + Mode::Regular + } else { + Mode::Executable + }; + read_bounded(&path, Role::Executable) + .map(|raw| (mode, raw.sha256)) + .map_err(|report| format!("{report:?}")) + } else { + Err("has an unsupported file type".to_owned()) + }; + match observed { + Ok(observed) if observed == *record => {} + Ok(_) => problems.push(format!( + "{relative} differs from the approved commit in mode, type or content" + )), + Err(reason) => problems.push(format!("{relative} {reason}")), + } +} + +fn helper_closure( + records: &Records, + manifest: &[u8], +) -> Result<(String, Vec), Report> { + let text = + std::str::from_utf8(manifest).map_err(|_| source_error("root Cargo.toml must be UTF-8"))?; + let table: toml::Table = toml::from_str(text) + .map_err(|error| source_error(format!("root Cargo.toml is invalid: {error}")))?; + let members = table + .get("workspace") + .and_then(|workspace| workspace.get("members")) + .and_then(toml::Value::as_array) + .ok_or_else(|| source_error("root Cargo.toml must list [workspace] members"))?; + let mut paths = BTreeSet::new(); + let mut member_manifests = BTreeSet::new(); + for member in members { + let member = member + .as_str() + .ok_or_else(|| source_error("workspace members must be strings"))?; + if member.contains(['*', '?', '[', ']']) { + return Err(source_error(format!( + "workspace member {member} uses a glob; the closure requires explicit members" + ))); + } + let manifest = format!("{member}/Cargo.toml"); + check_tracked_path(&manifest)?; + if !records.contains_key(&manifest) { + return Err(source_error(format!( + "workspace member manifest {manifest} is not tracked" + ))); + } + member_manifests.insert(manifest); + } + for required in REQUIRED_MEMBER_MANIFESTS { + if !member_manifests.contains(required) { + return Err(source_error(format!( + "{required} must be a listed workspace member manifest" + ))); + } + } + for path in CLOSURE_FILES { + if !records.contains_key(path) { + return Err(source_error(format!( + "helper closure file {path} is not tracked" + ))); + } + paths.insert(path.to_owned()); + } + paths.extend(member_manifests); + paths.extend( + records + .keys() + .filter(|path| path.starts_with(RELEASE_CRATE_PREFIX)) + .cloned(), + ); + let digest = digest_records(paths.iter().filter_map(|path| records.get_key_value(path))); + Ok((digest, paths.into_iter().collect())) +} + +/// Joins a JS input inventory to the approved source commit. +/// +/// # Errors +/// +/// Fails unless the inventory is strict JSON with the reviewed field set, +/// schema version 1 and a `source_commit` equal to the approved commit. +// Prepared-input composition (Task 9) consumes this join. +#[allow(dead_code)] +pub(crate) fn join_inventory( + source: &ApprovedSource, + bytes: &[u8], +) -> Result<(), Report> { + let value = parse_json(bytes, Role::Metadata)?; + let object = value + .as_object() + .ok_or_else(|| source_error("inventory must be an object"))?; + let keys: BTreeSet<&str> = object.keys().map(String::as_str).collect(); + if keys + != BTreeSet::from([ + "materials", + "modules", + "schema_version", + "source_commit", + "tools", + ]) + { + return Err(source_error( + "inventory must contain exactly the reviewed fields", + )); + } + let arrays = ["materials", "modules", "tools"] + .iter() + .all(|key| object[*key].is_array()); + if object["schema_version"] != 1 || !arrays { + return Err(source_error( + "inventory schema version or field types are invalid", + )); + } + match object["source_commit"].as_str() { + Some(commit) if commit == source.commit => Ok(()), + _ => Err(source_error( + "inventory source_commit does not match the approved commit", + )), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/source/git.rs b/crates/trusted-server-release/src/source/git.rs new file mode 100644 index 0000000000..8ca02475dd --- /dev/null +++ b/crates/trusted-server-release/src/source/git.rs @@ -0,0 +1,268 @@ +//! Bounded read-only Git plumbing under a cleared, hardened environment. + +use std::ffi::OsString; +use std::fs; +use std::io::{self, BufRead, BufReader, Read, Write as _}; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; + +use error_stack::Report; + +use super::{TreeEntry, source_error}; +use crate::error::ReleaseError; +use crate::preflight::AuditedEnvironment; + +const STDOUT_LIMIT: u64 = 64 * 1024 * 1024; +const STDERR_LIMIT: u64 = 64 * 1024; +const STDERR_SHOWN: usize = 4 * 1024; +const BLOB_LIMIT: u64 = 64 * 1024 * 1024; + +/// Read-only Git plumbing under a cleared, hardened environment. +pub(super) struct Git { + program: PathBuf, + search_path: String, + root: PathBuf, + home: tempfile::TempDir, +} + +impl Git { + pub(super) fn locate( + root: &Path, + audited: &AuditedEnvironment, + ) -> Result> { + let search_path = audited + .search_path() + .ok_or_else(|| source_error("PATH is required to locate git"))?; + let program = search_path + .split(':') + .filter(|directory| directory.starts_with('/')) + .map(|directory| Path::new(directory).join("git")) + .find(|candidate| { + fs::metadata(candidate).is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + }) + .ok_or_else(|| source_error("git was not found on an absolute PATH entry"))?; + let home = tempfile::Builder::new() + .prefix("release-git-home-") + .tempdir() + .map_err(|error| source_error(format!("cannot create private git HOME: {error}")))?; + Ok(Self { + program, + search_path: search_path.to_owned(), + root: root.to_path_buf(), + home, + }) + } + + fn command(&self, args: &[&str]) -> Command { + let mut git_dir = OsString::from("--git-dir="); + git_dir.push(self.root.join(".git")); + let mut work_tree = OsString::from("--work-tree="); + work_tree.push(&self.root); + let mut command = Command::new(&self.program); + command + .arg(git_dir) + .arg(work_tree) + .arg("--no-replace-objects") + .args([ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + "protocol.allow=never", + ]) + .args(args) + .current_dir(&self.root) + .env_clear() + .env("PATH", &self.search_path) + .env("HOME", self.home.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_NO_REPLACE_OBJECTS", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_OPTIONAL_LOCKS", "0") + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + } + + fn spawn(&self, args: &[&str], stdin: bool) -> Result> { + let mut command = self.command(args); + if stdin { + command.stdin(Stdio::piped()); + } + command + .spawn() + .map_err(|error| source_error(format!("cannot run git {}: {error}", args.join(" ")))) + } + + pub(super) fn run(&self, args: &[&str]) -> Result, Report> { + let mut child = self.spawn(args, false)?; + let stderr = capture_stderr(&mut child); + let mut output = Vec::new(); + let read = match child.stdout.take() { + Some(stdout) => stdout + .take(STDOUT_LIMIT + 1) + .read_to_end(&mut output) + .map_err(|error| error.to_string()), + None => Err("stdout is unavailable".to_owned()), + }; + let oversized = output.len() as u64 > STDOUT_LIMIT; + finish( + child, + stderr, + args, + read.and_then(|_| { + if oversized { + Err("stdout exceeds its byte limit".to_owned()) + } else { + Ok(()) + } + }), + )?; + Ok(output) + } + + pub(super) fn read_blobs( + &self, + entries: &[TreeEntry], + mut visit: impl FnMut(&TreeEntry, &[u8]) -> Result<(), Report>, + ) -> Result<(), Report> { + let args = ["cat-file", "--batch"]; + let mut child = self.spawn(&args, true)?; + let stderr = capture_stderr(&mut child); + let request: Vec = entries + .iter() + .flat_map(|entry| entry.oid.bytes().chain(std::iter::once(b'\n'))) + .collect(); + let writer = child + .stdin + .take() + .map(|mut stdin| thread::spawn(move || stdin.write_all(&request))); + let mut visited = Ok(()); + let read = match child.stdout.take() { + Some(stdout) => { + let mut reader = BufReader::new(stdout); + read_batch(&mut reader, entries, &mut |entry, bytes| { + visited = visit(entry, bytes); + visited.is_ok() + }) + } + None => Err("stdout is unavailable".to_owned()), + }; + if read.is_err() { + let _ = child.kill(); + } + let written = match writer.map(thread::JoinHandle::join) { + Some(Ok(Ok(()))) => Ok(()), + Some(Ok(Err(error))) => Err(error.to_string()), + Some(Err(_)) => Err("stdin writer panicked".to_owned()), + None => Err("stdin is unavailable".to_owned()), + }; + let finished = finish(child, stderr, &args, read.and(written)); + visited?; + finished + } +} + +fn read_batch( + reader: &mut impl BufRead, + entries: &[TreeEntry], + visit: &mut impl FnMut(&TreeEntry, &[u8]) -> bool, +) -> Result<(), String> { + for entry in entries { + let mut header = Vec::new(); + reader + .take(128) + .read_until(b'\n', &mut header) + .map_err(|error| error.to_string())?; + let header = std::str::from_utf8(&header).map_err(|_| "batch header is not UTF-8")?; + let fields: Vec<&str> = header.trim_end_matches('\n').split(' ').collect(); + let size = match fields.as_slice() { + [oid, "blob", size] if *oid == entry.oid => size + .parse::() + .map_err(|_| "batch blob size is malformed".to_owned())?, + _ => return Err(format!("object for {} is not a readable blob", entry.path)), + }; + if size > BLOB_LIMIT { + return Err(format!( + "{} exceeds the tracked blob byte limit", + entry.path + )); + } + let mut bytes = vec![0; usize::try_from(size).map_err(|_| "blob size overflows")?]; + reader + .read_exact(&mut bytes) + .map_err(|error| error.to_string())?; + let mut terminator = [0]; + reader + .read_exact(&mut terminator) + .map_err(|error| error.to_string())?; + if terminator != *b"\n" || !visit(entry, &bytes) { + return Err(format!("batch output for {} is rejected", entry.path)); + } + } + Ok(()) +} + +type StderrCapture = Option>>; + +fn capture_stderr(child: &mut Child) -> StderrCapture { + child.stderr.take().map(|stderr| { + thread::spawn(move || { + let mut captured = Vec::new(); + let mut stderr = stderr; + let _ = (&mut stderr).take(STDERR_LIMIT).read_to_end(&mut captured); + let _ = io::copy(&mut stderr, &mut io::sink()); + captured + }) + }) +} + +fn finish( + mut child: Child, + stderr: StderrCapture, + args: &[&str], + read: Result<(), String>, +) -> Result<(), Report> { + if read.is_err() { + let _ = child.kill(); + } + let status = child + .wait() + .map_err(|error| source_error(format!("cannot wait for git: {error}")))?; + let detail = stderr + .and_then(|handle| handle.join().ok()) + .unwrap_or_default(); + let mut detail = String::from_utf8_lossy(&detail).into_owned(); + if detail.len() > STDERR_SHOWN { + let mut cut = STDERR_SHOWN; + while !detail.is_char_boundary(cut) { + cut -= 1; + } + detail.truncate(cut); + detail.push_str(" [truncated]"); + } + let command = format!("git {}", args.join(" ")); + if let Err(reason) = read { + return Err(source_error(format!( + "{command} output is rejected: {reason}; stderr: {}", + detail.trim() + ))); + } + if !status.success() { + return Err(source_error(format!( + "{command} failed with {status}; stderr: {}", + detail.trim() + ))); + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/source/tests.rs b/crates/trusted-server-release/src/source/tests.rs new file mode 100644 index 0000000000..ead2848fa8 --- /dev/null +++ b/crates/trusted-server-release/src/source/tests.rs @@ -0,0 +1,675 @@ +use std::fs; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::process::Command; + +use serde_json::{Value, json}; +use tempfile::{TempDir, tempdir}; + +use super::*; +use crate::policy::ReleaseTarget; +use crate::preflight::{ChildRequest, audit_environment, child_environment}; + +const EPOCH: u64 = 1_700_000_000; + +struct Fixture { + parent: TempDir, + root: PathBuf, + commit: String, +} + +fn host_path() -> OsString { + std::env::var_os("PATH").expect("should have a PATH to locate git") +} + +fn fixture_git(root: &Path, home: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .env_clear() + .env("PATH", host_path()) + .env("HOME", home) + .env("LC_ALL", "C") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Fictional Author") + .env("GIT_AUTHOR_EMAIL", "author@example.com") + .env("GIT_AUTHOR_DATE", format!("{EPOCH} +0000")) + .env("GIT_COMMITTER_NAME", "Fictional Committer") + .env("GIT_COMMITTER_EMAIL", "committer@example.com") + .env("GIT_COMMITTER_DATE", format!("{EPOCH} +0000")) + .output() + .expect("should run fixture git"); + assert!( + output.status.success(), + "should run fixture git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("should emit UTF-8") + .trim() + .to_owned() +} + +fn write(root: &Path, path: &str, bytes: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().expect("should have parent")).expect("should create dirs"); + fs::write(path, bytes).expect("should write fixture file"); +} + +impl Fixture { + fn new() -> Self { + let parent = tempdir().expect("should create fixture parent"); + let root = parent.path().join("source"); + fs::create_dir(&root).expect("should create fixture root"); + write( + &root, + "Cargo.toml", + b"[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/trusted-server-openrtb\",\n \"crates/trusted-server-release\",\n]\n", + ); + write(&root, "Cargo.lock", b"# fictional lock\nversion = 4\n"); + write( + &root, + "rust-toolchain.toml", + b"[toolchain]\nchannel = \"1.95.0\"\n", + ); + write(&root, ".cargo/config.toml", b"[alias]\n"); + write(&root, ".tool-versions", b"rust 1.95.0\n"); + write( + &root, + "scripts/release/bootstrap.sh", + b"#!/bin/sh\nexit 1\n", + ); + fs::set_permissions( + root.join("scripts/release/bootstrap.sh"), + fs::Permissions::from_mode(0o755), + ) + .expect("should mark bootstrap executable"); + write( + &root, + "crates/trusted-server-openrtb/Cargo.toml", + b"[package]\nname = \"fictional-openrtb\"\n", + ); + write( + &root, + "crates/trusted-server-release/Cargo.toml", + b"[package]\nname = \"fictional-release\"\n", + ); + write( + &root, + "crates/trusted-server-release/src/main.rs", + b"fn main() {}\n", + ); + write(&root, "crates/other/src/lib.rs", b"// outside closure\n"); + write(&root, "AGENTS.md", b"# Fictional agents\n"); + symlink("AGENTS.md", root.join("CLAUDE.md")).expect("should create reviewed symlink"); + let mut fixture = Self { + parent, + root, + commit: String::new(), + }; + fixture.git(&["init", "-q"]); + fixture.commit_all(); + fixture + } + + fn home(&self) -> PathBuf { + let home = self.parent.path().join("fixture-home"); + fs::create_dir_all(&home).expect("should create fixture home"); + home + } + + fn git(&self, args: &[&str]) -> String { + fixture_git(&self.root, &self.home(), args) + } + + fn commit_all(&mut self) { + self.git(&["add", "-A"]); + self.git(&["commit", "-q", "--allow-empty", "-m", "fixture"]); + self.commit = self.git(&["rev-parse", "HEAD"]); + } + + fn audited(&self) -> AuditedEnvironment { + audit_environment(&[(OsString::from("PATH"), host_path())]) + .expect("should audit fixture environment") + } + + fn authenticate(&self) -> Result> { + authenticate(&self.root, &self.commit, &self.audited()) + } + + fn rejection(&self) -> String { + format!( + "{:?}", + self.authenticate() + .err() + .expect("should reject fixture source") + ) + } +} + +fn assert_rejects(fixture: &Fixture, needle: &str) { + let text = fixture.rejection(); + assert!( + text.contains(needle), + "should explain rejection with `{needle}`, got: {text}" + ); +} + +#[test] +fn authenticates_clean_fixture_with_commit_epoch_and_closure() { + let fixture = Fixture::new(); + let source = fixture + .authenticate() + .expect("should authenticate clean fixture"); + assert_eq!( + source.commit(), + fixture.commit, + "should keep approved commit" + ); + assert_eq!(source.epoch(), EPOCH, "should derive committer epoch"); + assert!( + lower_hex(source.snapshot().fingerprint(), 64), + "should fingerprint the tracked snapshot" + ); + assert!( + lower_hex(source.closure_digest(), 64), + "should digest closure" + ); + assert_eq!( + source.closure_paths(), + [ + ".cargo/config.toml", + ".tool-versions", + "Cargo.lock", + "Cargo.toml", + "crates/trusted-server-openrtb/Cargo.toml", + "crates/trusted-server-release/Cargo.toml", + "crates/trusted-server-release/src/main.rs", + "rust-toolchain.toml", + "scripts/release/bootstrap.sh", + ], + "should record the complete helper-source closure" + ); + assert_eq!( + source.tracked_sha256("AGENTS.md"), + Some(hex::encode(Sha256::digest(b"# Fictional agents\n")).as_str()), + "should expose tracked content digests" + ); + source + .snapshot() + .verify_unchanged() + .expect("should verify unchanged worktree"); +} + +#[test] +fn rejects_malformed_commit_arguments() { + let fixture = Fixture::new(); + for commit in [ + "ABC", + "--help", + &fixture.commit.to_uppercase(), + &fixture.commit[..39], + ] { + assert!( + authenticate(&fixture.root, commit, &fixture.audited()).is_err(), + "should reject commit argument {commit}" + ); + } +} + +#[test] +fn rejects_unknown_commit_with_bounded_git_detail() { + let fixture = Fixture::new(); + let text = format!( + "{:?}", + authenticate(&fixture.root, &"0".repeat(40), &fixture.audited()) + .err() + .expect("should reject unknown commit") + ); + assert!( + text.contains("git cat-file"), + "should name the failing git step: {text}" + ); +} + +#[test] +fn rejects_non_commit_objects() { + let fixture = Fixture::new(); + let tree = fixture.git(&["rev-parse", "HEAD^{tree}"]); + let text = format!( + "{:?}", + authenticate(&fixture.root, &tree, &fixture.audited()) + .err() + .expect("should reject tree object") + ); + assert!(text.contains("not a commit"), "should reject tree: {text}"); +} + +#[test] +fn rejects_head_that_differs_from_approved_commit() { + let mut fixture = Fixture::new(); + let approved = fixture.commit.clone(); + write(&fixture.root, "AGENTS.md", b"# Changed\n"); + fixture.commit_all(); + let text = format!( + "{:?}", + authenticate(&fixture.root, &approved, &fixture.audited()) + .err() + .expect("should reject HEAD mismatch") + ); + assert!( + text.contains("HEAD"), + "should explain HEAD mismatch: {text}" + ); +} + +#[test] +fn rejects_extensionless_rust_toolchain_before_other_checks() { + let fixture = Fixture::new(); + write(&fixture.root, "rust-toolchain", b"nightly\n"); + write( + &fixture.root, + ".git/objects/info/alternates", + b"/elsewhere\n", + ); + assert_rejects(&fixture, "rust-toolchain"); +} + +#[test] +fn rejects_linked_worktree_gitfile_indirection() { + let fixture = Fixture::new(); + fs::rename( + fixture.root.join(".git"), + fixture.parent.path().join("moved-git"), + ) + .expect("should move git directory"); + write(&fixture.root, ".git", b"gitdir: ../moved-git\n"); + assert_rejects(&fixture, "unsupported"); +} + +#[test] +fn rejects_commondir_indirection() { + let fixture = Fixture::new(); + write(&fixture.root, ".git/commondir", b"../other\n"); + assert_rejects(&fixture, "commondir"); +} + +#[test] +fn rejects_object_and_history_rewriting_channels() { + for (path, needle) in [ + ("objects/info/alternates", ".git/objects/info/alternates"), + ( + "objects/info/http-alternates", + ".git/objects/info/http-alternates", + ), + ("info/grafts", ".git/info/grafts"), + ("shallow", ".git/shallow"), + ("info/attributes", ".git/info/attributes"), + ( + "refs/replace/0000000000000000000000000000000000000000", + ".git/refs/replace", + ), + ] { + let fixture = Fixture::new(); + write(&fixture.root.join(".git"), path, b"fictional\n"); + assert_rejects(&fixture, needle); + } +} + +#[test] +fn rejects_packed_replace_refs() { + let fixture = Fixture::new(); + let line = format!( + "# pack-refs with: peeled fully-peeled sorted\n{} refs/replace/{}\n", + fixture.commit, fixture.commit + ); + write(&fixture.root, ".git/packed-refs", line.as_bytes()); + assert_rejects(&fixture, "refs/replace"); +} + +#[test] +fn rejects_unreviewed_git_config_keys() { + for addition in [ + "[include]\n\tpath = /dev/null\n", + "[includeIf \"gitdir:/\"]\n\tpath = /dev/null\n", + "[core]\n\tfsmonitor = true\n", + "[core]\n\thooksPath = /dev/null\n", + "[core]\n\tsshCommand = ssh\n", + "[core]\n\tworktree = /\n", + "[filter \"lfs\"]\n\tclean = cat\n", + "[diff \"x\"]\n\ttextconv = cat\n", + "[extensions]\n\tobjectformat = sha256\n", + "[user]\n\tname = Fictional\n", + ] { + let fixture = Fixture::new(); + let config = fixture.root.join(".git/config"); + let mut text = fs::read_to_string(&config).expect("should read fixture config"); + text.push_str(addition); + fs::write(&config, text).expect("should write fixture config"); + assert_rejects(&fixture, "git config"); + } +} + +#[test] +fn parses_only_allowlisted_config_values() { + assert!( + check_git_config("[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://example.com/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n").is_ok(), + "should accept allowlisted keys" + ); + for text in [ + "[core]\n\trepositoryformatversion = 1\n", + "[core]\n\tbare = true\n", + "[core]\n\tfilemode = maybe\n", + "[core]\n\tsymlinks = \"true\"\n", + "[core]\n\tfilemode = true \\\n", + "[remote \"origin\"]\n\tpushurl = https://example.com\n", + "[core.x]\n\tfilemode = true\n", + "\tfilemode = true\n", + ] { + assert!( + check_git_config(text).is_err(), + "should reject config {text:?}" + ); + } +} + +#[test] +fn rejects_fsmonitor_without_executing_it() { + let fixture = Fixture::new(); + let marker = fixture.parent.path().join("fsmonitor-ran"); + let hook = fixture.parent.path().join("fsmonitor.sh"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display())) + .expect("should write hook"); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).expect("should chmod hook"); + let config = fixture.root.join(".git/config"); + let mut text = fs::read_to_string(&config).expect("should read config"); + text.push_str(&format!("[core]\n\tfsmonitor = {}\n", hook.display())); + fs::write(&config, text).expect("should write config"); + assert_rejects(&fixture, "git config"); + assert!( + !marker.exists(), + "should never execute the fsmonitor command" + ); +} + +#[test] +fn rejects_modified_tracked_file() { + let fixture = Fixture::new(); + write(&fixture.root, "AGENTS.md", b"# Modified\n"); + assert_rejects(&fixture, "AGENTS.md"); +} + +#[test] +fn rejects_changed_executable_bit() { + let fixture = Fixture::new(); + fs::set_permissions( + fixture.root.join("scripts/release/bootstrap.sh"), + fs::Permissions::from_mode(0o644), + ) + .expect("should chmod"); + assert_rejects(&fixture, "scripts/release/bootstrap.sh"); +} + +#[test] +fn rejects_file_replaced_by_symlink() { + let fixture = Fixture::new(); + let outside = fixture.parent.path().join("outside.md"); + fs::write(&outside, b"# Fictional agents\n").expect("should write outside file"); + fs::remove_file(fixture.root.join("AGENTS.md")).expect("should remove tracked file"); + symlink(&outside, fixture.root.join("AGENTS.md")).expect("should create symlink"); + assert_rejects(&fixture, "AGENTS.md"); +} + +#[test] +fn rejects_preexisting_hard_link() { + let fixture = Fixture::new(); + fs::hard_link( + fixture.root.join("Cargo.lock"), + fixture.parent.path().join("lock-link"), + ) + .expect("should create hard link outside source"); + assert_rejects(&fixture, "Cargo.lock"); +} + +#[test] +fn rejects_missing_tracked_file() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("crates/other/src/lib.rs")).expect("should remove file"); + assert_rejects(&fixture, "crates/other/src/lib.rs"); +} + +#[test] +fn rejects_untracked_and_ignored_paths() { + for (path, needle) in [ + (".DS_Store", ".DS_Store"), + ( + "crates/trusted-server-js/lib/node_modules/x/index.js", + "crates/trusted-server-js", + ), + ( + "crates/other/src/extra_test.rs", + "crates/other/src/extra_test.rs", + ), + ("target/debug/fictional", "target"), + ] { + let mut fixture = Fixture::new(); + write( + &fixture.root, + ".gitignore", + b".DS_Store\nnode_modules\ntarget\n*_test.rs\n", + ); + fixture.commit_all(); + write(&fixture.root, path, b"untracked\n"); + assert_rejects(&fixture, needle); + } +} + +#[test] +fn rejects_empty_untracked_directory() { + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("empty")).expect("should create directory"); + assert_rejects(&fixture, "empty"); +} + +#[test] +fn rejects_unreviewed_tracked_symlink() { + let mut fixture = Fixture::new(); + symlink("Cargo.toml", fixture.root.join("manifest-link")).expect("should create symlink"); + fixture.commit_all(); + assert_rejects(&fixture, "manifest-link"); +} + +#[test] +fn rejects_tracked_submodule() { + let mut fixture = Fixture::new(); + let gitlink = format!("160000,{},vendor/module", fixture.commit); + fixture.git(&["update-index", "--add", "--cacheinfo", &gitlink]); + fixture.git(&["commit", "-q", "-m", "submodule"]); + fixture.commit = fixture.git(&["rev-parse", "HEAD"]); + assert_rejects(&fixture, "vendor/module"); +} + +type Mutation = fn(&Fixture); + +#[test] +fn verify_unchanged_detects_mutations_after_snapshot() { + let mutations: [(&str, Mutation); 5] = [ + ("content", |fixture| { + write(&fixture.root, "AGENTS.md", b"# Changed\n") + }), + ("mode", |fixture| { + fs::set_permissions( + fixture.root.join("Cargo.lock"), + fs::Permissions::from_mode(0o755), + ) + .expect("should chmod"); + }), + ("symlink", |fixture| { + fs::remove_file(fixture.root.join("Cargo.lock")).expect("should remove"); + symlink("Cargo.toml", fixture.root.join("Cargo.lock")).expect("should link"); + }), + ("hard link", |fixture| { + fs::hard_link( + fixture.root.join("Cargo.lock"), + fixture.parent.path().join("late-link"), + ) + .expect("should hard link"); + }), + ("new file", |fixture| { + write(&fixture.root, "late.txt", b"late\n") + }), + ]; + for (label, mutate) in mutations { + let fixture = Fixture::new(); + let source = fixture.authenticate().expect("should authenticate"); + mutate(&fixture); + assert!( + source.snapshot().verify_unchanged().is_err(), + "should detect {label} mutation" + ); + } +} + +#[test] +fn closure_requires_every_listed_member_manifest() { + let mut fixture = Fixture::new(); + write( + &fixture.root, + "Cargo.toml", + b"[workspace]\nmembers = [\"crates/trusted-server-openrtb\", \"crates/missing\"]\n", + ); + fixture.commit_all(); + assert_rejects(&fixture, "crates/missing/Cargo.toml"); + write( + &fixture.root, + "Cargo.toml", + b"[workspace]\nmembers = [\"crates/trusted-server-release\"]\n", + ); + fixture.commit_all(); + assert_rejects(&fixture, "crates/trusted-server-openrtb/Cargo.toml"); + write( + &fixture.root, + "Cargo.toml", + b"[workspace]\nmembers = [\"crates/*\"]\n", + ); + fixture.commit_all(); + assert_rejects(&fixture, "glob"); +} + +#[test] +fn closure_digest_changes_with_helper_sources() { + let mut fixture = Fixture::new(); + let before = fixture.authenticate().expect("should authenticate"); + write( + &fixture.root, + "crates/trusted-server-release/src/main.rs", + b"fn main() { }\n", + ); + fixture.commit_all(); + let after = fixture + .authenticate() + .expect("should authenticate changed helper"); + assert_ne!( + before.closure_digest(), + after.closure_digest(), + "should invalidate the closure when helper source changes" + ); + write( + &fixture.root, + "crates/other/src/lib.rs", + b"// changed outside closure\n", + ); + fixture.commit_all(); + let outside = fixture + .authenticate() + .expect("should authenticate outside change"); + assert_eq!( + after.closure_digest(), + outside.closure_digest(), + "should ignore sources outside the helper closure" + ); +} + +#[test] +fn parses_committer_epoch_strictly() { + let header = + "tree 0000000000000000000000000000000000000000\nauthor A 1 +0000\n"; + let valid = format!("{header}committer C 1700000000 -0130\n\nmessage\n"); + assert_eq!( + committer_epoch(valid.as_bytes()).expect("should parse committer epoch"), + EPOCH + ); + for committer in [ + "", + "committer C +1700000000 +0000\n", + "committer C 17e8 +0000\n", + "committer C 1700000000 0000\n", + "committer C 1700000000\n", + "committer C 1 +0000\ncommitter D 2 +0000\n", + ] { + let raw = format!("{header}{committer}\nmessage\n"); + assert!( + committer_epoch(raw.as_bytes()).is_err(), + "should reject committer line {committer:?}" + ); + } +} + +#[test] +fn joins_inventory_only_to_the_approved_commit() { + let fixture = Fixture::new(); + let source = fixture.authenticate().expect("should authenticate"); + let inventory = |commit: &str| { + serde_json::to_vec(&json!({ + "materials": [], + "modules": [], + "schema_version": 1, + "source_commit": commit, + "tools": [], + })) + .expect("should encode inventory") + }; + join_inventory(&source, &inventory(&fixture.commit)).expect("should join matching inventory"); + assert!( + join_inventory(&source, &inventory(&"a".repeat(40))).is_err(), + "should reject a different inventory commit" + ); + let mut extra: Value = + serde_json::from_slice(&inventory(&fixture.commit)).expect("should decode inventory"); + extra["approved"] = json!(true); + assert!( + join_inventory(&source, &serde_json::to_vec(&extra).expect("should encode")).is_err(), + "should reject unknown inventory fields" + ); +} + +#[test] +fn child_environment_takes_commit_and_epoch_from_approved_source() { + let fixture = Fixture::new(); + let audited = fixture.audited(); + let source = fixture.authenticate().expect("should authenticate"); + let child = child_environment( + &audited, + &source, + &ChildRequest { + target: ReleaseTarget::Wasm32Wasip1, + home: PathBuf::from("/private/release/home"), + cargo_home: PathBuf::from("/private/release/cargo-home"), + tool_directories: Vec::new(), + }, + ) + .expect("should build child environment"); + assert_eq!( + child.variables().get("TRUSTED_SERVER__GIT_VERSION"), + Some(&fixture.commit), + "should inject the approved commit" + ); + assert_eq!( + child + .variables() + .get("SOURCE_DATE_EPOCH") + .map(String::as_str), + Some("1700000000"), + "should inject the approved committer epoch" + ); +} diff --git a/crates/trusted-server-release/tests/commands.rs b/crates/trusted-server-release/tests/commands.rs index 852eb76491..21530fd476 100644 --- a/crates/trusted-server-release/tests/commands.rs +++ b/crates/trusted-server-release/tests/commands.rs @@ -1,7 +1,177 @@ +use std::collections::BTreeSet; +use std::ffi::OsString; use std::fs; -use std::process::Command; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; -use tempfile::tempdir; +use serde_json::{Value, json}; +use tempfile::{TempDir, tempdir}; + +const RELEASE_BIN: &str = env!("CARGO_BIN_EXE_trusted-server-release"); +const REPOSITORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); +const COHORT: &str = "scripts/release/release-cohort.json"; +const TOOLS: &str = "scripts/release/release-tools.json"; +const CLI_POLICY: &str = "crates/trusted-server-cli/release-policy.json"; +const BOOTSTRAP: &str = "scripts/release/bootstrap.sh"; + +fn host_path() -> OsString { + std::env::var_os("PATH").expect("should have a PATH to locate git") +} + +fn tracked_input(path: &str) -> Vec { + fs::read(Path::new(REPOSITORY).join(path)).expect("should read tracked release input") +} + +fn write(root: &Path, path: &str, bytes: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().expect("should have parent")).expect("should create dirs"); + fs::write(path, bytes).expect("should write fixture file"); +} + +/// A clean fixture repository holding the real tracked release inputs. +struct SourceFixture { + parent: TempDir, + root: PathBuf, + commit: String, +} + +impl SourceFixture { + fn new(customize: impl FnOnce(&Path)) -> Self { + let parent = tempdir().expect("should create fixture parent"); + let root = parent.path().join("source"); + for path in [COHORT, TOOLS, CLI_POLICY] { + write(&root, path, &tracked_input(path)); + } + write( + &root, + "Cargo.toml", + b"[workspace]\nmembers = [\n \"crates/trusted-server-openrtb\",\n \"crates/trusted-server-release\",\n]\n", + ); + write(&root, "Cargo.lock", b"# fictional lock\nversion = 4\n"); + write( + &root, + "rust-toolchain.toml", + b"[toolchain]\nchannel = \"1.95.0\"\n", + ); + write(&root, ".cargo/config.toml", b"[alias]\n"); + write(&root, ".tool-versions", b"rust 1.95.0\n"); + write(&root, BOOTSTRAP, b"#!/bin/sh\nexit 1\n"); + write( + &root, + "crates/trusted-server-openrtb/Cargo.toml", + b"[package]\nname = \"fictional-openrtb\"\n", + ); + write( + &root, + "crates/trusted-server-release/Cargo.toml", + b"[package]\nname = \"fictional-release\"\n", + ); + write(&root, "AGENTS.md", b"# Fictional agents\n"); + symlink("AGENTS.md", root.join("CLAUDE.md")).expect("should create reviewed symlink"); + customize(&root); + let fixture = Self { + parent, + root, + commit: String::new(), + }; + fixture.git(&["init", "-q"]); + fixture.git(&["add", "-A"]); + fixture.git(&["commit", "-q", "-m", "fixture"]); + Self { + commit: fixture.git(&["rev-parse", "HEAD"]), + ..fixture + } + } + + fn git(&self, args: &[&str]) -> String { + let home = self.parent.path().join("fixture-home"); + fs::create_dir_all(&home).expect("should create fixture home"); + let output = Command::new("git") + .args(args) + .current_dir(&self.root) + .env_clear() + .env("PATH", host_path()) + .env("HOME", &home) + .env("LC_ALL", "C") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Fictional Author") + .env("GIT_AUTHOR_EMAIL", "author@example.com") + .env("GIT_AUTHOR_DATE", "1700000000 +0000") + .env("GIT_COMMITTER_NAME", "Fictional Committer") + .env("GIT_COMMITTER_EMAIL", "committer@example.com") + .env("GIT_COMMITTER_DATE", "1700000000 +0000") + .output() + .expect("should run fixture git"); + assert!( + output.status.success(), + "should run fixture git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("should emit UTF-8") + .trim() + .to_owned() + } + + fn release(&self, args: &[&str], environment: &[(&str, &str)]) -> Output { + Command::new(RELEASE_BIN) + .args(args) + .current_dir(&self.root) + .env_clear() + .env("PATH", host_path()) + .envs(environment.iter().copied()) + .output() + .expect("should run release producer") + } + + fn preflight(&self, require_qualified: bool, environment: &[(&str, &str)]) -> Output { + let mut args = vec![ + "preflight", + "--cohort", + COHORT, + "--source-commit", + &self.commit, + ]; + if require_qualified { + args.push("--require-qualified"); + } + self.release(&args, environment) + } + + fn listing(&self) -> BTreeSet { + let mut paths = BTreeSet::new(); + let mut pending = vec![self.root.clone()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).expect("should list fixture") { + let path = entry.expect("should read fixture entry").path(); + if fs::symlink_metadata(&path) + .expect("should inspect fixture entry") + .is_dir() + { + pending.push(path.clone()); + } + paths.insert(path); + } + } + paths + } +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn assert_failed_without_output(output: &Output, needle: &str) { + let text = stderr(output); + assert!(!output.status.success(), "should fail closed: {text}"); + assert!(output.stdout.is_empty(), "should emit no stdout on failure"); + assert!( + text.contains(needle), + "should explain failure with `{needle}`, got: {text}" + ); +} #[test] fn producer_dispatch_remains_closed_for_every_invocation() { @@ -11,13 +181,15 @@ fn producer_dispatch_remains_closed_for_every_invocation() { vec![], vec!["--help"], vec!["--version"], + vec!["help"], vec!["preflight", "--require-qualified"], vec!["schema", "--schema-file", "override.json"], vec!["publish"], ] { - let output = Command::new(env!("CARGO_BIN_EXE_trusted-server-release")) + let output = Command::new(RELEASE_BIN) .args(&arguments) .current_dir(root.path()) + .env_clear() .output() .expect("should run closed producer binary"); assert!( @@ -29,7 +201,7 @@ fn producer_dispatch_remains_closed_for_every_invocation() { "should emit no result or release bytes" ); assert!( - String::from_utf8_lossy(&output.stderr).contains("release execution remains disabled"), + stderr(&output).contains("release execution remains disabled"), "should explain pending execution gate" ); assert_eq!( @@ -48,21 +220,196 @@ fn producer_dispatch_remains_closed_for_every_invocation() { } #[test] -fn inherited_approval_labels_cannot_enable_pending_execution() { - let output = Command::new(env!("CARGO_BIN_EXE_trusted-server-release")) - .args(["preflight", "--require-qualified"]) - .env("TS_RELEASE_BUILD", "1") - .env("TS_RELEASE_SOURCE_COMMIT", "a".repeat(40)) - .env("RELEASE_AUTHENTICATED", "true") - .env("RELEASE_QUALIFIED", "true") +fn inherited_approval_labels_cannot_enable_preflight() { + let fixture = SourceFixture::new(|_| {}); + let output = fixture.preflight( + true, + &[ + ("TS_RELEASE_BUILD", "1"), + ("TS_RELEASE_SOURCE_COMMIT", &"a".repeat(40)), + ("RELEASE_AUTHENTICATED", "true"), + ("RELEASE_QUALIFIED", "true"), + ], + ); + assert_failed_without_output(&output, "not qualified"); +} + +#[test] +fn clean_fixture_preflight_fails_at_closed_selector_without_writes() { + let fixture = SourceFixture::new(|_| {}); + let before = fixture.listing(); + let output = fixture.preflight(true, &[]); + assert_failed_without_output(&output, "Production release selection is not qualified"); + assert!( + stderr(&output).contains("authenticated locally"), + "should pass every earlier check before the selector" + ); + assert_eq!(fixture.listing(), before, "should write no files"); +} + +#[test] +fn qualified_looking_tools_still_fail_at_closed_receipt_check() { + let fixture = SourceFixture::new(|root| { + let digest = "ab".repeat(32); + let mut tools: Value = + serde_json::from_slice(&tracked_input(TOOLS)).expect("should parse tools"); + for section in ["environments", "recipes", "tools"] { + for entry in tools[section] + .as_array_mut() + .expect("should contain entries") + { + for (key, field) in entry.as_object_mut().expect("should be an object") { + if key == "qualification" { + *field = json!("qualified"); + } else if field.is_null() && key.ends_with("sha256") { + *field = json!(digest); + } else if field.is_null() && key != "revision" { + *field = json!("fictional.example.com/qualified"); + } + } + } + } + write( + root, + TOOLS, + &serde_json::to_vec(&tools).expect("should encode tools"), + ); + }); + let output = fixture.preflight(true, &[]); + assert_failed_without_output(&output, "production selection remains closed"); +} + +#[test] +fn omitting_require_qualified_still_fails() { + let fixture = SourceFixture::new(|_| {}); + let output = fixture.preflight(false, &[]); + assert_failed_without_output(&output, "Production release selection is not qualified"); +} + +#[test] +fn empty_skip_flag_fails_naming_the_variable() { + let fixture = SourceFixture::new(|_| {}); + let output = fixture.preflight(true, &[("TSJS_SKIP_BUILD", "")]); + assert_failed_without_output(&output, "TSJS_SKIP_BUILD"); +} + +#[test] +fn untracked_extensionless_rust_toolchain_fails() { + let fixture = SourceFixture::new(|_| {}); + write(&fixture.root, "rust-toolchain", b"nightly\n"); + let output = fixture.preflight(true, &[]); + assert_failed_without_output(&output, "rust-toolchain"); +} + +#[test] +fn wrong_cohort_path_fails() { + let fixture = SourceFixture::new(|root| { + write(root, "other/release-cohort.json", &tracked_input(COHORT)); + }); + let outside = fixture.parent.path().join("release-cohort.json"); + for cohort in [ + "other/release-cohort.json", + "scripts/release/../release/release-cohort.json", + outside.to_str().expect("should be UTF-8"), + ] { + let output = fixture.release( + &[ + "preflight", + "--cohort", + cohort, + "--source-commit", + &fixture.commit, + "--require-qualified", + ], + &[], + ); + assert_failed_without_output(&output, "--cohort must name"); + } +} + +#[test] +fn candidate_cohort_listing_its_own_rc_branch_fails() { + let fixture = SourceFixture::new(|root| { + let mut cohort: Value = + serde_json::from_slice(&tracked_input(COHORT)).expect("should parse cohort"); + cohort["eligible_branches"] = json!(["main", "rc/202610"]); + write( + root, + COHORT, + &serde_json::to_vec(&cohort).expect("should encode cohort"), + ); + }); + let output = fixture.preflight(true, &[]); + assert_failed_without_output(&output, "eligible_branches"); +} + +#[test] +fn fsmonitor_config_fails_without_executing_it() { + let fixture = SourceFixture::new(|_| {}); + let marker = fixture.parent.path().join("fsmonitor-ran"); + let hook = fixture.parent.path().join("fsmonitor.sh"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display())) + .expect("should write fsmonitor command"); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).expect("should chmod"); + let config = fixture.root.join(".git/config"); + let mut text = fs::read_to_string(&config).expect("should read config"); + text.push_str(&format!("[core]\n\tfsmonitor = {}\n", hook.display())); + fs::write(&config, text).expect("should write config"); + let output = fixture.preflight(true, &[]); + assert_failed_without_output(&output, "git config"); + assert!( + !marker.exists(), + "should never execute the fsmonitor command" + ); +} + +#[test] +fn audit_inputs_reports_incomplete_with_distinct_status() { + let fixture = SourceFixture::new(|_| {}); + let output = fixture.release(&["audit-inputs"], &[]); + assert_eq!( + output.status.code(), + Some(3), + "should use the incomplete status" + ); + let report: Value = serde_json::from_slice(&output.stdout).expect("should print JSON report"); + assert_eq!(report["status"], json!("incomplete")); + assert!( + report["pending"] + .as_array() + .is_some_and(|pending| !pending.is_empty()), + "should list pending pins" + ); + assert_eq!( + report["inputs"].as_array().map(Vec::len), + Some(3), + "should list input digests" + ); +} + +fn bootstrap(directory: &Path) -> Output { + Command::new("/bin/sh") + .arg(Path::new(REPOSITORY).join(BOOTSTRAP)) + .current_dir(directory) + .env_clear() .output() - .expect("should run pending producer"); + .expect("should run bootstrap") +} + +#[test] +fn bootstrap_rejects_extensionless_rust_toolchain_before_closing() { + let root = tempdir().expect("should create bootstrap fixture"); + let closed = bootstrap(root.path()); + assert!(!closed.status.success(), "should stay closed"); assert!( - !output.status.success(), - "should reject inherited labels as execution authority" + stderr(&closed).contains("pending"), + "should explain the closed launcher" ); + fs::write(root.path().join("rust-toolchain"), b"nightly\n").expect("should write pin"); + let rejected = bootstrap(root.path()); + assert!(!rejected.status.success(), "should reject the pin"); assert!( - output.stdout.is_empty(), - "should return no release evidence" + stderr(&rejected).contains("rust-toolchain"), + "should name the extensionless toolchain file" ); } diff --git a/crates/trusted-server-release/tests/fixtures/README.md b/crates/trusted-server-release/tests/fixtures/README.md index 7fa5abd804..22960c3f8a 100644 --- a/crates/trusted-server-release/tests/fixtures/README.md +++ b/crates/trusted-server-release/tests/fixtures/README.md @@ -1,6 +1,6 @@ # Release document fixtures -Private document unit tests in `src/documents/tests.rs` are registered by `src/documents.rs`; public process tests exercise only the closed producer dispatch in `tests/commands.rs`. 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. +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. @@ -8,4 +8,4 @@ Source: [SPDX specification schema](https://github.com/spdx/spdx-spec/blob/aadf3 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. Authenticated source/path policy and the complete helper source closure belong to Task 3. Producer execution and the POSIX bootstrap remain closed pending that stage and independent authentication. Full real-artifact SPDX coverage remains Task 11 work. +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. diff --git a/scripts/release/bootstrap.sh b/scripts/release/bootstrap.sh index 9f52210efb..de3d445720 100644 --- a/scripts/release/bootstrap.sh +++ b/scripts/release/bootstrap.sh @@ -2,5 +2,11 @@ # Rust release tooling must be source-approved before this launcher can execute it. # Keep release execution closed while the replacement is implemented and reviewed. set -eu +# An extensionless pin overrides rust-toolchain.toml during rustup selection, +# so reject it before any future toolchain selection can happen. +if [ -e rust-toolchain ] || [ -L rust-toolchain ]; then + printf '%s\n' 'Extensionless rust-toolchain is rejected before toolchain selection' >&2 + exit 1 +fi printf '%s\n' 'Release bootstrap is pending the reviewed Rust tooling replacement' >&2 exit 1 From 7450b37805131d307d402df089d7a585104dd543 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 11:23:14 +0530 Subject: [PATCH 043/153] Parse release evidence under embedded consumer policy and bound gh Add the host-only `ts` consumer verifier foundations (plan Tasks 17-18): strict byte-first release documents with the governing index schema, the embedded consumer policy with fixed identities and bounds, producer policy acceptance bound to an independently approved source snapshot or the fixed Contents API endpoint, and an isolated gh 2.97.0 runner with fixed argv, private configuration, bounded pipes/deadlines and process-group supervision. The verifier is not yet wired to a public command. Real signed-fixture verification under the pinned gh binary remains outstanding. --- Cargo.lock | 4 + crates/trusted-server-cli/Cargo.toml | 11 + crates/trusted-server-cli/src/lib.rs | 12 + .../trusted-server-cli/src/release/bounds.rs | 109 +++ .../src/release/documents.rs | 713 ++++++++++++++++++ .../src/release/environment.rs | 107 +++ .../trusted-server-cli/src/release/error.rs | 61 ++ crates/trusted-server-cli/src/release/gh.rs | 464 ++++++++++++ crates/trusted-server-cli/src/release/mod.rs | 18 + .../trusted-server-cli/src/release/policy.rs | 669 ++++++++++++++++ .../trusted-server-cli/src/release/process.rs | 324 ++++++++ .../src/release/source_policy.rs | 379 ++++++++++ .../src/release/tests/documents/mod.rs | 668 ++++++++++++++++ .../src/release/tests/documents/producer.rs | 605 +++++++++++++++ .../src/release/tests/gh/argv.rs | 130 ++++ .../src/release/tests/gh/environment.rs | 305 ++++++++ .../src/release/tests/gh/lifecycle.rs | 293 +++++++ .../src/release/tests/gh/mod.rs | 206 +++++ .../src/release/tests/mod.rs | 5 + .../tests/fixtures/release/release-index.json | 218 ++++++ 20 files changed, 5301 insertions(+) create mode 100644 crates/trusted-server-cli/src/release/bounds.rs create mode 100644 crates/trusted-server-cli/src/release/documents.rs create mode 100644 crates/trusted-server-cli/src/release/environment.rs create mode 100644 crates/trusted-server-cli/src/release/error.rs create mode 100644 crates/trusted-server-cli/src/release/gh.rs create mode 100644 crates/trusted-server-cli/src/release/mod.rs create mode 100644 crates/trusted-server-cli/src/release/policy.rs create mode 100644 crates/trusted-server-cli/src/release/process.rs create mode 100644 crates/trusted-server-cli/src/release/source_policy.rs create mode 100644 crates/trusted-server-cli/src/release/tests/documents/mod.rs create mode 100644 crates/trusted-server-cli/src/release/tests/documents/producer.rs create mode 100644 crates/trusted-server-cli/src/release/tests/gh/argv.rs create mode 100644 crates/trusted-server-cli/src/release/tests/gh/environment.rs create mode 100644 crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs create mode 100644 crates/trusted-server-cli/src/release/tests/gh/mod.rs create mode 100644 crates/trusted-server-cli/src/release/tests/mod.rs create mode 100644 crates/trusted-server-cli/tests/fixtures/release/release-index.json diff --git a/Cargo.lock b/Cargo.lock index 28bdd528b2..21fd90a4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6773,10 +6773,13 @@ dependencies = [ "futures", "gix", "glob", + "hex", "http", "http-body-util", "hyper", "hyper-util", + "jsonschema", + "libc", "log", "predicates", "rand 0.8.6", @@ -6790,6 +6793,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sha2 0.10.9", "similar", "temp-env", "tempfile", diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index ce97919c81..ec620760ed 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -85,6 +85,17 @@ hyper = { workspace = true, features = ["http1", "client"] } hyper-util = { workspace = true, features = ["tokio"] } tokio = { workspace = true, features = ["net", "rt-multi-thread"] } +# Consumer release verification (`src/release`), compiled only where the +# module is: Unix hosts. `sha2`/`hex` hash exact distributed bytes, `libc` +# provides process-group termination and `openat` snapshot reads, and +# `jsonschema` evaluates the governing release-index schema offline. The +# `jsonschema` exact pin and empty default features match `trusted-server-release`. +[target.'cfg(all(unix, not(target_arch = "wasm32")))'.dependencies] +hex = { workspace = true } +jsonschema = { version = "=0.58.6", default-features = false } +libc = { workspace = true } +sha2 = { workspace = true } + # The shared proxy engine supports macOS and Linux only. [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] base64 = { workspace = true } diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 080bdb0db5..12eafb3e46 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -6,6 +6,18 @@ mod app_config; mod error; #[cfg(not(target_arch = "wasm32"))] mod prebid_bundle; +// Consumer-side release attestation verification. Unix-only because the gh +// runner terminates whole process groups. Dispatch wiring arrives with the +// `ts release verify` command, so the internals are not yet reachable. +#[cfg(all(unix, not(target_arch = "wasm32")))] +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "release verification is reachable only from tests until `ts release verify` dispatch lands" + ) +)] +mod release; #[cfg(not(target_arch = "wasm32"))] mod run; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/trusted-server-cli/src/release/bounds.rs b/crates/trusted-server-cli/src/release/bounds.rs new file mode 100644 index 0000000000..0ba0130bd2 --- /dev/null +++ b/crates/trusted-server-cli/src/release/bounds.rs @@ -0,0 +1,109 @@ +//! Fixed R-BOUNDS resource limits. +//! +//! These values mirror the embedded policy `bounds` object, which +//! [`super::policy`] requires to match exactly. They change only through a +//! reviewed policy update. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +const MIB: u64 = 1024 * 1024; + +/// Maximum release-index bytes. +pub(crate) const INDEX_BYTES: u64 = MIB; +/// Maximum release-policy bytes, embedded or approved-source. +pub(crate) const POLICY_BYTES: u64 = MIB; +/// Maximum JS inventory bytes. +pub(crate) const INVENTORY_BYTES: u64 = MIB; +/// Maximum generic metadata bytes. +pub(crate) const METADATA_BYTES: u64 = MIB; +/// Maximum Sigstore bundle bytes. +pub(crate) const BUNDLE_BYTES: u64 = 32 * MIB; +/// Maximum build companion bytes. +pub(crate) const COMPANION_BYTES: u64 = 32 * MIB; +/// Maximum SPDX SBOM bytes (the pinned action's limit). +pub(crate) const SPDX_BYTES: u64 = 16 * MIB; +/// Maximum trusted-root snapshot bytes. +pub(crate) const TRUSTED_ROOTS_BYTES: u64 = MIB; +/// Maximum executable bytes. +pub(crate) const EXECUTABLE_BYTES: u64 = 512 * MIB; +/// Maximum JSON container nesting, counting the root. +pub(crate) const JSON_DEPTH: usize = 64; +/// Exact number of executables in the cohort. +pub(crate) const COHORT_EXECUTABLES: usize = 4; +/// Exact number of matching attestation results accepted per bundle. +pub(crate) const MATCHING_ATTESTATIONS_PER_BUNDLE: usize = 1; +/// Per-child wall-clock deadline in seconds. +pub(crate) const CHILD_SECONDS: u64 = 120; +/// Whole verification attempt deadline in seconds. +pub(crate) const ATTEMPT_SECONDS: u64 = 900; +/// Maximum child stdout bytes. +pub(crate) const STDOUT_BYTES: u64 = 128 * MIB; +/// Maximum child stderr bytes. +pub(crate) const STDERR_BYTES: u64 = MIB; + +/// Bounded wait for pipe drains after the child process group is killed. +/// +/// This is a runner-internal liveness bound, not an R-BOUNDS policy value: a +/// killed group closes its pipes promptly, so anything still holding them +/// after this grace has escaped the group and is reported as such. +const KILL_GRACE: Duration = Duration::from_secs(2); + +/// Per-child process limits. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ProcessBounds { + pub(crate) child_timeout: Duration, + pub(crate) stdout_bytes: u64, + pub(crate) stderr_bytes: u64, + pub(crate) kill_grace: Duration, +} + +impl ProcessBounds { + /// Returns the fixed production limits. + pub(crate) const fn production() -> Self { + Self { + child_timeout: Duration::from_secs(CHILD_SECONDS), + stdout_bytes: STDOUT_BYTES, + stderr_bytes: STDERR_BYTES, + kill_grace: KILL_GRACE, + } + } +} + +/// The overall deadline shared by every step of one verification attempt. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AttemptDeadline(Instant); + +impl AttemptDeadline { + /// Starts the fixed 15-minute attempt deadline now. + pub(crate) fn start() -> Self { + Self(Instant::now() + Duration::from_secs(ATTEMPT_SECONDS)) + } + + /// Uses an explicit instant, for callers that already hold a deadline. + pub(crate) const fn at(instant: Instant) -> Self { + Self(instant) + } + + /// Returns the deadline instant. + pub(crate) const fn instant(self) -> Instant { + self.0 + } +} + +/// A cancellation flag shared between the caller and running children. +#[derive(Clone, Debug, Default)] +pub(crate) struct Cancellation(Arc); + +impl Cancellation { + /// Requests cancellation of every child observing this flag. + pub(crate) fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + /// Returns whether cancellation was requested. + pub(crate) fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} diff --git a/crates/trusted-server-cli/src/release/documents.rs b/crates/trusted-server-cli/src/release/documents.rs new file mode 100644 index 0000000000..f8a496eb3d --- /dev/null +++ b/crates/trusted-server-cli/src/release/documents.rs @@ -0,0 +1,713 @@ +//! Byte-first strict JSON parsing and closed release-index validation. +//! +//! This mirrors the producer's strict parser without depending on the release +//! crate: role byte limits apply before decoding, then BOM, UTF-8, lexical +//! number range, duplicate keys (rejected before insertion), nesting depth and +//! trailing data are checked. Hashes always cover the exact distributed bytes. +//! +//! [`parse_release_index`] enforces the closed field set and every semantic +//! rule for the fixed cohort, and additionally evaluates the retained +//! governing release-index schema with explicit Draft 2020-12, offline. + +use std::collections::BTreeSet; +use std::fmt; +use std::sync::LazyLock; + +use error_stack::Report; +use jsonschema::Draft; +use regex::Regex; +use serde::Deserialize; +use serde::de::{DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest as _, Sha256}; + +use super::bounds; +use super::error::{ReleaseError, report}; +use super::policy::{ConsumerPolicy, DeclaredIdentity, FixedArtifact, check_capability_vocabulary}; + +/// Largest integer every JSON implementation represents exactly. +const SAFE_INTEGER_MAX: u64 = 9_007_199_254_740_991; +/// Fixed name of the shared JS inventory asset. +const INVENTORY_NAME: &str = "tsjs-inventory.json"; +/// Maximum flat asset name length from the governing schema. +const NAME_MAX: usize = 160; +/// Maximum release tag length from the governing schema. +const TAG_MAX: usize = 128; + +static TAG_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new( + r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$", + ) + .expect("should compile the reviewed release tag pattern") +}); + +/// Byte-limit class selected before a document is parsed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Role { + Index, + Policy, + Inventory, + Companion, + Bundle, + Spdx, + GhOutput, +} + +impl Role { + /// Returns the R-BOUNDS byte limit for this role. + pub(crate) const fn limit(self) -> u64 { + match self { + Self::Index => bounds::INDEX_BYTES, + Self::Policy => bounds::POLICY_BYTES, + Self::Inventory => bounds::INVENTORY_BYTES, + Self::Companion => bounds::COMPANION_BYTES, + Self::Bundle => bounds::BUNDLE_BYTES, + Self::Spdx => bounds::SPDX_BYTES, + Self::GhOutput => bounds::STDOUT_BYTES, + } + } +} + +/// Parses bounded strict JSON with unique keys and finite in-range numbers. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] for oversize input, a BOM, invalid UTF-8, +/// out-of-range numbers, duplicate keys, nesting beyond 64 levels or trailing +/// data. +pub(crate) fn parse_json(bytes: &[u8], role: Role) -> Result> { + if bytes.len() as u64 > role.limit() { + return Err(report( + ReleaseError::Document, + "document exceeds role byte limit", + )); + } + if bytes.starts_with(b"\xef\xbb\xbf") { + return Err(report( + ReleaseError::Document, + "document must not start with a BOM", + )); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| report(ReleaseError::Document, "document must be UTF-8"))?; + validate_numbers(text)?; + let mut parser = serde_json::Deserializer::from_str(text); + let value = StrictSeed { depth: 1 } + .deserialize(&mut parser) + .map_err(|source| report(ReleaseError::Document, source))?; + parser + .end() + .map_err(|source| report(ReleaseError::Document, source))?; + Ok(value) +} + +/// Returns the lowercase hexadecimal SHA-256 of the exact bytes. +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Returns whether `value` is exactly `length` lowercase hexadecimal digits. +pub(crate) fn lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Returns whether `name` is a safe flat asset name. +/// +/// Rejects path separators, `..`, empty segments, control characters and +/// anything outside the governing schema's name pattern. +pub(crate) fn safe_name(name: &str) -> bool { + let mut parts = name.split('.'); + let first = parts.next().unwrap_or_default(); + name.len() <= NAME_MAX + && first + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && std::iter::once(first).chain(parts).all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) +} + +/// An independently approved full 40-character lowercase source commit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ApprovedCommit(String); + +impl ApprovedCommit { + /// Parses an approved commit supplied by trusted consumer approval. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Policy`] unless the value is 40 lowercase hex digits. + pub(crate) fn parse(value: &str) -> Result> { + if !lower_hex(value, 40) { + return Err(report( + ReleaseError::Policy, + "approved source commit must be 40 lowercase hexadecimal digits", + )); + } + Ok(Self(value.to_owned())) + } + + /// Returns the commit text. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +/// A release tag matching the governing `SemVer` `v`-prefixed pattern. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ReleaseTag(String); + +impl ReleaseTag { + /// Parses a release tag. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Policy`] for tags outside the reviewed pattern. + pub(crate) fn parse(value: &str) -> Result> { + if value.len() > TAG_MAX || !TAG_PATTERN.is_match(value) { + return Err(report( + ReleaseError::Policy, + "release tag must be a v-prefixed SemVer tag", + )); + } + Ok(Self(value.to_owned())) + } + + /// Returns the tag text. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +/// The tag and source commit approved independently of downloaded metadata. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ApprovedRelease { + tag: ReleaseTag, + commit: ApprovedCommit, +} + +impl ApprovedRelease { + /// Pairs an approved tag with its approved source commit. + pub(crate) fn new(tag: ReleaseTag, commit: ApprovedCommit) -> Self { + Self { tag, commit } + } + + /// Returns the approved tag. + pub(crate) fn tag(&self) -> &ReleaseTag { + &self.tag + } + + /// Returns the approved source commit. + pub(crate) fn commit(&self) -> &ApprovedCommit { + &self.commit + } +} + +/// One named, sized and digested release file. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct FileRecord { + pub(crate) name: String, + pub(crate) size: u64, + pub(crate) sha256: String, +} + +/// Declared runtime compatibility of an artifact. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Compatibility { + pub(crate) runtime: String, + pub(crate) minimum_version: String, + pub(crate) architecture: String, +} + +/// The six per-executable companion files. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Companions { + pub(crate) sbom: FileRecord, + pub(crate) provenance: FileRecord, + pub(crate) sbom_attestation: FileRecord, + pub(crate) build_evidence: FileRecord, + pub(crate) build_recipe: FileRecord, + pub(crate) rebuild_check: FileRecord, +} + +impl Companions { + fn records(&self) -> [(&'static str, u64, &FileRecord); 6] { + [ + (".spdx.json", bounds::SPDX_BYTES, &self.sbom), + ( + ".provenance.sigstore.json", + bounds::BUNDLE_BYTES, + &self.provenance, + ), + ( + ".sbom.sigstore.json", + bounds::BUNDLE_BYTES, + &self.sbom_attestation, + ), + ( + ".build-evidence.json", + bounds::COMPANION_BYTES, + &self.build_evidence, + ), + ( + ".build-recipe.json", + bounds::COMPANION_BYTES, + &self.build_recipe, + ), + ( + ".rebuild-check.json", + bounds::COMPANION_BYTES, + &self.rebuild_check, + ), + ] + } +} + +/// One executable entry of a release index. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct IndexArtifact { + pub(crate) name: String, + pub(crate) size: u64, + pub(crate) sha256: String, + pub(crate) role: String, + pub(crate) kind: String, + pub(crate) package: String, + pub(crate) target: String, + pub(crate) variant: String, + pub(crate) assurance_profile: String, + pub(crate) capabilities: Vec, + pub(crate) compatibility: Compatibility, + pub(crate) js_inventory_sha256: String, + pub(crate) companions: Companions, +} + +/// A strictly parsed, policy-checked version-1 release index. +/// +/// Its `policy_version`/`policy_sha256` identify the producer policy only; +/// [`super::policy::accept_producer_policy`] must still bind them to +/// approved-source bytes. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReleaseIndex { + pub(crate) schema_version: u64, + pub(crate) repository: String, + pub(crate) tag: String, + pub(crate) source_commit: String, + pub(crate) recipe_source_commit: String, + pub(crate) cohort_source_commit: String, + pub(crate) cohort: String, + pub(crate) policy_version: u64, + pub(crate) policy_sha256: String, + pub(crate) tsjs_inventory: FileRecord, + pub(crate) artifacts: Vec, +} + +/// Exact bytes of the governing release-index v1 schema. +const INDEX_SCHEMA: &[u8] = + include_bytes!("../../../../docs/superpowers/specs/schemas/release-index-v1.schema.json"); + +/// Evaluates `value` against `schema` with explicit Draft 2020-12, offline. +/// +/// Offline evaluation rejects every unregistered reference, so no remote or +/// file retrieval can occur. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] if the schema does not compile. +pub(crate) fn schema_accepts(schema: &Value, value: &Value) -> Result> { + let validator = jsonschema::options() + .with_draft(Draft::Draft202012) + .offline() + .build(schema) + .map_err(|source| report(ReleaseError::Document, source))?; + Ok(validator.is_valid(value)) +} + +/// Validates a strictly parsed index against the retained governing schema. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] when the schema rejects the document. +pub(crate) fn validate_index_schema(value: &Value) -> Result<(), Report> { + let schema = parse_json(INDEX_SCHEMA, Role::Index)?; + if !schema_accepts(&schema, value)? { + return Err(report( + ReleaseError::Document, + "release index does not match the governing release-index v1 schema", + )); + } + Ok(()) +} + +/// Parses and validates release-index bytes against the consumer policy. +/// +/// Semantic checks run first so diagnostics name the precise contract rule; +/// the governing schema is then evaluated as an independent structural gate. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] for malformed, ambiguous or open-field +/// JSON and [`ReleaseError::Policy`] for any semantic contract violation. +pub(crate) fn parse_release_index( + bytes: &[u8], + policy: &ConsumerPolicy, + approved: &ApprovedRelease, +) -> Result> { + let value = parse_json(bytes, Role::Index)?; + validate_safe_integers(&value)?; + let index = ReleaseIndex::deserialize(&value) + .map_err(|source| report(ReleaseError::Document, source))?; + check_index_header(&index, policy, approved)?; + let mut names = BTreeSet::new(); + if index.tsjs_inventory.name != INVENTORY_NAME { + return Err(report( + ReleaseError::Policy, + "JS inventory must be named tsjs-inventory.json", + )); + } + check_file( + &index.tsjs_inventory, + 0, + bounds::INVENTORY_BYTES, + &mut names, + )?; + if index.artifacts.len() != policy.artifacts().len() { + return Err(report( + ReleaseError::Policy, + "release index must list exactly the cohort artifacts", + )); + } + for artifact in &index.artifacts { + let fixed = policy.artifact(&artifact.name).ok_or_else(|| { + report( + ReleaseError::Policy, + format!("unknown artifact `{}`", artifact.name), + ) + })?; + check_artifact(artifact, fixed, &index.tsjs_inventory, &mut names)?; + } + validate_index_schema(&value)?; + Ok(index) +} + +fn check_index_header( + index: &ReleaseIndex, + policy: &ConsumerPolicy, + approved: &ApprovedRelease, +) -> Result<(), Report> { + if index.schema_version != 1 { + return Err(report( + ReleaseError::Policy, + "unsupported release-index schema version", + )); + } + if index.repository != policy.repository() || index.cohort != policy.cohort() { + return Err(report( + ReleaseError::Policy, + "release index names another repository or cohort", + )); + } + ReleaseTag::parse(&index.tag)?; + if index.tag != approved.tag().as_str() { + return Err(report( + ReleaseError::Policy, + "release index tag is not the approved tag", + )); + } + if !lower_hex(&index.source_commit, 40) || index.source_commit != approved.commit().as_str() { + return Err(report( + ReleaseError::Policy, + "release index source commit is not the approved commit", + )); + } + if index.recipe_source_commit != index.source_commit + || index.cohort_source_commit != index.source_commit + { + return Err(report( + ReleaseError::Policy, + "recipe and cohort source commits must equal the approved source", + )); + } + if index.policy_version < 1 || !lower_hex(&index.policy_sha256, 64) { + return Err(report( + ReleaseError::Policy, + "release index producer policy identity is malformed", + )); + } + Ok(()) +} + +fn check_file( + record: &FileRecord, + minimum: u64, + maximum: u64, + names: &mut BTreeSet, +) -> Result<(), Report> { + if !safe_name(&record.name) { + return Err(report( + ReleaseError::Policy, + "release file name is not a safe flat name", + )); + } + if record.size < minimum || record.size > maximum { + return Err(report( + ReleaseError::Policy, + format!( + "release file `{}` length is outside its role bound", + record.name + ), + )); + } + if !lower_hex(&record.sha256, 64) { + return Err(report( + ReleaseError::Policy, + format!( + "release file `{}` digest must be 64 lowercase hex digits", + record.name + ), + )); + } + if !names.insert(record.name.to_ascii_lowercase()) { + return Err(report( + ReleaseError::Policy, + format!("release file `{}` is listed more than once", record.name), + )); + } + Ok(()) +} + +fn check_artifact( + artifact: &IndexArtifact, + fixed: &FixedArtifact, + inventory: &FileRecord, + names: &mut BTreeSet, +) -> Result<(), Report> { + let file = FileRecord { + name: artifact.name.clone(), + size: artifact.size, + sha256: artifact.sha256.clone(), + }; + check_file(&file, 1, bounds::EXECUTABLE_BYTES, names)?; + check_capabilities(artifact)?; + let declared = DeclaredIdentity { + role: &artifact.role, + kind: &artifact.kind, + package: &artifact.package, + target: &artifact.target, + variant: &artifact.variant, + assurance_profile: &artifact.assurance_profile, + capabilities: &artifact.capabilities, + compatibility: &artifact.compatibility, + }; + if !fixed.matches(&declared) { + return Err(report( + ReleaseError::Policy, + format!( + "artifact `{}` differs from the supported role/kind/package/target/variant/profile/capability combination", + artifact.name + ), + )); + } + if artifact.js_inventory_sha256 != inventory.sha256 { + return Err(report( + ReleaseError::Policy, + format!( + "artifact `{}` names a different JS inventory", + artifact.name + ), + )); + } + check_companions(artifact, names) +} + +fn check_companions( + artifact: &IndexArtifact, + names: &mut BTreeSet, +) -> Result<(), Report> { + for (suffix, maximum, record) in artifact.companions.records() { + if record.name != format!("{}{suffix}", artifact.name) { + return Err(report( + ReleaseError::Policy, + format!( + "artifact `{}` companion must be named `{}{suffix}`", + artifact.name, artifact.name + ), + )); + } + check_file(record, 0, maximum, names)?; + } + Ok(()) +} + +fn check_capabilities(artifact: &IndexArtifact) -> Result<(), Report> { + let unique: BTreeSet<&str> = artifact.capabilities.iter().map(String::as_str).collect(); + if unique.is_empty() || unique.len() != artifact.capabilities.len() || unique.len() > 2 { + return Err(report( + ReleaseError::Policy, + format!( + "artifact `{}` capabilities must be one or two unique values", + artifact.name + ), + )); + } + check_capability_vocabulary(&artifact.capabilities) +} + +/// Requires every number to be a nonnegative safe integer token. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] for negative, fractional or unsafe numbers. +pub(crate) fn validate_safe_integers(value: &Value) -> Result<(), Report> { + match value { + Value::Number(number) + if number + .as_u64() + .is_none_or(|integer| integer > SAFE_INTEGER_MAX) => + { + Err(report( + ReleaseError::Document, + "closed release documents require nonnegative safe integer tokens", + )) + } + Value::Object(values) => values.values().try_for_each(validate_safe_integers), + Value::Array(values) => values.iter().try_for_each(validate_safe_integers), + _ => Ok(()), + } +} + +// Serde may otherwise convert overflowing integer tokens to finite floats, so +// every numeric token outside strings is range-checked lexically first. +fn validate_numbers(text: &str) -> Result<(), Report> { + let bytes = text.as_bytes(); + let mut cursor = 0; + while cursor < bytes.len() { + if bytes[cursor] == b'"' { + cursor += 1; + while cursor < bytes.len() { + match bytes[cursor] { + b'\\' => cursor += 2, + b'"' => { + cursor += 1; + break; + } + _ => cursor += 1, + } + } + } else if bytes[cursor] == b'-' || bytes[cursor].is_ascii_digit() { + let start = cursor; + while cursor < bytes.len() + && matches!( + bytes[cursor], + b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9' + ) + { + cursor += 1; + } + let token = text.get(start..cursor).unwrap_or_default(); + let valid = if token.contains(['.', 'e', 'E']) { + token.parse::().is_ok_and(f64::is_finite) + } else if token.starts_with('-') { + token.parse::().is_ok() + } else { + token.parse::().is_ok() + }; + if !valid { + return Err(report( + ReleaseError::Document, + "JSON number exceeds its finite numeric range", + )); + } + } else { + cursor += 1; + } + } + Ok(()) +} + +struct StrictSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for StrictSeed { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictSeed { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON with unique object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.to_owned())) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite number")) + } + + fn visit_seq>(self, mut access: A) -> Result { + if self.depth > bounds::JSON_DEPTH { + return Err(serde::de::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Vec::new(); + while let Some(value) = access.next_element_seed(StrictSeed { + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map>(self, mut access: A) -> Result { + if self.depth > bounds::JSON_DEPTH { + return Err(serde::de::Error::custom("JSON container depth exceeds 64")); + } + let mut values = Map::new(); + while let Some(key) = access.next_key::()? { + if values.contains_key(&key) { + return Err(serde::de::Error::custom("duplicate object key")); + } + let value = access.next_value_seed(StrictSeed { + depth: self.depth + 1, + })?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} diff --git a/crates/trusted-server-cli/src/release/environment.rs b/crates/trusted-server-cli/src/release/environment.rs new file mode 100644 index 0000000000..7e349d69ab --- /dev/null +++ b/crates/trusted-server-cli/src/release/environment.rs @@ -0,0 +1,107 @@ +//! Caller environment snapshots and the explicit GitHub credential. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; + +use error_stack::Report; + +use super::error::{ReleaseError, report}; + +/// Text substituted for the credential in every diagnostic. +pub(crate) const REDACTED: &str = "[redacted]"; + +/// A snapshot of the caller's environment, taken once. +/// +/// The gh runner reads only explicit entries from it and never lets the child +/// inherit it. +#[derive(Clone, Debug, Default)] +pub(crate) struct CallerEnvironment(BTreeMap); + +impl CallerEnvironment { + /// Snapshots the current process environment. + pub(crate) fn from_process() -> Self { + Self(std::env::vars_os().collect()) + } + + /// Builds a snapshot from explicit pairs. + #[cfg(test)] + pub(crate) fn from_pairs(pairs: I) -> Self + where + K: Into, + V: Into, + I: IntoIterator, + { + Self( + pairs + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + ) + } + + /// Returns one variable's value. + pub(crate) fn get(&self, name: &str) -> Option<&OsStr> { + self.0.get(OsStr::new(name)).map(OsString::as_os_str) + } + + /// Iterates over variable names. + pub(crate) fn names(&self) -> impl Iterator { + self.0.keys().map(OsString::as_os_str) + } +} + +/// An explicit read-only GitHub credential. +/// +/// Its [`fmt::Debug`] output never includes the secret. +#[derive(Clone)] +pub(crate) struct GithubToken(String); + +impl fmt::Debug for GithubToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "GithubToken({REDACTED})") + } +} + +impl GithubToken { + /// Selects explicit `GH_TOKEN`, else `GITHUB_TOKEN`, from the snapshot. + /// + /// Stored `gh auth login` credentials are deliberately not used. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Environment`] when neither variable holds a + /// nonempty, printable, whitespace-free value. + pub(crate) fn select(environment: &CallerEnvironment) -> Result> { + let selected = ["GH_TOKEN", "GITHUB_TOKEN"] + .into_iter() + .find_map(|name| environment.get(name).filter(|value| !value.is_empty())) + .ok_or_else(|| { + report( + ReleaseError::Environment, + "release verification requires an explicit read-only GH_TOKEN \ + (or GITHUB_TOKEN); stored `gh auth login` credentials are not used", + ) + })?; + let token = selected + .to_str() + .filter(|token| token.bytes().all(|byte| byte.is_ascii_graphic())) + .ok_or_else(|| { + report( + ReleaseError::Environment, + "the GitHub token must be printable ASCII without whitespace", + ) + })?; + Ok(Self(token.to_owned())) + } + + /// Returns the secret for the single place it is transmitted. + pub(crate) fn expose(&self) -> &str { + &self.0 + } + + /// Replaces every occurrence of the secret in `text`. + pub(crate) fn redact(&self, text: &str) -> String { + text.replace(&self.0, REDACTED) + } +} diff --git a/crates/trusted-server-cli/src/release/error.rs b/crates/trusted-server-cli/src/release/error.rs new file mode 100644 index 0000000000..850bb5664e --- /dev/null +++ b/crates/trusted-server-cli/src/release/error.rs @@ -0,0 +1,61 @@ +//! Concrete release verification errors. + +use std::fmt; + +use error_stack::Report; + +/// Reports why release verification refused an input, policy or process. +/// +/// Every variant is fail-closed: no verified value is constructed after any of +/// them. Diagnostic attachments never contain credentials. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ReleaseError { + /// A JSON document is malformed, ambiguous, oversized or schema-invalid. + #[display("invalid release document")] + Document, + /// The embedded consumer policy or a release index violates the compiled contract. + #[display("release policy rejects the input")] + Policy, + /// The producer policy identity is not current or reviewed historical policy. + #[display("producer release policy is not accepted")] + ProducerPolicy, + /// The approved-source policy could not be acquired from the fixed endpoint. + #[display("approved source policy acquisition failed")] + PolicyFetch, + /// The caller environment cannot safely run the GitHub CLI. + #[display("GitHub CLI environment is rejected")] + Environment, + /// The resolved GitHub CLI is missing or not the qualified version. + #[display("unsupported GitHub CLI")] + UnsupportedTool, + /// A child process could not be spawned, signalled or reaped. + #[display("GitHub CLI process failed")] + Process, + /// A child or attempt deadline elapsed. + #[display("release verification deadline elapsed")] + Timeout, + /// The caller cancelled the verification attempt. + #[display("release verification was cancelled")] + Cancelled, + /// Child stdout or stderr exceeded its byte bound. + #[display("GitHub CLI output exceeds its byte bound")] + OutputLimit, + /// A descendant left the child process group and kept its pipes open. + #[display("a GitHub CLI descendant escaped process-group termination")] + DescendantEscaped, + /// The child exited unsuccessfully; its output is never trusted. + #[display("GitHub CLI exited unsuccessfully")] + ExitFailure, + /// Successful child output is not exactly one attestation result. + #[display("GitHub CLI output is rejected")] + Output, +} + +impl core::error::Error for ReleaseError {} + +/// Builds a report for `context` with one diagnostic attachment. +/// +/// Callers must never pass credentials or secret context in `message`. +pub(super) fn report(context: ReleaseError, message: impl fmt::Display) -> Report { + Report::new(context).attach(message.to_string()) +} diff --git a/crates/trusted-server-cli/src/release/gh.rs b/crates/trusted-server-cli/src/release/gh.rs new file mode 100644 index 0000000000..48ca25e928 --- /dev/null +++ b/crates/trusted-server-cli/src/release/gh.rs @@ -0,0 +1,464 @@ +//! Bounded, isolated invocation of the pinned GitHub CLI. +//! +//! gh is resolved once (explicit trusted path or one trusted `PATH` lookup) +//! to a canonical executable that every later invocation reuses. Its +//! `--version` text must report exactly the qualified version; that is a +//! compatibility diagnostic only, because version text never authenticates an +//! executable. +//! +//! Each child gets a cleared environment carrying only the explicit token (as +//! `GH_TOKEN`), private home/config/state/cache directories and disabled +//! prompts. It runs in its own process group with fixed argument vectors, a +//! per-child and per-attempt deadline, cancellation, and concurrently drained +//! byte-capped pipes. On any limit the whole group is killed and the leader +//! reaped; a descendant that escaped the group and still holds the pipes is +//! reported instead of awaited. A failed exit never yields parsed claims. + +use std::ffi::OsString; +use std::fs::DirBuilder; +use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use error_stack::Report; +use serde_json::Value; +use tempfile::TempDir; + +use super::bounds::{self, AttemptDeadline, Cancellation, ProcessBounds}; +use super::documents::{ApprovedRelease, ReleaseTag, Role, parse_json}; +use super::environment::{CallerEnvironment, GithubToken}; +use super::error::{ReleaseError, report}; +use super::policy::{ + GH_VERSION, GITHUB_HOST, ISSUER, PROVENANCE_PREDICATE, REPOSITORY, SBOM_PREDICATE, + certificate_identity, +}; +use super::process::supervise; + +const MINIMAL_PATH: &str = "/usr/bin:/bin"; +const STDERR_EXCERPT_BYTES: usize = 4096; +const VERSION_EXCERPT_CHARS: usize = 128; +/// `gh release` subcommands take a host-qualified repository. +fn release_repository() -> String { + format!("{GITHUB_HOST}/{REPOSITORY}") +} + +/// Where to find the gh executable. +#[derive(Clone, Copy, Debug)] +pub(crate) enum GhLocation<'a> { + /// An absolute path from trusted installer configuration. + Explicit(&'a Path), + /// One lookup through the absolute entries of the snapshot's `PATH`. + SearchPath, +} + +/// The attestation predicate a verification requires. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PredicateKind { + /// SLSA build provenance. + Provenance, + /// SPDX SBOM. + Spdx, +} + +impl PredicateKind { + fn bundle_suffix(self) -> &'static str { + match self { + Self::Provenance => ".provenance.sigstore.json", + Self::Spdx => ".sbom.sigstore.json", + } + } + + fn uri(self) -> &'static str { + match self { + Self::Provenance => PROVENANCE_PREDICATE, + Self::Spdx => SBOM_PREDICATE, + } + } +} + +fn absolute(path: &Path, what: &str) -> Result> { + if !path.is_absolute() { + return Err(report( + ReleaseError::Policy, + format!("{what} path must be absolute"), + )); + } + Ok(path.as_os_str().to_owned()) +} + +/// Builds the fixed `gh attestation verify` argument vector. +/// +/// `bundle` must be the retained, membership-checked companion bundle for +/// `predicate` (`.provenance.sigstore.json` or `.sbom.sigstore.json`), so gh +/// verifies exactly that bundle instead of fetching attestations itself. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Policy`] unless `subject` and `bundle` are absolute +/// (so neither can be parsed as a flag) and the bundle name matches the +/// predicate. +pub(crate) fn attestation_verify_args( + subject: &Path, + bundle: &Path, + release: &ApprovedRelease, + predicate: PredicateKind, +) -> Result, Report> { + let subject = absolute(subject, "attestation subject")?; + let bundle_name = bundle.file_name().map(|name| name.to_string_lossy()); + if !bundle_name.is_some_and(|name| name.ends_with(predicate.bundle_suffix())) { + return Err(report( + ReleaseError::Policy, + "attestation bundle does not match the requested predicate", + )); + } + let bundle = absolute(bundle, "attestation bundle")?; + let commit = release.commit().as_str(); + let fixed = ["attestation".to_owned(), "verify".to_owned()].map(OsString::from); + let options = [ + "-R".to_owned(), + REPOSITORY.to_owned(), + "--hostname".to_owned(), + GITHUB_HOST.to_owned(), + "--cert-identity".to_owned(), + certificate_identity(release.tag()), + "--cert-oidc-issuer".to_owned(), + ISSUER.to_owned(), + "--source-ref".to_owned(), + format!("refs/tags/{}", release.tag().as_str()), + "--source-digest".to_owned(), + commit.to_owned(), + "--signer-digest".to_owned(), + commit.to_owned(), + "--deny-self-hosted-runners".to_owned(), + "--predicate-type".to_owned(), + predicate.uri().to_owned(), + "--format".to_owned(), + "json".to_owned(), + ] + .map(OsString::from); + Ok(fixed + .into_iter() + .chain([subject, OsString::from("--bundle"), bundle]) + .chain(options) + .collect()) +} + +/// Builds the fixed `gh release verify` argument vector. +pub(crate) fn release_verify_args(tag: &ReleaseTag) -> Vec { + vec![ + OsString::from("release"), + OsString::from("verify"), + OsString::from(tag.as_str()), + OsString::from("-R"), + OsString::from(release_repository()), + ] +} + +/// Builds the fixed `gh release verify-asset` argument vector. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Policy`] unless `asset` is absolute. +pub(crate) fn release_verify_asset_args( + tag: &ReleaseTag, + asset: &Path, +) -> Result, Report> { + let asset = absolute(asset, "release asset")?; + Ok(vec![ + OsString::from("release"), + OsString::from("verify-asset"), + OsString::from(tag.as_str()), + asset, + OsString::from("-R"), + OsString::from(release_repository()), + ]) +} + +/// Captured output of a successful child. +#[derive(Debug)] +pub(crate) struct ChildOutput { + pub(crate) stdout: Vec, + /// Lossily decoded stderr with the credential redacted. + pub(crate) stderr: String, +} + +/// Private directories and the reviewed non-secret variables for every gh child. +/// +/// The credential is never stored here; it stays in [`GithubToken`] (whose +/// `Debug` is redacted) and is injected only when a child is spawned. +#[derive(Debug)] +struct ChildEnvironment { + root: TempDir, + variables: Vec<(OsString, OsString)>, +} + +/// Fixed non-secret settings for every gh child. +const FIXED_VARIABLES: [(&str, &str); 8] = [ + ("GH_PROMPT_DISABLED", "1"), + ("GH_NO_UPDATE_NOTIFIER", "1"), + ("GH_NO_EXTENSION_UPDATE_NOTIFIER", "1"), + ("GH_SPINNER_DISABLED", "1"), + ("DO_NOT_TRACK", "1"), + ("NO_COLOR", "1"), + ("LC_ALL", "C"), + ("PATH", MINIMAL_PATH), +]; + +/// Private directory variables, relative to the isolated root. +const PRIVATE_DIRECTORIES: [(&str, &str); 7] = [ + ("GH_CONFIG_DIR", "config/gh"), + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_CACHE_HOME", "cache"), + ("XDG_DATA_HOME", "data"), + ("TMPDIR", "tmp"), + ("HOME", "home"), +]; + +fn reject_loader_injection(caller: &CallerEnvironment) -> Result<(), Report> { + let injected = caller.names().find(|name| { + let name = name.to_string_lossy(); + name.starts_with("LD_") || name.starts_with("DYLD_") + }); + match injected { + Some(name) => Err(report( + ReleaseError::Environment, + format!( + "dynamic-loader variable {} is set; refusing to run gh", + name.to_string_lossy() + ), + )), + None => Ok(()), + } +} + +impl ChildEnvironment { + fn isolated(caller: &CallerEnvironment) -> Result<(Self, GithubToken), Report> { + reject_loader_injection(caller)?; + let token = GithubToken::select(caller)?; + let root = tempfile::Builder::new() + .prefix("ts-release-gh-") + .tempdir() + .map_err(|source| report(ReleaseError::Environment, source))?; + let mut variables = Vec::with_capacity(PRIVATE_DIRECTORIES.len() + FIXED_VARIABLES.len()); + for (name, relative) in PRIVATE_DIRECTORIES { + let path = root.path().join(relative); + DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&path) + .map_err(|source| report(ReleaseError::Environment, source))?; + variables.push((OsString::from(name), path.into_os_string())); + } + variables.extend( + FIXED_VARIABLES.map(|(name, value)| (OsString::from(name), OsString::from(value))), + ); + Ok((Self { root, variables }, token)) + } +} + +fn is_executable_file(path: &Path) -> bool { + std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +fn locate( + location: GhLocation<'_>, + caller: &CallerEnvironment, +) -> Result> { + let candidate = match location { + GhLocation::Explicit(path) => { + if !path.is_absolute() { + return Err(report( + ReleaseError::UnsupportedTool, + "explicit gh path must be absolute", + )); + } + path.to_path_buf() + } + GhLocation::SearchPath => caller + .get("PATH") + .and_then(|search| { + std::env::split_paths(search) + .filter(|directory| directory.is_absolute()) + .map(|directory| directory.join("gh")) + .find(|candidate| is_executable_file(candidate)) + }) + .ok_or_else(|| { + report( + ReleaseError::UnsupportedTool, + "gh was not found in any absolute PATH entry", + ) + })?, + }; + let canonical = candidate + .canonicalize() + .map_err(|source| report(ReleaseError::UnsupportedTool, source))?; + if !is_executable_file(&canonical) { + return Err(report( + ReleaseError::UnsupportedTool, + "gh path is not an executable regular file", + )); + } + Ok(canonical) +} + +fn check_version(stdout: &[u8], token: &GithubToken) -> Result<(), Report> { + let text = String::from_utf8_lossy(stdout); + let first = text.lines().next().unwrap_or_default(); + let qualified = first.strip_prefix("gh version ").is_some_and(|rest| { + let (version, tail) = rest.split_once(' ').unwrap_or((rest, "")); + version == GH_VERSION && (tail.is_empty() || (tail.starts_with('(') && tail.ends_with(')'))) + }); + if qualified { + return Ok(()); + } + let excerpt: String = token + .redact(first) + .chars() + .take(VERSION_EXCERPT_CHARS) + .collect(); + Err(report( + ReleaseError::UnsupportedTool, + format!( + "gh reported `{excerpt}`; release verification requires exactly gh {GH_VERSION}. \ + This is a compatibility diagnostic: version text does not authenticate the executable" + ), + )) +} + +/// A resolved, version-checked gh executable with an isolated environment. +#[derive(Debug)] +pub(crate) struct GhRunner { + executable: PathBuf, + environment: ChildEnvironment, + token: GithubToken, + bounds: ProcessBounds, +} + +impl GhRunner { + /// Resolves gh once, isolates its environment and checks its version. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Environment`] for loader injection or a missing + /// explicit token, [`ReleaseError::UnsupportedTool`] when gh is missing or + /// not exactly the qualified version, or a process error from the + /// version check. + pub(crate) fn resolve( + location: GhLocation<'_>, + caller: &CallerEnvironment, + bounds: ProcessBounds, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + let (environment, token) = ChildEnvironment::isolated(caller)?; + let executable = locate(location, caller)?; + let runner = Self { + executable, + environment, + token, + bounds, + }; + let output = runner.run(&[OsString::from("--version")], deadline, cancellation)?; + check_version(&output.stdout, &runner.token)?; + Ok(runner) + } + + /// Replaces the process bounds; tests use short deadlines and caps while + /// production keeps [`ProcessBounds::production`]. + #[cfg(test)] + pub(crate) fn with_bounds(mut self, bounds: ProcessBounds) -> Self { + self.bounds = bounds; + self + } + + /// Returns the canonical executable every invocation uses. + pub(crate) fn executable(&self) -> &Path { + &self.executable + } + + /// Runs gh with a fixed argument vector under all bounds. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Timeout`], [`ReleaseError::Cancelled`], + /// [`ReleaseError::OutputLimit`], [`ReleaseError::DescendantEscaped`], + /// [`ReleaseError::Process`] or [`ReleaseError::ExitFailure`]; output from + /// a failed child is never returned. + pub(crate) fn run( + &self, + args: &[OsString], + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + let mut command = Command::new(&self.executable); + command + .args(args) + .env_clear() + .envs(self.environment.variables.iter().cloned()) + .env("GH_TOKEN", self.token.expose()) + .current_dir(self.environment.root.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + let completed = supervise(&mut command, &self.bounds, deadline, cancellation)?; + let stderr = self + .token + .redact(&String::from_utf8_lossy(&completed.stderr)); + if !completed.status.success() { + let excerpt: String = stderr.chars().take(STDERR_EXCERPT_BYTES).collect(); + return Err(report( + ReleaseError::ExitFailure, + format!("gh {}; stderr: {excerpt}", completed.status), + )); + } + Ok(ChildOutput { + stdout: completed.stdout, + stderr, + }) + } + + /// Runs one fixed `gh attestation verify` against one retained, + /// membership-checked bundle and returns its single result. + /// + /// # Errors + /// + /// Returns any [`Self::run`] error, [`ReleaseError::Document`] for + /// malformed or incomplete JSON, or [`ReleaseError::Output`] unless stdout + /// is an array of exactly one attestation result. + pub(crate) fn verify_attestation( + &self, + subject: &Path, + bundle: &Path, + release: &ApprovedRelease, + predicate: PredicateKind, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + let args = attestation_verify_args(subject, bundle, release, predicate)?; + let output = self.run(&args, deadline, cancellation)?; + let value = parse_json(&output.stdout, Role::GhOutput)?; + let Value::Array(results) = value else { + return Err(report( + ReleaseError::Output, + "gh attestation output must be a JSON array", + )); + }; + if results.len() != bounds::MATCHING_ATTESTATIONS_PER_BUNDLE { + return Err(report( + ReleaseError::Output, + format!( + "gh returned {} attestation results; exactly one is required", + results.len() + ), + )); + } + results + .into_iter() + .next() + .ok_or_else(|| report(ReleaseError::Output, "gh returned no attestation result")) + } +} diff --git a/crates/trusted-server-cli/src/release/mod.rs b/crates/trusted-server-cli/src/release/mod.rs new file mode 100644 index 0000000000..1fe65ce502 --- /dev/null +++ b/crates/trusted-server-cli/src/release/mod.rs @@ -0,0 +1,18 @@ +//! Consumer-side verification of attested Trusted Server releases. +//! +//! Everything here is private to the CLI. The embedded consumer policy and the +//! compiled constants in [`policy`] are the only verifier authority; downloaded +//! release metadata, operator configuration and forwarded gh flags never +//! redefine them. + +mod bounds; +mod documents; +mod environment; +mod error; +mod gh; +mod policy; +mod process; +mod source_policy; + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-cli/src/release/policy.rs b/crates/trusted-server-cli/src/release/policy.rs new file mode 100644 index 0000000000..46a4d8ee23 --- /dev/null +++ b/crates/trusted-server-cli/src/release/policy.rs @@ -0,0 +1,669 @@ +//! Embedded consumer release policy and producer-policy acceptance. +//! +//! The reviewed `release-policy.json` bytes are compiled in; their SHA-256 is +//! computed at runtime and never stored inside the policy, avoiding a +//! self-hash cycle. Parsing is closed: every field must equal the compiled +//! constants below, so neither a downloaded index nor operator input can +//! redefine repository identity, signer identity, supported combinations, +//! tool pins or resource bounds. + +use std::collections::BTreeSet; + +use error_stack::Report; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::bounds; +use super::documents::{ + Compatibility, ReleaseIndex, ReleaseTag, Role, lower_hex, parse_json, sha256_hex, +}; +use super::error::{ReleaseError, report}; +use super::source_policy::ApprovedSourcePolicy; + +/// Exact reviewed consumer policy bytes. +pub(crate) const EMBEDDED_POLICY: &[u8] = include_bytes!("../../release-policy.json"); +/// Policy version this consumer was compiled with. +pub(crate) const CONSUMER_POLICY_VERSION: u64 = 1; +/// Official repository slug. +pub(crate) const REPOSITORY: &str = "IABTechLab/trusted-server"; +/// GitHub host serving the official repository. +pub(crate) const GITHUB_HOST: &str = "github.com"; +/// Numeric repository identifier. +pub(crate) const REPOSITORY_ID: &str = "950860906"; +/// Numeric repository owner identifier. +pub(crate) const REPOSITORY_OWNER_ID: &str = "82903538"; +/// Fixed phase-one cohort. +pub(crate) const COHORT: &str = "phase1-fastly-cli"; +/// Sigstore certificate OIDC issuer. +pub(crate) const ISSUER: &str = "https://token.actions.githubusercontent.com"; +/// Certificate SAN and builder ID before the release tag. +pub(crate) const SIGNER_WORKFLOW_PREFIX: &str = + "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/"; +/// Exactly qualified GitHub CLI version. +pub(crate) const GH_VERSION: &str = "2.97.0"; +/// Pinned `actions/attest` version. +pub(crate) const ATTEST_VERSION: &str = "4.2.2"; +/// Pinned `actions/attest` commit. +pub(crate) const ATTEST_REVISION: &str = "1e69f48acb82d1966a394da916b4c1698aa569d6"; +/// SLSA provenance predicate type. +pub(crate) const PROVENANCE_PREDICATE: &str = "https://slsa.dev/provenance/v1"; +/// SPDX SBOM predicate type. +pub(crate) const SBOM_PREDICATE: &str = "https://spdx.dev/Document/v2.3"; +/// GitHub Actions workflow build type. +pub(crate) const BUILD_TYPE: &str = "https://actions.github.io/buildtypes/workflow/v1"; +/// The only action any artifact currently authorizes. +pub(crate) const READONLY_VERIFY: &str = "readonly-verify"; +/// Capability that the v1 schema allows but current policy forbids. +pub(crate) const STAGING_CAPABILITY: &str = "fastly-staging"; +/// Every capability the v1 schema can express. +pub(crate) const KNOWN_CAPABILITIES: [&str; 3] = + ["fastly-production", STAGING_CAPABILITY, "cli-execute"]; +const KNOWN_KINDS: [&str; 2] = ["wasm-core", "native-executable"]; +const KNOWN_PROFILES: [&str; 2] = ["provenance-sbom-repeatable", "provenance-sbom"]; +const KNOWN_ACTIONS: [&str; 1] = [READONLY_VERIFY]; + +/// One source-owned cohort identity; policy JSON can only restate it. +#[derive(Debug)] +pub(crate) struct FixedArtifact { + pub(crate) name: &'static str, + pub(crate) package: &'static str, + pub(crate) bin: &'static str, + pub(crate) kind: &'static str, + pub(crate) role: &'static str, + pub(crate) assurance_profile: &'static str, + pub(crate) capabilities: &'static [&'static str], + pub(crate) target: &'static str, + pub(crate) runtime: &'static str, + pub(crate) minimum_version: &'static str, + pub(crate) architecture: &'static str, +} + +/// The complete phase-one cohort, in policy order. +pub(crate) const FIXED_ARTIFACTS: [FixedArtifact; bounds::COHORT_EXECUTABLES] = [ + FixedArtifact { + name: "trusted-server-fastly.wasm", + package: "trusted-server-adapter-fastly", + bin: "trusted-server-adapter-fastly", + kind: "wasm-core", + role: "server", + assurance_profile: "provenance-sbom-repeatable", + capabilities: &["fastly-production"], + target: "wasm32-wasip1", + runtime: "fastly-compute", + minimum_version: "wasip1", + architecture: "wasm32", + }, + FixedArtifact { + name: "ts-aarch64-apple-darwin", + package: "trusted-server-cli", + bin: "ts", + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capabilities: &["cli-execute"], + target: "aarch64-apple-darwin", + runtime: "macos", + minimum_version: "13.0", + architecture: "aarch64", + }, + FixedArtifact { + name: "ts-x86_64-apple-darwin", + package: "trusted-server-cli", + bin: "ts", + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capabilities: &["cli-execute"], + target: "x86_64-apple-darwin", + runtime: "macos", + minimum_version: "13.0", + architecture: "x86_64", + }, + FixedArtifact { + name: "ts-x86_64-unknown-linux-gnu", + package: "trusted-server-cli", + bin: "ts", + kind: "native-executable", + role: "operator-cli", + assurance_profile: "provenance-sbom", + capabilities: &["cli-execute"], + target: "x86_64-unknown-linux-gnu", + runtime: "linux-gnu", + minimum_version: "glibc-2.34", + architecture: "x86_64", + }, +]; + +/// The only artifact variant in the phase-one cohort. +pub(crate) const FIXED_VARIANT: &str = "default"; + +/// Declared identity fields shared by policy and index artifact entries. +#[derive(Clone, Copy, Debug)] +pub(crate) struct DeclaredIdentity<'a> { + pub(crate) role: &'a str, + pub(crate) kind: &'a str, + pub(crate) package: &'a str, + pub(crate) target: &'a str, + pub(crate) variant: &'a str, + pub(crate) assurance_profile: &'a str, + pub(crate) capabilities: &'a [String], + pub(crate) compatibility: &'a Compatibility, +} + +impl FixedArtifact { + /// Returns the declared runtime compatibility of this artifact. + pub(crate) fn compatibility(&self) -> Compatibility { + Compatibility { + runtime: self.runtime.to_owned(), + minimum_version: self.minimum_version.to_owned(), + architecture: self.architecture.to_owned(), + } + } + + /// Returns whether a declared identity is exactly this supported + /// role/kind/package/target/variant/profile/capability/compatibility + /// combination. Capabilities compare as a set. + pub(crate) fn matches(&self, declared: &DeclaredIdentity<'_>) -> bool { + let declared_capabilities: BTreeSet<&str> = + declared.capabilities.iter().map(String::as_str).collect(); + let fixed_capabilities: BTreeSet<&str> = self.capabilities.iter().copied().collect(); + declared.role == self.role + && declared.kind == self.kind + && declared.package == self.package + && declared.target == self.target + && declared.variant == FIXED_VARIANT + && declared.assurance_profile == self.assurance_profile + && declared.capabilities.len() == fixed_capabilities.len() + && declared_capabilities == fixed_capabilities + && *declared.compatibility == self.compatibility() + } +} + +/// A reviewed historical producer-policy identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct HistoricalPolicy<'a> { + pub(crate) version: u64, + pub(crate) sha256: &'a str, +} + +/// Reviewed historical producer policies this consumer still accepts. +/// +/// Empty for consumer v1. A new CLI policy must explicitly retain or retire +/// each identity here; an index can never add one. +pub(crate) const HISTORICAL_PRODUCER_POLICIES: &[HistoricalPolicy<'static>] = &[]; + +/// Returns the exact certificate identity for a release tag. +pub(crate) fn certificate_identity(tag: &ReleaseTag) -> String { + format!("{SIGNER_WORKFLOW_PREFIX}{}", tag.as_str()) +} + +/// The validated consumer policy and its externally computed identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ConsumerPolicy { + version: u64, + sha256: String, +} + +impl ConsumerPolicy { + /// Returns the consumer policy version. + pub(crate) fn version(&self) -> u64 { + self.version + } + + /// Returns the SHA-256 of the exact policy bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Returns the official repository slug. + pub(crate) fn repository(&self) -> &'static str { + REPOSITORY + } + + /// Returns the fixed cohort name. + pub(crate) fn cohort(&self) -> &'static str { + COHORT + } + + /// Returns the supported cohort artifacts. + pub(crate) fn artifacts(&self) -> &'static [FixedArtifact] { + &FIXED_ARTIFACTS + } + + /// Returns the supported artifact with this exact name. + pub(crate) fn artifact(&self, name: &str) -> Option<&'static FixedArtifact> { + FIXED_ARTIFACTS + .iter() + .find(|artifact| artifact.name == name) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyDocument { + artifacts: Vec, + bounds: Value, + cohort: String, + github_host: String, + historical_producer_policies: Vec, + identity: Value, + policy_version: u64, + predicates: Value, + public_actions: Vec, + repository: String, + repository_id: String, + repository_owner_id: String, + tool_versions: Value, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyArtifact { + actions: Vec, + assurance_profile: String, + bin: String, + capabilities: Vec, + compatibility: Compatibility, + default_features: bool, + features: Vec, + kind: String, + name: String, + package: String, + role: String, + target: String, + variant: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoricalDocument { + policy_version: u64, + policy_sha256: String, +} + +/// Parses and validates the compiled-in consumer policy. +/// +/// # Errors +/// +/// Returns an error if the embedded bytes violate the compiled contract, +/// which indicates an unreviewed policy edit. +pub(crate) fn embedded_policy() -> Result> { + parse_consumer_policy(EMBEDDED_POLICY) +} + +/// Parses consumer policy bytes and requires them to equal the compiled contract. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] for malformed JSON and +/// [`ReleaseError::Policy`] for unknown fields, kinds, profiles, actions or +/// capabilities, forbidden `fastly-staging`, or any changed identity, tool pin +/// or bound. +pub(crate) fn parse_consumer_policy(bytes: &[u8]) -> Result> { + let value = parse_json(bytes, Role::Policy)?; + let document = PolicyDocument::deserialize(&value) + .map_err(|source| report(ReleaseError::Policy, source))?; + check_scalars(&document)?; + check_structures(&document)?; + check_artifacts(&document.artifacts)?; + Ok(ConsumerPolicy { + version: document.policy_version, + sha256: sha256_hex(bytes), + }) +} + +fn check_scalars(document: &PolicyDocument) -> Result<(), Report> { + if document.policy_version != CONSUMER_POLICY_VERSION { + return Err(report( + ReleaseError::Policy, + "consumer policy version is not the compiled version", + )); + } + if document.repository != REPOSITORY + || document.github_host != GITHUB_HOST + || document.repository_id != REPOSITORY_ID + || document.repository_owner_id != REPOSITORY_OWNER_ID + || document.cohort != COHORT + { + return Err(report( + ReleaseError::Policy, + "policy repository, host, numeric IDs or cohort differ from compiled values", + )); + } + check_actions(&document.public_actions)?; + let historical: Vec<(u64, &str)> = document + .historical_producer_policies + .iter() + .map(|entry| (entry.policy_version, entry.policy_sha256.as_str())) + .collect(); + let compiled: Vec<(u64, &str)> = HISTORICAL_PRODUCER_POLICIES + .iter() + .map(|entry| (entry.version, entry.sha256)) + .collect(); + if historical != compiled { + return Err(report( + ReleaseError::Policy, + "policy historical producer list differs from the compiled allowlist", + )); + } + Ok(()) +} + +fn check_structures(document: &PolicyDocument) -> Result<(), Report> { + if document.tool_versions + != json!({ + "actions_attest": ATTEST_VERSION, + "actions_attest_revision": ATTEST_REVISION, + "gh": GH_VERSION, + }) + { + return Err(report( + ReleaseError::Policy, + "policy tool versions differ from the qualified gh and attest pins", + )); + } + if document.predicates + != json!({ + "build_type": BUILD_TYPE, + "provenance": PROVENANCE_PREDICATE, + "sbom": SBOM_PREDICATE, + }) + { + return Err(report( + ReleaseError::Policy, + "policy predicates differ from compiled values", + )); + } + if document.identity != expected_identity() { + return Err(report( + ReleaseError::Policy, + "policy signer identity differs from compiled values", + )); + } + if document.bounds != expected_bounds() { + return Err(report( + ReleaseError::Policy, + "policy bounds differ from compiled R-BOUNDS", + )); + } + Ok(()) +} + +fn expected_identity() -> Value { + let template = format!("{SIGNER_WORKFLOW_PREFIX}"); + json!({ + "builder_id": template, + "caller_commit": "same-approved-source-commit", + "caller_path": ".github/workflows/release.yml", + "caller_repository": format!("https://{GITHUB_HOST}/{REPOSITORY}"), + "certificate_fields": [ + "buildConfigDigest", + "buildConfigURI", + "buildTrigger", + "runInvocationURI", + "sourceRepositoryIdentifier", + "sourceRepositoryOwnerIdentifier", + ], + "certificate_san": template, + "event": "push", + "external_parameters": "exact-caller-workflow-only-no-inputs", + "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance", + "issuer": ISSUER, + "runner": "github-hosted", + "signer_commit": "same-approved-source-commit", + "source_commit": "independently-approved-40-lowercase-hex", + "source_dependency": format!("git+https://{GITHUB_HOST}/{REPOSITORY}@refs/tags/"), + "source_ref": "refs/tags/", + }) +} + +fn expected_bounds() -> Value { + json!({ + "attempt_seconds": bounds::ATTEMPT_SECONDS, + "bundle_bytes": bounds::BUNDLE_BYTES, + "child_seconds": bounds::CHILD_SECONDS, + "cohort_executables": bounds::COHORT_EXECUTABLES, + "companion_bytes": bounds::COMPANION_BYTES, + "executable_bytes": bounds::EXECUTABLE_BYTES, + "index_bytes": bounds::INDEX_BYTES, + "inventory_bytes": bounds::INVENTORY_BYTES, + "json_depth": bounds::JSON_DEPTH, + "matching_attestations_per_bundle": bounds::MATCHING_ATTESTATIONS_PER_BUNDLE, + "metadata_bytes": bounds::METADATA_BYTES, + "policy_bytes": bounds::POLICY_BYTES, + "spdx_bytes": bounds::SPDX_BYTES, + "stderr_bytes": bounds::STDERR_BYTES, + "stdout_bytes": bounds::STDOUT_BYTES, + "trusted_roots_bytes": bounds::TRUSTED_ROOTS_BYTES, + }) +} + +fn check_actions(actions: &[String]) -> Result<(), Report> { + if let Some(unknown) = actions + .iter() + .find(|action| !KNOWN_ACTIONS.contains(&action.as_str())) + { + return Err(report( + ReleaseError::Policy, + format!("unknown policy action `{unknown}`"), + )); + } + if actions != [READONLY_VERIFY] { + return Err(report( + ReleaseError::Policy, + "policy actions must be exactly [readonly-verify]", + )); + } + Ok(()) +} + +fn check_artifacts(artifacts: &[PolicyArtifact]) -> Result<(), Report> { + if artifacts.len() != FIXED_ARTIFACTS.len() { + return Err(report( + ReleaseError::Policy, + "policy must list exactly the cohort artifacts", + )); + } + let mut names = BTreeSet::new(); + for artifact in artifacts { + if !names.insert(artifact.name.as_str()) { + return Err(report( + ReleaseError::Policy, + format!("policy lists `{}` more than once", artifact.name), + )); + } + check_policy_artifact(artifact)?; + } + Ok(()) +} + +fn check_policy_artifact(artifact: &PolicyArtifact) -> Result<(), Report> { + if !KNOWN_KINDS.contains(&artifact.kind.as_str()) { + return Err(report( + ReleaseError::Policy, + format!("unknown artifact kind `{}`", artifact.kind), + )); + } + if !KNOWN_PROFILES.contains(&artifact.assurance_profile.as_str()) { + return Err(report( + ReleaseError::Policy, + format!("unknown assurance profile `{}`", artifact.assurance_profile), + )); + } + check_actions(&artifact.actions)?; + check_capability_vocabulary(&artifact.capabilities)?; + let fixed = FIXED_ARTIFACTS + .iter() + .find(|fixed| fixed.name == artifact.name) + .ok_or_else(|| { + report( + ReleaseError::Policy, + format!("unknown policy artifact `{}`", artifact.name), + ) + })?; + let declared = DeclaredIdentity { + role: &artifact.role, + kind: &artifact.kind, + package: &artifact.package, + target: &artifact.target, + variant: &artifact.variant, + assurance_profile: &artifact.assurance_profile, + capabilities: &artifact.capabilities, + compatibility: &artifact.compatibility, + }; + if !fixed.matches(&declared) + || artifact.bin != fixed.bin + || !artifact.default_features + || !artifact.features.is_empty() + { + return Err(report( + ReleaseError::Policy, + format!( + "policy artifact `{}` differs from the supported combination", + artifact.name + ), + )); + } + Ok(()) +} + +/// Rejects capabilities the v1 schema cannot express and the +/// structurally valid but policy-forbidden `fastly-staging`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Policy`] for an unknown or forbidden capability. +pub(crate) fn check_capability_vocabulary( + capabilities: &[String], +) -> Result<(), Report> { + if let Some(unknown) = capabilities + .iter() + .find(|capability| !KNOWN_CAPABILITIES.contains(&capability.as_str())) + { + return Err(report( + ReleaseError::Policy, + format!("unknown capability `{unknown}`"), + )); + } + if capabilities + .iter() + .any(|capability| capability == STAGING_CAPABILITY) + { + return Err(report( + ReleaseError::Policy, + "fastly-staging is structurally valid but forbidden by current policy", + )); + } + Ok(()) +} + +/// Which accepted producer policy an index was built under. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum AcceptedProducerPolicy { + /// The producer policy is byte-identical to this consumer's policy. + Current, + /// The producer policy is a reviewed historical identity. + Historical { version: u64, sha256: String }, +} + +/// The consumer identity and allowlist that producer policies are judged by. +#[derive(Clone, Copy, Debug)] +pub(crate) struct AcceptanceRules<'a> { + pub(crate) current_version: u64, + pub(crate) current_sha256: &'a str, + pub(crate) historical: &'a [HistoricalPolicy<'a>], +} + +impl<'a> AcceptanceRules<'a> { + /// Returns the compiled rules for this consumer. + pub(crate) fn compiled(policy: &'a ConsumerPolicy) -> Self { + Self { + current_version: policy.version(), + current_sha256: policy.sha256(), + historical: HISTORICAL_PRODUCER_POLICIES, + } + } +} + +/// Returns the exact-byte identity (version, SHA-256) of approved-source bytes. +fn producer_identity( + approved: &ApprovedSourcePolicy, +) -> Result<(u64, String), Report> { + let producer = parse_json(approved.bytes(), Role::Policy)?; + let version = producer + .get("policy_version") + .and_then(Value::as_u64) + .filter(|version| *version >= 1) + .ok_or_else(|| { + report( + ReleaseError::ProducerPolicy, + "approved source policy has no integer version", + ) + })?; + Ok((version, sha256_hex(approved.bytes()))) +} + +/// Binds an index's producer-policy identity to approved-source bytes and +/// accepts only the current policy or a reviewed historical identity. +/// +/// The index never selects the consumer policy; acceptance only decides +/// whether the producer contract is one this consumer has reviewed. The +/// consumer's own compiled constraints stay in force either way. +/// +/// # Errors +/// +/// Returns [`ReleaseError::ProducerPolicy`] if the approved source belongs to +/// another commit, the index identity differs from the approved-source bytes, +/// the producer version is newer than the consumer, or the identity is neither +/// current nor allowlisted. +pub(crate) fn accept_producer_policy( + index: &ReleaseIndex, + approved: &ApprovedSourcePolicy, + rules: &AcceptanceRules<'_>, +) -> Result> { + if approved.commit().as_str() != index.source_commit { + return Err(report( + ReleaseError::ProducerPolicy, + "approved source policy belongs to another commit", + )); + } + let (producer_version, producer_sha256) = producer_identity(approved)?; + if index.policy_version != producer_version || index.policy_sha256 != producer_sha256 { + return Err(report( + ReleaseError::ProducerPolicy, + "index policy identity does not match the approved source policy bytes", + )); + } + if !lower_hex(rules.current_sha256, 64) { + return Err(report( + ReleaseError::ProducerPolicy, + "consumer policy digest is malformed", + )); + } + if producer_version == rules.current_version && producer_sha256 == rules.current_sha256 { + return Ok(AcceptedProducerPolicy::Current); + } + if producer_version > rules.current_version { + return Err(report( + ReleaseError::ProducerPolicy, + format!( + "unsupported producer policy version {producer_version} under consumer policy version {}", + rules.current_version + ), + )); + } + if rules + .historical + .iter() + .any(|entry| entry.version == producer_version && entry.sha256 == producer_sha256) + { + return Ok(AcceptedProducerPolicy::Historical { + version: producer_version, + sha256: producer_sha256, + }); + } + Err(report( + ReleaseError::ProducerPolicy, + "producer policy is neither current nor a reviewed historical identity", + )) +} diff --git a/crates/trusted-server-cli/src/release/process.rs b/crates/trusted-server-cli/src/release/process.rs new file mode 100644 index 0000000000..a657198b9f --- /dev/null +++ b/crates/trusted-server-cli/src/release/process.rs @@ -0,0 +1,324 @@ +//! Supervision of one bounded child process group. +//! +//! The child leads its own process group. Its exit is observed with +//! `waitid(WNOWAIT)`, which leaves the leader an unreaped zombie: while that +//! zombie exists its PID, and therefore the group ID, cannot be reused, so +//! signalling the group can never hit an unrelated process. The group is +//! killed only in that window and the leader is reaped last. +//! +//! After the group `SIGKILL`, closure of both output pipes (EOF in the drain +//! threads) is the liveness signal, not repeated `killpg` results: killed +//! descendants become zombies that a non-reaping PID 1 (for example a +//! container without an init) may never collect, yet zombies hold no pipes. +//! A descendant that left the group and keeps the pipes open is reported as +//! escaped after a bounded grace. Limitation: a descendant that escaped the +//! group *and* closed its copies of the pipes is not observable here and may +//! outlive verification; it never contributes output. + +use std::io::{self, Read}; +use std::process::{Child, Command, ExitStatus}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use super::bounds::{AttemptDeadline, Cancellation, ProcessBounds}; +use super::error::{ReleaseError, report}; + +const POLL_INTERVAL: Duration = Duration::from_millis(10); +const READ_CHUNK: usize = 64 * 1024; + +/// Exit status and raw output of a child whose group was fully drained. +pub(super) struct Completed { + pub(super) status: ExitStatus, + pub(super) stdout: Vec, + pub(super) stderr: Vec, +} + +struct Drained { + bytes: Vec, + overflowed: bool, +} + +/// Both drained pipes, or why draining could not complete. +/// A drain thread collecting one pipe. +type Reader = JoinHandle>; + +type DrainResult = Result<(Drained, Drained), Report>; + +/// A drain thread reached its byte cap. +struct Overflow; + +enum Stop { + Timeout, + Cancelled, + Overflow, + Wait(io::Error), +} + +impl Stop { + fn into_report(self) -> Report { + match self { + Self::Timeout => report( + ReleaseError::Timeout, + "gh child exceeded its child or attempt deadline", + ), + Self::Cancelled => report(ReleaseError::Cancelled, "gh child was cancelled"), + Self::Overflow => report( + ReleaseError::OutputLimit, + "gh stdout or stderr exceeded its cap", + ), + Self::Wait(source) => report(ReleaseError::Process, source), + } + } +} + +/// Sends one `SIGKILL` to the whole group. +/// +/// `ESRCH` (no members) and `EPERM` (macOS reports it when only the zombie +/// leader remains) both mean nothing signalable is left. +fn kill_group(process_group: libc::pid_t) -> Result<(), Report> { + // SAFETY: `killpg` only reads its two integer arguments; failure is + // reported through errno. + let result = unsafe { libc::killpg(process_group, libc::SIGKILL) }; + if result == 0 { + return Ok(()); + } + let source = io::Error::last_os_error(); + match source.raw_os_error() { + Some(libc::ESRCH | libc::EPERM) => Ok(()), + _ => Err(report( + ReleaseError::Process, + format!("killpg failed: {source}"), + )), + } +} + +/// Returns whether the leader has exited, without reaping it. +fn leader_exited(pid: libc::pid_t) -> io::Result { + let id = libc::id_t::try_from(pid).map_err(io::Error::other)?; + // SAFETY: an all-zero `siginfo_t` is a valid value of this plain C struct. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a valid, writable `siginfo_t` for the duration of the + // call; `WNOWAIT` leaves the child waitable so it stays unreaped. + let result = unsafe { + libc::waitid( + libc::P_PID, + id, + &raw mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + let source = io::Error::last_os_error(); + if source.kind() == io::ErrorKind::Interrupted { + return Ok(false); + } + return Err(source); + } + // SAFETY: `waitid` succeeded, so `info` is initialized; with `WNOHANG` and + // no state change `si_pid` stays zero. + Ok(unsafe { info.si_pid() } != 0) +} + +fn drain( + mut reader: R, + limit: u64, + overflow: Sender, +) -> Reader { + thread::spawn(move || { + let mut bytes = Vec::new(); + let mut buffer = vec![0; READ_CHUNK]; + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(source) if source.kind() == io::ErrorKind::Interrupted => continue, + Err(source) => return Err(source), + }; + if count == 0 { + return Ok(Drained { + bytes, + overflowed: false, + }); + } + if count as u64 > limit.saturating_sub(bytes.len() as u64) { + // Dropping the reader closes the pipe; the supervisor kills the group. + let _ = overflow.send(Overflow); + return Ok(Drained { + bytes, + overflowed: true, + }); + } + bytes.extend_from_slice(buffer.get(..count).unwrap_or_default()); + } + }) +} + +/// Waits until the leader exits (left unreaped) or a stop condition occurs. +fn wait_for_leader( + pid: libc::pid_t, + overflow: &Receiver, + child_deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Stop> { + loop { + match leader_exited(pid) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(source) => return Err(Stop::Wait(source)), + } + if cancellation.is_cancelled() { + return Err(Stop::Cancelled); + } + if Instant::now() >= child_deadline { + return Err(Stop::Timeout); + } + match overflow.recv_timeout(POLL_INTERVAL) { + Ok(Overflow) => return Err(Stop::Overflow), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => thread::sleep(POLL_INTERVAL), + } + } +} + +/// Waits, bounded, for a killed leader to become a zombie. +fn await_leader_exit(pid: libc::pid_t, grace: Duration) -> Result<(), Report> { + let until = Instant::now() + grace; + loop { + if leader_exited(pid).map_err(|source| report(ReleaseError::Process, source))? { + return Ok(()); + } + if Instant::now() >= until { + return Err(report( + ReleaseError::Process, + "gh leader did not exit after SIGKILL", + )); + } + thread::sleep(POLL_INTERVAL); + } +} + +fn join_readers( + stdout: Reader, + stderr: Reader, + grace: Duration, +) -> Result<(Drained, Drained), Report> { + let until = Instant::now() + grace; + while !(stdout.is_finished() && stderr.is_finished()) { + if Instant::now() >= until { + // The reader threads stay blocked until the escaped process + // closes its pipe ends; they hold no other resources. + return Err(report( + ReleaseError::DescendantEscaped, + "a gh descendant left the process group and still holds its output pipes", + )); + } + thread::sleep(POLL_INTERVAL); + } + let join = |handle: Reader| { + handle + .join() + .map_err(|_| report(ReleaseError::Process, "gh output reader panicked"))? + .map_err(|source| report(ReleaseError::Process, source)) + }; + Ok((join(stdout)?, join(stderr)?)) +} + +/// Kills the group while the unreaped leader pins its ID, waits for pipe +/// closure, then reaps the leader. +fn settle( + child: &mut Child, + process_group: libc::pid_t, + leader_done: bool, + readers: (Reader, Reader), + grace: Duration, +) -> (DrainResult, io::Result) { + let mut cleanup = kill_group(process_group); + if !leader_done && cleanup.is_ok() { + // A second group signal while the zombie still pins the ID catches + // anything forked between the first signal and the leader's death. + cleanup = await_leader_exit(process_group, grace).and_then(|()| kill_group(process_group)); + } + let drained = cleanup.and_then(|()| join_readers(readers.0, readers.1, grace)); + if !leader_done { + let _ = child.kill(); + } + (drained, child.wait()) +} + +/// Runs `command` (already configured with piped output and its own process +/// group) under the child deadline, attempt deadline, cancellation and byte +/// caps. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Timeout`], [`ReleaseError::Cancelled`], +/// [`ReleaseError::OutputLimit`], [`ReleaseError::DescendantEscaped`] or +/// [`ReleaseError::Process`]. The child is reaped on every path. +pub(super) fn supervise( + command: &mut Command, + process_bounds: &ProcessBounds, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result> { + let child_deadline = (Instant::now() + process_bounds.child_timeout).min(deadline.instant()); + let mut child = command.spawn().map_err(|source| { + report( + ReleaseError::Process, + format!("cannot spawn gh ({:?}): {source}", source.kind()), + ) + })?; + let process_group = libc::pid_t::try_from(child.id()) + .map_err(|_| report(ReleaseError::Process, "gh process ID is out of range")); + let pipes = child.stdout.take().zip(child.stderr.take()); + let (Ok(process_group), Some((stdout, stderr))) = (process_group, pipes) else { + let _ = child.kill(); + let _ = child.wait(); + return Err(report( + ReleaseError::Process, + "gh process ID or pipes are unavailable", + )); + }; + let (sender, overflow) = mpsc::channel(); + let readers = ( + drain(stdout, process_bounds.stdout_bytes, sender.clone()), + drain(stderr, process_bounds.stderr_bytes, sender), + ); + let stopped = wait_for_leader(process_group, &overflow, child_deadline, cancellation); + let settled = settle( + &mut child, + process_group, + stopped.is_ok(), + readers, + process_bounds.kill_grace, + ); + complete(stopped, settled) +} + +/// Turns a stop condition and cleanup outcome into the supervision result. +fn complete( + stopped: Result<(), Stop>, + (drained, status): (DrainResult, io::Result), +) -> Result> { + if let Err(stop) = stopped { + let stop = stop.into_report(); + return Err(match drained { + Err(failure) => stop.attach(format!( + "cleanup also failed: {}", + failure.current_context() + )), + Ok(_) => stop, + }); + } + let (stdout, stderr) = drained?; + let status = status.map_err(|source| report(ReleaseError::Process, source))?; + if stdout.overflowed || stderr.overflowed { + return Err(Stop::Overflow.into_report()); + } + Ok(Completed { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + }) +} diff --git a/crates/trusted-server-cli/src/release/source_policy.rs b/crates/trusted-server-cli/src/release/source_policy.rs new file mode 100644 index 0000000000..36f347c46a --- /dev/null +++ b/crates/trusted-server-cli/src/release/source_policy.rs @@ -0,0 +1,379 @@ +//! Producer-policy bytes from an independently approved source. +//! +//! [`ApprovedSourcePolicy`] can only be built from the fixed official GitHub +//! Contents API endpoint at an approved full commit, or from an explicitly +//! transferred approved-source snapshot. The release index never supplies the +//! ref, host, path or bytes. + +use std::ffi::CString; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read as _}; +use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::Path; + +use error_stack::Report; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderName, HeaderValue, USER_AGENT}; +use reqwest::{Client, ClientBuilder, Method, Request, StatusCode}; +use url::Url; + +use super::bounds::{self, AttemptDeadline}; +use super::documents::ApprovedCommit; +use super::environment::GithubToken; +use super::error::{ReleaseError, report}; + +/// The only producer-policy endpoint; the approved commit is the sole variable. +pub(crate) const POLICY_CONTENTS_URL: &str = "https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json"; // allow-domain: api.github.com +/// Fixed source-relative directories and file name of the producer policy. +const POLICY_DIRECTORIES: [&str; 2] = ["crates", "trusted-server-cli"]; +const POLICY_FILE: &str = "release-policy.json"; +/// Raw-content media type for the Contents API. +const RAW_MEDIA_TYPE: &str = "application/vnd.github.raw+json"; +/// Reviewed GitHub REST API version header value. +const API_VERSION: &str = "2022-11-28"; + +/// Producer-policy bytes bound to an independently approved commit. +/// +/// Only [`fetch_approved_policy`] and [`read_approved_snapshot`] construct +/// it; neither accepts a caller-chosen URL, path or byte string. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ApprovedSourcePolicy { + commit: ApprovedCommit, + bytes: Vec, +} + +impl ApprovedSourcePolicy { + fn bounded(commit: ApprovedCommit, bytes: Vec) -> Result> { + if bytes.len() as u64 > bounds::POLICY_BYTES { + return Err(report( + ReleaseError::PolicyFetch, + "approved source policy exceeds 1 MiB", + )); + } + Ok(Self { commit, bytes }) + } + + /// Pairs bytes with a commit directly; test fixtures only. + #[cfg(test)] + pub(crate) fn for_tests(commit: ApprovedCommit, bytes: Vec) -> Self { + Self { commit, bytes } + } + + /// Returns the approved commit these bytes belong to. + pub(crate) fn commit(&self) -> &ApprovedCommit { + &self.commit + } + + /// Returns the exact approved-source policy bytes. + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// The root of an independently transferred source snapshot for an explicit +/// approved commit. +/// +/// The root directory is opened once at construction and held, so later +/// reads resolve relative to that descriptor rather than a re-resolved path. +/// Offline checks establish build proof only, never official deployment. +#[derive(Debug)] +pub(crate) struct ApprovedSourceRoot { + commit: ApprovedCommit, + directory: OwnedFd, +} + +impl ApprovedSourceRoot { + /// Opens an absolute snapshot root directory bound to its approved commit. + /// + /// # Errors + /// + /// Returns [`ReleaseError::PolicyFetch`] unless `root` is an absolute + /// directory that can be opened. + pub(crate) fn new(commit: ApprovedCommit, root: &Path) -> Result> { + if !root.is_absolute() { + return Err(report( + ReleaseError::PolicyFetch, + "approved source root must be absolute", + )); + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(root) + .map_err(|source| { + report( + ReleaseError::PolicyFetch, + format!("cannot open approved source root: {source}"), + ) + })?; + Ok(Self { + commit, + directory: OwnedFd::from(directory), + }) + } +} + +fn open_at(directory: &OwnedFd, name: &str, flags: libc::c_int) -> io::Result { + let name = CString::new(name).map_err(io::Error::other)?; + // SAFETY: `name` is a NUL-terminated string that outlives the call and + // `directory` is an open descriptor borrowed for its duration. No + // `O_CREAT` flag is passed, so no mode argument is read. + let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: `openat` just returned this new descriptor; nothing else owns it. + Ok(unsafe { OwnedFd::from_raw_fd(descriptor) }) +} + +/// Reads `crates/trusted-server-cli/release-policy.json` under an approved +/// snapshot root. +/// +/// Every component is opened with `openat` relative to the previously held +/// descriptor using `O_NOFOLLOW` (directories also `O_DIRECTORY`), so no +/// symlink is ever followed and no path is re-resolved; the file is then +/// checked with `fstat` on its own descriptor. +/// +/// # Errors +/// +/// Returns [`ReleaseError::PolicyFetch`] for a symlinked or missing +/// component, a non-regular or oversize policy file, or a read failure. +pub(crate) fn read_approved_snapshot( + root: &ApprovedSourceRoot, +) -> Result> { + let mut current = root + .directory + .try_clone() + .map_err(|source| report(ReleaseError::PolicyFetch, source))?; + for name in POLICY_DIRECTORIES { + current = open_at( + ¤t, + name, + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + .map_err(|source| { + report( + ReleaseError::PolicyFetch, + format!("approved source component `{name}` must be a real directory: {source}"), + ) + })?; + } + let file = File::from( + open_at( + ¤t, + POLICY_FILE, + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + .map_err(|source| { + report( + ReleaseError::PolicyFetch, + format!("cannot open approved source policy: {source}"), + ) + })?, + ); + let held = file + .metadata() + .map_err(|source| report(ReleaseError::PolicyFetch, source))?; + if !held.is_file() || held.len() > bounds::POLICY_BYTES { + return Err(report( + ReleaseError::PolicyFetch, + "approved source policy must be a regular file of at most 1 MiB", + )); + } + let mut bytes = Vec::new(); + file.take(bounds::POLICY_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|source| report(ReleaseError::PolicyFetch, source))?; + ApprovedSourcePolicy::bounded(root.commit.clone(), bytes) +} + +/// Builds the fixed read-only Contents API request. +/// +/// # Errors +/// +/// Returns [`ReleaseError::PolicyFetch`] if the request cannot be encoded. +pub(crate) fn policy_request( + commit: &ApprovedCommit, + token: &GithubToken, +) -> Result> { + let url = Url::parse(&format!("{POLICY_CONTENTS_URL}?ref={}", commit.as_str())) + .map_err(|source| report(ReleaseError::PolicyFetch, source))?; + let mut request = Request::new(Method::GET, url); + let mut authorization = + HeaderValue::from_str(&format!("Bearer {}", token.expose())).map_err(|_| { + report( + ReleaseError::PolicyFetch, + "GitHub token is not a valid header value", + ) + })?; + authorization.set_sensitive(true); + let headers = request.headers_mut(); + headers.insert(ACCEPT, HeaderValue::from_static(RAW_MEDIA_TYPE)); + headers.insert( + HeaderName::from_static("x-github-api-version"), + HeaderValue::from_static(API_VERSION), + ); + headers.insert(USER_AGENT, HeaderValue::from_static("trusted-server-cli")); + headers.insert(AUTHORIZATION, authorization); + Ok(request) +} + +/// Builds the HTTPS client used for policy acquisition. +/// +/// Redirects are never followed, so a credential is never forwarded to a +/// different origin or path; any 3xx is then rejected by +/// [`check_policy_response`]. +/// +/// # Errors +/// +/// Returns [`ReleaseError::PolicyFetch`] if the client cannot be built. +pub(crate) fn policy_client() -> Result> { + no_redirect_builder() + .https_only(true) + .build() + .map_err(|source| report(ReleaseError::PolicyFetch, source)) +} + +/// The shared policy-client configuration: no redirect is ever followed. +fn no_redirect_builder() -> ClientBuilder { + crate::tls::install_crypto_provider(); + Client::builder().redirect(reqwest::redirect::Policy::none()) +} + +/// Builds the production client configuration without `https_only`, so a +/// local plain-HTTP server can prove redirects are returned, not followed. +#[cfg(test)] +pub(crate) fn plain_http_policy_client_for_tests() -> Result> { + no_redirect_builder() + .build() + .map_err(|source| report(ReleaseError::PolicyFetch, source)) +} + +/// Validates response status, final URL and declared length. +/// +/// # Errors +/// +/// Returns [`ReleaseError::PolicyFetch`] for redirects, non-200 statuses, a +/// final URL differing from the request, or a declared length over 1 MiB. +pub(crate) fn check_policy_response( + request_url: &Url, + status: StatusCode, + final_url: &Url, + content_length: Option, +) -> Result<(), Report> { + if status.is_redirection() { + return Err(report( + ReleaseError::PolicyFetch, + format!("policy endpoint redirected ({status}); redirects are rejected"), + )); + } + if status != StatusCode::OK { + return Err(report( + ReleaseError::PolicyFetch, + format!("policy endpoint returned {status}"), + )); + } + if final_url != request_url { + return Err(report( + ReleaseError::PolicyFetch, + "policy response origin or path differs from the fixed endpoint", + )); + } + if content_length.is_some_and(|length| length > bounds::POLICY_BYTES) { + return Err(report( + ReleaseError::PolicyFetch, + "policy response declares more than 1 MiB", + )); + } + Ok(()) +} + +/// Accumulates a streamed response body, rejecting it once the total would +/// exceed a fixed byte limit. +#[derive(Debug)] +pub(crate) struct BoundedBody { + bytes: Vec, + limit: u64, +} + +impl BoundedBody { + /// Starts an empty body that accepts at most `limit` bytes in total. + pub(crate) fn new(limit: u64) -> Self { + Self { + bytes: Vec::new(), + limit, + } + } + + /// Appends one chunk. + /// + /// # Errors + /// + /// Returns [`ReleaseError::PolicyFetch`] once the limit would be exceeded. + pub(crate) fn push(&mut self, chunk: &[u8]) -> Result<(), Report> { + if chunk.len() as u64 > self.limit.saturating_sub(self.bytes.len() as u64) { + return Err(report( + ReleaseError::PolicyFetch, + "policy response exceeds 1 MiB", + )); + } + self.bytes.extend_from_slice(chunk); + Ok(()) + } + + /// Returns the accumulated bytes. + pub(crate) fn into_bytes(self) -> Vec { + self.bytes + } +} + +/// Fetches producer-policy bytes from the fixed endpoint at an approved commit. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Timeout`] when the attempt deadline elapses and +/// [`ReleaseError::PolicyFetch`] for transport, status, redirect, origin or +/// size failures. Diagnostics never include the credential. +pub(crate) async fn fetch_approved_policy( + client: &Client, + commit: &ApprovedCommit, + token: &GithubToken, + deadline: AttemptDeadline, +) -> Result> { + // Check first: an elapsed deadline must not start any network activity. + if std::time::Instant::now() >= deadline.instant() { + return Err(report( + ReleaseError::Timeout, + "policy acquisition deadline already elapsed", + )); + } + let request = policy_request(commit, token)?; + let request_url = request.url().clone(); + let fetch = + async { + let mut response = client.execute(request).await.map_err(|error| { + report(ReleaseError::PolicyFetch, token.redact(&error.to_string())) + })?; + check_policy_response( + &request_url, + response.status(), + response.url(), + response.content_length(), + )?; + let mut body = BoundedBody::new(bounds::POLICY_BYTES); + while let Some(chunk) = response.chunk().await.map_err(|error| { + report(ReleaseError::PolicyFetch, token.redact(&error.to_string())) + })? { + body.push(&chunk)?; + } + ApprovedSourcePolicy::bounded(commit.clone(), body.into_bytes()) + }; + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline.instant()), fetch) + .await + .map_err(|_| { + report( + ReleaseError::Timeout, + "policy acquisition exceeded the deadline", + ) + })? +} diff --git a/crates/trusted-server-cli/src/release/tests/documents/mod.rs b/crates/trusted-server-cli/src/release/tests/documents/mod.rs new file mode 100644 index 0000000000..657590dfc1 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/documents/mod.rs @@ -0,0 +1,668 @@ +//! Strict document, embedded policy, producer-policy identity and approved +//! source policy acquisition tests. + +use std::fs; +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::time::{Duration, Instant}; + +use error_stack::Report; +use reqwest::StatusCode; +use reqwest::header::{ACCEPT, AUTHORIZATION}; +use serde_json::{Value, json}; +use url::Url; + +use crate::release::bounds::{self, AttemptDeadline, ProcessBounds}; +use crate::release::documents::{ + ApprovedCommit, ApprovedRelease, ReleaseIndex, ReleaseTag, Role, parse_json, + parse_release_index, schema_accepts, sha256_hex, validate_index_schema, +}; +use crate::release::environment::{CallerEnvironment, GithubToken}; +use crate::release::error::ReleaseError; +use crate::release::policy::{ + AcceptanceRules, AcceptedProducerPolicy, ConsumerPolicy, EMBEDDED_POLICY, GITHUB_HOST, + HISTORICAL_PRODUCER_POLICIES, HistoricalPolicy, REPOSITORY, SIGNER_WORKFLOW_PREFIX, + accept_producer_policy, embedded_policy, parse_consumer_policy, +}; +use crate::release::source_policy::{ + ApprovedSourcePolicy, ApprovedSourceRoot, BoundedBody, POLICY_CONTENTS_URL, + check_policy_response, fetch_approved_policy, plain_http_policy_client_for_tests, + policy_client, policy_request, read_approved_snapshot, +}; + +mod producer; + +const INDEX_FIXTURE: &[u8] = + include_bytes!("../../../../tests/fixtures/release/release-index.json"); +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const OTHER_COMMIT: &str = "89abcdef0123456789abcdef0123456789abcdef"; +const TOKEN: &str = "fictional-read-only-token-0000"; + +fn context(result: Result>) -> ReleaseError { + *result.expect_err("should reject input").current_context() +} + +fn policy() -> ConsumerPolicy { + embedded_policy().expect("should accept the embedded consumer policy") +} + +fn commit() -> ApprovedCommit { + ApprovedCommit::parse(COMMIT).expect("should accept fictional approved commit") +} + +fn approved_release() -> ApprovedRelease { + ApprovedRelease::new( + ReleaseTag::parse("v1.2.0").expect("should accept fictional tag"), + commit(), + ) +} + +fn index_value() -> Value { + let mut value = parse_json(INDEX_FIXTURE, Role::Index).expect("should parse index fixture"); + value["policy_sha256"] = json!(sha256_hex(EMBEDDED_POLICY)); + value +} + +fn encode(value: &Value) -> Vec { + serde_json::to_vec(value).expect("should encode JSON fixture") +} + +fn parse_index(value: &Value) -> Result> { + parse_release_index(&encode(value), &policy(), &approved_release()) +} + +fn embedded_policy_value() -> Value { + parse_json(EMBEDDED_POLICY, Role::Policy).expect("should parse embedded policy") +} + +fn source_policy(bytes: &[u8]) -> ApprovedSourcePolicy { + ApprovedSourcePolicy::for_tests(commit(), bytes.to_vec()) +} + +fn token() -> GithubToken { + GithubToken::select(&CallerEnvironment::from_pairs([("GH_TOKEN", TOKEN)])) + .expect("should select explicit token") +} + +// Strict JSON. + +#[test] +fn strict_json_rejects_duplicate_keys_at_every_depth() { + for text in [ + r#"{"a":1,"a":2}"#, + r#"{"outer":{"inner":[{"k":1,"k":1}]}}"#, + r#"{"a":1,"a":2}"#, + ] { + assert_eq!( + context(parse_json(text.as_bytes(), Role::Index)), + ReleaseError::Document, + "should reject duplicate key in {text}" + ); + } +} + +#[test] +fn strict_json_rejects_depth_beyond_sixty_four_including_root() { + let accepted = format!("{}{}", "[".repeat(64), "]".repeat(64)); + let rejected = format!("{}{}", "[".repeat(65), "]".repeat(65)); + + assert!( + parse_json(accepted.as_bytes(), Role::Index).is_ok(), + "should accept depth 64" + ); + assert_eq!( + context(parse_json(rejected.as_bytes(), Role::Index)), + ReleaseError::Document, + "should reject depth 65" + ); +} + +#[test] +fn strict_json_rejects_oversize_before_parsing() { + let mut oversized = vec![b' '; usize::try_from(bounds::INDEX_BYTES).expect("should fit")]; + oversized.extend_from_slice(b"{}"); + + assert_eq!( + context(parse_json(&oversized, Role::Index)), + ReleaseError::Document, + "should reject index over 1 MiB" + ); + assert!( + parse_json(&oversized, Role::Bundle).is_ok(), + "should accept the same bytes under the larger bundle bound" + ); +} + +#[test] +fn strict_json_role_limits_match_bounds() { + assert_eq!(Role::Index.limit(), bounds::INDEX_BYTES, "index bound"); + assert_eq!(Role::Policy.limit(), bounds::POLICY_BYTES, "policy bound"); + assert_eq!( + Role::Inventory.limit(), + bounds::INVENTORY_BYTES, + "inventory bound" + ); + assert_eq!(Role::Bundle.limit(), bounds::BUNDLE_BYTES, "bundle bound"); + assert_eq!( + Role::Companion.limit(), + bounds::COMPANION_BYTES, + "companion bound" + ); + assert_eq!(Role::Spdx.limit(), bounds::SPDX_BYTES, "SPDX bound"); + assert_eq!( + Role::GhOutput.limit(), + bounds::STDOUT_BYTES, + "gh stdout bound" + ); +} + +#[test] +fn strict_json_rejects_bom_invalid_utf8_and_trailing_data() { + for bytes in [ + b"\xef\xbb\xbf{}".as_slice(), + b"{\"a\":\"\xff\"}".as_slice(), + b"{} {}".as_slice(), + b"{}x".as_slice(), + br#"{"a":"\ud800"}"#.as_slice(), + ] { + assert_eq!( + context(parse_json(bytes, Role::Index)), + ReleaseError::Document, + "should reject {bytes:?}" + ); + } +} + +#[test] +fn strict_json_rejects_integer_overflow_lexically() { + for text in [ + "18446744073709551616", + "-9223372036854775809", + r#"{"deep":[[{"n":18446744073709551616}]]}"#, + "1e999", + "-1e999", + ] { + assert_eq!( + context(parse_json(text.as_bytes(), Role::Index)), + ReleaseError::Document, + "should reject out-of-range number {text}" + ); + } + for text in [ + "18446744073709551615", + "-9223372036854775808", + r#"{"text":"18446744073709551616 1e999 \" \\"}"#, + ] { + assert!( + parse_json(text.as_bytes(), Role::Index).is_ok(), + "should accept boundary or quoted text {text}" + ); + } +} + +#[test] +fn sha256_matches_known_vector() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "should compute SHA-256 of exact bytes" + ); +} + +// Embedded consumer policy. + +#[test] +fn embedded_policy_identity_is_external_exact_byte_digest() { + let policy = policy(); + + assert_eq!(policy.version(), 1, "should be consumer policy v1"); + assert_eq!( + policy.sha256(), + sha256_hex(EMBEDDED_POLICY), + "should hash the exact embedded bytes" + ); + assert!( + !String::from_utf8_lossy(EMBEDDED_POLICY).contains(policy.sha256()), + "should not contain its own digest" + ); + assert!( + HISTORICAL_PRODUCER_POLICIES.is_empty(), + "should start with an empty reviewed historical allowlist" + ); +} + +fn reject_policy(mutate: impl FnOnce(&mut Value), reason: &str) { + let mut value = embedded_policy_value(); + mutate(&mut value); + + assert_eq!( + context(parse_consumer_policy(&encode(&value))), + ReleaseError::Policy, + "should reject policy: {reason}" + ); +} + +#[test] +fn policy_rejects_unknown_kind() { + reject_policy( + |value| value["artifacts"][0]["kind"] = json!("archive"), + "unknown kind", + ); +} + +#[test] +fn policy_rejects_kind_swapped_between_artifacts() { + reject_policy( + |value| value["artifacts"][1]["kind"] = json!("wasm-core"), + "native CLI declared as wasm-core", + ); +} + +#[test] +fn policy_rejects_unknown_profile() { + reject_policy( + |value| value["artifacts"][1]["assurance_profile"] = json!("provenance-only"), + "unknown profile", + ); +} + +#[test] +fn policy_rejects_profile_downgrade() { + reject_policy( + |value| value["artifacts"][0]["assurance_profile"] = json!("provenance-sbom"), + "server without repeatable rebuild", + ); +} + +#[test] +fn policy_rejects_unknown_action() { + reject_policy( + |value| value["artifacts"][0]["actions"] = json!(["readonly-verify", "deploy"]), + "unknown action", + ); + reject_policy( + |value| value["public_actions"] = json!(["deploy"]), + "unknown public action", + ); +} + +#[test] +fn policy_rejects_fastly_staging_capability() { + reject_policy( + |value| { + value["artifacts"][0]["capabilities"] = json!(["fastly-production", "fastly-staging"]); + }, + "structurally possible fastly-staging", + ); +} + +#[test] +fn policy_rejects_unknown_capability_and_fields() { + reject_policy( + |value| value["artifacts"][1]["capabilities"] = json!(["cli-admin"]), + "unknown capability", + ); + reject_policy( + |value| value["artifacts"][1]["extra"] = json!(true), + "unknown artifact field", + ); + reject_policy( + |value| value["extra"] = json!(true), + "unknown top-level field", + ); +} + +#[test] +fn policy_rejects_changed_identity_and_bounds() { + reject_policy(|value| value["repository_id"] = json!("1"), "repository id"); + reject_policy( + |value| value["identity"]["issuer"] = json!("https://issuer.example.com"), + "issuer", + ); + reject_policy( + |value| value["bounds"]["child_seconds"] = json!(121), + "child bound", + ); + reject_policy( + |value| value["github_host"] = json!("ghe.example.com"), + "github host", + ); +} + +#[test] +fn policy_rejects_unsupported_tool_version() { + reject_policy( + |value| value["tool_versions"]["gh"] = json!("2.98.0"), + "gh version", + ); + reject_policy( + |value| value["tool_versions"]["actions_attest_revision"] = json!("0".repeat(40)), + "attest revision", + ); +} + +#[test] +fn policy_rejects_nonempty_json_historical_list_not_compiled() { + reject_policy( + |value| { + value["historical_producer_policies"] = + json!([{"policy_version": 1, "policy_sha256": "d".repeat(64)}]); + }, + "historical list that is not the compiled allowlist", + ); +} + +// Governing JSON Schema. + +#[test] +fn governing_schema_accepts_fixture() { + validate_index_schema(&index_value()).expect("should accept the closed fixture"); +} + +/// A named mutation of the index fixture. +type Mutation = (&'static str, fn(&mut Value)); + +#[test] +fn governing_schema_rejects_structural_violations() { + let mutations: [Mutation; 8] = [ + ("CLI asset as server", |value| { + value["artifacts"][3]["role"] = json!("server"); + }), + ("unknown kind", |value| { + value["artifacts"][0]["kind"] = json!("archive"); + }), + ("fifth artifact", |value| { + let first = value["artifacts"][0].clone(); + value["artifacts"] + .as_array_mut() + .expect("should hold artifacts") + .push(first); + }), + ("traversal companion name", |value| { + value["artifacts"][0]["companions"]["sbom"]["name"] = json!("../escape.spdx.json"); + }), + ("uppercase digest", |value| { + value["artifacts"][0]["sha256"] = json!("A".repeat(64)); + }), + ("oversize SBOM", |value| { + value["artifacts"][0]["companions"]["sbom"]["size"] = json!(bounds::SPDX_BYTES + 1); + }), + ("extra field", |value| { + value["artifacts"][1]["extra"] = json!(true); + }), + ("wrong compatibility pairing", |value| { + value["artifacts"][1]["compatibility"]["minimum_version"] = json!("glibc-2.34"); + }), + ]; + + for (reason, mutate) in mutations { + let mut value = index_value(); + mutate(&mut value); + + assert_eq!( + context(validate_index_schema(&value)), + ReleaseError::Document, + "should reject {reason} in the schema step" + ); + } +} + +#[test] +fn draft_202012_prefix_items_and_items_are_enforced() { + let schema = json!({ + "type": "array", + "prefixItems": [{"const": "first"}, {"type": "integer"}], + "items": false + }); + + assert!( + schema_accepts(&schema, &json!(["first", 2])).expect("should compile 2020-12 schema"), + "should accept prefix items" + ); + for value in [ + json!(["wrong", 2]), + json!(["first", "wrong"]), + json!(["first", 2, 3]), + ] { + assert!( + !schema_accepts(&schema, &value).expect("should compile 2020-12 schema"), + "should reject invalid prefix or additional item {value}" + ); + } +} + +#[test] +fn schema_evaluation_never_retrieves_remote_references() { + let schema = json!({"$ref": "https://schemas.example.com/remote.json"}); + + assert!( + schema_accepts(&schema, &json!({})).is_err(), + "should refuse to resolve an external reference offline" + ); +} + +// Release index. + +#[test] +fn index_fixture_matches_closed_contract() { + let index = parse_index(&index_value()).expect("should accept the closed fixture"); + + assert_eq!(index.artifacts.len(), 4, "should keep four artifacts"); + assert_eq!( + index.policy_version, 1, + "should keep producer policy version" + ); +} + +fn reject_index(mutate: impl FnOnce(&mut Value), expected: ReleaseError, reason: &str) { + let mut value = index_value(); + mutate(&mut value); + + assert_eq!( + context(parse_index(&value)), + expected, + "should reject index: {reason}" + ); +} + +#[test] +fn index_rejects_unknown_schema_version_and_fields() { + reject_index( + |value| value["schema_version"] = json!(2), + ReleaseError::Policy, + "v2", + ); + reject_index( + |value| value["extra"] = json!(1), + ReleaseError::Document, + "extra field", + ); + reject_index( + |value| value["artifacts"][0]["companions"]["extra"] = value["tsjs_inventory"].clone(), + ReleaseError::Document, + "extra companion", + ); +} + +#[test] +fn index_rejects_cli_as_server_substitution() { + reject_index( + |value| { + value["artifacts"][3]["role"] = json!("server"); + value["artifacts"][3]["capabilities"] = json!(["fastly-production"]); + }, + ReleaseError::Policy, + "ts asset claiming server role and fastly-production", + ); + reject_index( + |value| value["artifacts"][3]["capabilities"] = json!(["fastly-production"]), + ReleaseError::Policy, + "ts asset claiming fastly-production", + ); +} + +#[test] +fn index_rejects_role_only_mismatch() { + reject_index( + |value| value["artifacts"][3]["role"] = json!("server"), + ReleaseError::Policy, + "ts asset claiming server role with its own cli-execute capability", + ); +} + +#[test] +fn index_rejects_fastly_staging_capability() { + reject_index( + |value| { + value["artifacts"][0]["capabilities"] = json!(["fastly-production", "fastly-staging"]); + }, + ReleaseError::Policy, + "structurally valid but policy-forbidden fastly-staging", + ); +} + +#[test] +fn index_rejects_unknown_kind_profile_and_capability() { + reject_index( + |value| value["artifacts"][0]["kind"] = json!("archive"), + ReleaseError::Policy, + "unknown kind", + ); + reject_index( + |value| value["artifacts"][1]["assurance_profile"] = json!("none"), + ReleaseError::Policy, + "unknown profile", + ); + reject_index( + |value| value["artifacts"][1]["capabilities"] = json!(["cli-admin"]), + ReleaseError::Policy, + "unknown capability", + ); + reject_index( + |value| value["artifacts"][1]["target"] = json!("x86_64-apple-darwin"), + ReleaseError::Policy, + "target swap", + ); +} + +#[test] +fn index_rejects_duplicate_and_unknown_artifact_names() { + reject_index( + |value| value["artifacts"][2] = value["artifacts"][1].clone(), + ReleaseError::Policy, + "duplicate artifact", + ); + reject_index( + |value| value["artifacts"][1]["name"] = json!("ts-extra"), + ReleaseError::Policy, + "unknown artifact", + ); + reject_index( + |value| { + value["artifacts"] + .as_array_mut() + .expect("should hold artifacts") + .pop(); + }, + ReleaseError::Policy, + "three artifacts", + ); +} + +#[test] +fn index_rejects_unsafe_names_and_companion_naming() { + for name in [ + "../escape.spdx.json", + "dir/name.spdx.json", + "dir\\name.spdx.json", + "line\nbreak.spdx.json", + ".hidden.spdx.json", + ] { + reject_index( + |value| value["artifacts"][0]["companions"]["sbom"]["name"] = json!(name), + ReleaseError::Policy, + "unsafe companion name", + ); + } + reject_index( + |value| { + value["artifacts"][0]["companions"]["sbom"]["name"] = + json!("ts-aarch64-apple-darwin.spdx.json"); + }, + ReleaseError::Policy, + "companion naming another artifact", + ); + reject_index( + |value| value["tsjs_inventory"]["name"] = json!("inventory.json"), + ReleaseError::Policy, + "inventory name", + ); +} + +#[test] +fn index_rejects_bad_digests_lengths_and_commits() { + reject_index( + |value| value["artifacts"][0]["sha256"] = json!("A".repeat(64)), + ReleaseError::Policy, + "uppercase digest", + ); + reject_index( + |value| value["artifacts"][0]["size"] = json!(-1), + ReleaseError::Document, + "negative length", + ); + reject_index( + |value| value["artifacts"][0]["size"] = json!(1.5), + ReleaseError::Document, + "fractional length", + ); + reject_index( + |value| value["artifacts"][0]["size"] = json!(9_007_199_254_740_992_u64), + ReleaseError::Document, + "unsafe integer", + ); + reject_index( + |value| value["artifacts"][0]["companions"]["sbom"]["size"] = json!(bounds::SPDX_BYTES + 1), + ReleaseError::Policy, + "SBOM over 16 MiB", + ); + reject_index( + |value| value["recipe_source_commit"] = json!(OTHER_COMMIT), + ReleaseError::Policy, + "recipe source differs", + ); + reject_index( + |value| value["source_commit"] = json!(OTHER_COMMIT), + ReleaseError::Policy, + "source not approved", + ); + reject_index( + |value| value["tag"] = json!("v1.2.1"), + ReleaseError::Policy, + "tag not approved", + ); + reject_index( + |value| value["artifacts"][1]["js_inventory_sha256"] = json!("e".repeat(64)), + ReleaseError::Policy, + "mismatched JS inventory", + ); +} + +#[test] +fn approved_inputs_reject_malformed_commit_and_tag() { + for commit in ["0123", &COMMIT.to_uppercase(), &format!("{COMMIT}\n")] { + assert!( + ApprovedCommit::parse(commit).is_err(), + "should reject commit {commit:?}" + ); + } + for tag in ["1.2.0", "v1.2", "v01.2.0", "v1.2.0\n", "-v1.2.0"] { + assert!(ReleaseTag::parse(tag).is_err(), "should reject tag {tag:?}"); + } + assert!( + ReleaseTag::parse("v1.2.0-rc.1+build.5").is_ok(), + "should accept SemVer tag" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/documents/producer.rs b/crates/trusted-server-cli/src/release/tests/documents/producer.rs new file mode 100644 index 0000000000..58192a9dee --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/documents/producer.rs @@ -0,0 +1,605 @@ +//! Producer-policy identity binding and approved-source policy acquisition. + +use super::*; + +// Producer policy identity. + +fn index_with_policy(version: u64, sha256: &str) -> ReleaseIndex { + let mut value = index_value(); + value["policy_version"] = json!(version); + value["policy_sha256"] = json!(sha256); + parse_index(&value).expect("should accept structurally valid index") +} + +#[test] +fn producer_policy_accepts_current_embedded_identity() { + let policy = policy(); + let index = index_with_policy(1, policy.sha256()); + + let accepted = accept_producer_policy( + &index, + &source_policy(EMBEDDED_POLICY), + &AcceptanceRules::compiled(&policy), + ) + .expect("should accept current producer policy"); + + assert_eq!( + accepted, + AcceptedProducerPolicy::Current, + "should be current policy" + ); +} + +#[test] +fn producer_policy_rejects_self_selected_policy() { + let policy = policy(); + let mut shipped = embedded_policy_value(); + shipped["bounds"]["child_seconds"] = json!(86_400); + let shipped = encode(&shipped); + let index = index_with_policy(1, &sha256_hex(&shipped)); + + assert_eq!( + context(accept_producer_policy( + &index, + &source_policy(EMBEDDED_POLICY), + &AcceptanceRules::compiled(&policy), + )), + ReleaseError::ProducerPolicy, + "should reject index digest naming a policy it ships" + ); + assert_eq!( + context(accept_producer_policy( + &index, + &source_policy(&shipped), + &AcceptanceRules::compiled(&policy), + )), + ReleaseError::ProducerPolicy, + "should reject self-consistent policy that is neither current nor reviewed" + ); +} + +#[test] +fn producer_policy_rejects_altered_approved_source_bytes() { + let policy = policy(); + let index = index_with_policy(1, policy.sha256()); + let mut altered = EMBEDDED_POLICY.to_vec(); + altered.push(b'\n'); + + assert_eq!( + context(accept_producer_policy( + &index, + &source_policy(&altered), + &AcceptanceRules::compiled(&policy), + )), + ReleaseError::ProducerPolicy, + "should reject approved-source bytes that differ from the index identity" + ); +} + +#[test] +fn producer_policy_rejects_source_commit_mismatch() { + let policy = policy(); + let index = index_with_policy(1, policy.sha256()); + let other = ApprovedSourcePolicy::for_tests( + ApprovedCommit::parse(OTHER_COMMIT).expect("should parse commit"), + EMBEDDED_POLICY.to_vec(), + ); + + assert_eq!( + context(accept_producer_policy( + &index, + &other, + &AcceptanceRules::compiled(&policy), + )), + ReleaseError::ProducerPolicy, + "should reject policy bytes approved for another commit" + ); +} + +fn historical_bytes() -> Vec { + let mut value = embedded_policy_value(); + value["bounds"]["child_seconds"] = json!(60); + encode(&value) +} + +#[test] +fn producer_policy_accepts_reviewed_historical_under_newer_consumer() { + let historical = historical_bytes(); + let digest = sha256_hex(&historical); + let allowlist = [HistoricalPolicy { + version: 1, + sha256: &digest, + }]; + let newer = "f".repeat(64); + let rules = AcceptanceRules { + current_version: 2, + current_sha256: &newer, + historical: &allowlist, + }; + let index = index_with_policy(1, &digest); + + let accepted = accept_producer_policy(&index, &source_policy(&historical), &rules) + .expect("should accept reviewed historical v1"); + + assert_eq!( + accepted, + AcceptedProducerPolicy::Historical { + version: 1, + sha256: digest.clone(), + }, + "should report the historical identity" + ); +} + +#[test] +fn producer_policy_rejects_retired_historical_identity() { + let historical = historical_bytes(); + let digest = sha256_hex(&historical); + let newer = "f".repeat(64); + let rules = AcceptanceRules { + current_version: 2, + current_sha256: &newer, + historical: &[], + }; + let index = index_with_policy(1, &digest); + + assert_eq!( + context(accept_producer_policy( + &index, + &source_policy(&historical), + &rules + )), + ReleaseError::ProducerPolicy, + "should reject identity retired from the allowlist" + ); +} + +#[test] +fn producer_policy_rejects_allowlisted_digest_with_wrong_source() { + let historical = historical_bytes(); + let digest = sha256_hex(&historical); + let allowlist = [HistoricalPolicy { + version: 1, + sha256: &digest, + }]; + let newer = "f".repeat(64); + let rules = AcceptanceRules { + current_version: 2, + current_sha256: &newer, + historical: &allowlist, + }; + let index = index_with_policy(1, &digest); + + assert_eq!( + context(accept_producer_policy( + &index, + &source_policy(EMBEDDED_POLICY), + &rules + )), + ReleaseError::ProducerPolicy, + "should reject allowlisted digest when approved source bytes differ" + ); +} + +#[test] +fn producer_policy_rejects_v2_under_consumer_v1() { + let policy = policy(); + let mut producer = embedded_policy_value(); + producer["policy_version"] = json!(2); + let producer = encode(&producer); + let index = index_with_policy(2, &sha256_hex(&producer)); + + let report = accept_producer_policy( + &index, + &source_policy(&producer), + &AcceptanceRules::compiled(&policy), + ) + .expect_err("should reject producer v2"); + + assert_eq!( + *report.current_context(), + ReleaseError::ProducerPolicy, + "should be a producer policy rejection" + ); + assert!( + format!("{report:?}").contains("unsupported producer policy version 2"), + "should explain the unsupported version: {report:?}" + ); +} + +// Approved-source policy acquisition. + +fn snapshot_root(contents: &[u8]) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("should create snapshot root"); + let directory = root.path().join("crates/trusted-server-cli"); + fs::create_dir_all(&directory).expect("should create snapshot directories"); + fs::write(directory.join("release-policy.json"), contents).expect("should write policy"); + root +} + +fn read_root(root: &Path) -> Result> { + let root = ApprovedSourceRoot::new(commit(), root)?; + read_approved_snapshot(&root) +} + +#[test] +fn approved_snapshot_reads_only_the_fixed_policy_path() { + let root = snapshot_root(EMBEDDED_POLICY); + fs::write(root.path().join("other.json"), b"{}").expect("should write decoy"); + + let snapshot = read_root(root.path()).expect("should read fixed policy path"); + + assert_eq!(snapshot.bytes(), EMBEDDED_POLICY, "should keep exact bytes"); + assert_eq!( + snapshot.commit(), + &commit(), + "should bind the approved commit" + ); +} + +#[test] +fn approved_snapshot_rejects_symlinked_policy_file() { + let root = snapshot_root(b""); + let target = root.path().join("elsewhere.json"); + fs::write(&target, EMBEDDED_POLICY).expect("should write target"); + let policy = root + .path() + .join("crates/trusted-server-cli/release-policy.json"); + fs::remove_file(&policy).expect("should remove placeholder"); + symlink(&target, &policy).expect("should create leaf symlink"); + + assert_eq!( + context(read_root(root.path())), + ReleaseError::PolicyFetch, + "should reject a symlinked policy file" + ); +} + +#[test] +fn approved_snapshot_rejects_symlinked_directory_component() { + let root = tempfile::tempdir().expect("should create snapshot root"); + let real = snapshot_root(EMBEDDED_POLICY); + fs::create_dir(root.path().join("crates")).expect("should create crates"); + symlink( + real.path().join("crates/trusted-server-cli"), + root.path().join("crates/trusted-server-cli"), + ) + .expect("should create directory symlink"); + + assert_eq!( + context(read_root(root.path())), + ReleaseError::PolicyFetch, + "should reject a symlinked directory component" + ); +} + +#[test] +fn approved_snapshot_rejects_directory_at_policy_path() { + let root = snapshot_root(b""); + let policy = root + .path() + .join("crates/trusted-server-cli/release-policy.json"); + fs::remove_file(&policy).expect("should remove placeholder"); + fs::create_dir(&policy).expect("should create directory at policy path"); + + assert_eq!( + context(read_root(root.path())), + ReleaseError::PolicyFetch, + "should reject a non-regular policy path" + ); +} + +#[test] +fn approved_snapshot_rejects_oversize_and_relative_root() { + let root = snapshot_root(&vec![b' '; 1024 * 1024 + 1]); + + assert_eq!( + context(read_root(root.path())), + ReleaseError::PolicyFetch, + "should reject policy over 1 MiB" + ); + assert!( + ApprovedSourceRoot::new(commit(), Path::new("relative/root")).is_err(), + "should require an absolute snapshot root" + ); +} + +#[test] +fn historical_acceptance_keeps_consumer_bounds_in_force() { + let historical = historical_bytes(); + let digest = sha256_hex(&historical); + let allowlist = [HistoricalPolicy { + version: 1, + sha256: &digest, + }]; + let policy = policy(); + let rules = AcceptanceRules { + historical: &allowlist, + ..AcceptanceRules::compiled(&policy) + }; + let mut value = index_value(); + value["policy_sha256"] = json!(digest); + + let index = parse_index(&value).expect("should accept index"); + let accepted = accept_producer_policy(&index, &source_policy(&historical), &rules) + .expect("should accept reviewed historical policy"); + + assert!( + matches!(accepted, AcceptedProducerPolicy::Historical { .. }), + "should accept the historical identity" + ); + assert_eq!( + ProcessBounds::production().child_timeout, + Duration::from_secs(120), + "should keep the consumer's 120 s child bound, not the historical 60 s" + ); + assert_eq!( + embedded_policy().expect("should reparse consumer policy"), + policy, + "should leave the consumer policy unchanged" + ); + value["artifacts"][0]["companions"]["sbom"]["size"] = json!(bounds::SPDX_BYTES + 1); + assert_eq!( + context(parse_index(&value)), + ReleaseError::Policy, + "should keep enforcing consumer document bounds" + ); +} + +#[test] +fn producer_policy_rejects_newer_version_even_if_allowlisted() { + let policy = policy(); + let mut producer = embedded_policy_value(); + producer["policy_version"] = json!(2); + let producer = encode(&producer); + let digest = sha256_hex(&producer); + let allowlist = [HistoricalPolicy { + version: 2, + sha256: &digest, + }]; + let rules = AcceptanceRules { + historical: &allowlist, + ..AcceptanceRules::compiled(&policy) + }; + let index = index_with_policy(2, &digest); + + assert_eq!( + context(accept_producer_policy( + &index, + &source_policy(&producer), + &rules + )), + ReleaseError::ProducerPolicy, + "should reject a producer newer than the consumer before the allowlist" + ); +} + +#[test] +fn policy_client_rejects_plain_http_without_connecting() { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind local listener"); + listener + .set_nonblocking(true) + .expect("should make listener nonblocking"); + let address = listener.local_addr().expect("should read local address"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build runtime"); + let client = policy_client().expect("should build policy client"); + + let result = runtime.block_on(client.get(format!("http://{address}/redirect")).send()); + + assert!(result.is_err(), "should refuse a plain HTTP URL"); + assert_eq!( + listener.accept().map_err(|error| error.kind()).err(), + Some(std::io::ErrorKind::WouldBlock), + "should never connect to the local listener" + ); +} + +#[test] +fn policy_fetch_respects_expired_attempt_deadline() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build runtime"); + let client = policy_client().expect("should build policy client"); + let expired = AttemptDeadline::at(Instant::now()); + + let result = runtime.block_on(fetch_approved_policy(&client, &commit(), &token(), expired)); + + assert_eq!( + context(result), + ReleaseError::Timeout, + "should time out before any request" + ); +} + +#[test] +fn policy_request_targets_only_the_fixed_contents_endpoint() { + let request = policy_request(&commit(), &token()).expect("should build request"); + + assert_eq!( + request.url().as_str(), + format!( + "https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref={COMMIT}" // allow-domain: api.github.com + ), + "should use the fixed endpoint and approved full commit" + ); + assert_eq!( + request.url().as_str(), + format!("{POLICY_CONTENTS_URL}?ref={COMMIT}"), + "constant" + ); + assert_eq!(request.method(), reqwest::Method::GET, "should only read"); + assert_eq!( + request.headers()[ACCEPT], + "application/vnd.github.raw+json", + "should request raw media" + ); + let authorization = &request.headers()[AUTHORIZATION]; + assert!( + authorization.is_sensitive(), + "should mark credential sensitive" + ); + assert_eq!( + authorization, + &format!("Bearer {TOKEN}"), + "should send explicit token" + ); +} + +#[test] +fn policy_response_rejects_redirects_and_moved_origin_or_path() { + let request_url = + Url::parse(&format!("{POLICY_CONTENTS_URL}?ref={COMMIT}")).expect("should parse URL"); + let other_host = Url::parse(&format!( + "https://objects.example.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref={COMMIT}" + )) + .expect("should parse URL"); + let other_path = Url::parse(&format!( + "https://api.github.com/repos/IABTechLab/trusted-server/contents/README.md?ref={COMMIT}" // allow-domain: api.github.com + )) + .expect("should parse URL"); + + for status in [ + StatusCode::MOVED_PERMANENTLY, + StatusCode::FOUND, + StatusCode::TEMPORARY_REDIRECT, + StatusCode::PERMANENT_REDIRECT, + ] { + assert_eq!( + context(check_policy_response( + &request_url, + status, + &request_url, + None + )), + ReleaseError::PolicyFetch, + "should reject redirect status {status}" + ); + } + for final_url in [&other_host, &other_path] { + assert_eq!( + context(check_policy_response( + &request_url, + StatusCode::OK, + final_url, + None + )), + ReleaseError::PolicyFetch, + "should reject changed origin or path {final_url}" + ); + } + assert_eq!( + context(check_policy_response( + &request_url, + StatusCode::NOT_FOUND, + &request_url, + None + )), + ReleaseError::PolicyFetch, + "should reject non-200" + ); + assert_eq!( + context(check_policy_response( + &request_url, + StatusCode::OK, + &request_url, + Some(bounds::POLICY_BYTES + 1), + )), + ReleaseError::PolicyFetch, + "should reject declared oversize" + ); + assert!( + check_policy_response(&request_url, StatusCode::OK, &request_url, Some(10)).is_ok(), + "should accept exact 200 response" + ); +} + +#[test] +fn policy_body_stream_is_bounded() { + let mut body = BoundedBody::new(bounds::POLICY_BYTES); + let chunk = vec![b' '; 512 * 1024]; + + body.push(&chunk).expect("should accept first half"); + body.push(&chunk).expect("should accept exact bound"); + + assert_eq!( + context(body.push(b"x")), + ReleaseError::PolicyFetch, + "should reject streamed byte past 1 MiB" + ); +} + +#[test] +fn approved_source_policy_never_comes_from_index_fields() { + let mut value = index_value(); + value["source_commit"] = json!(OTHER_COMMIT); + value["recipe_source_commit"] = json!(OTHER_COMMIT); + value["cohort_source_commit"] = json!(OTHER_COMMIT); + + assert_eq!( + context(parse_index(&value)), + ReleaseError::Policy, + "should require independently approved source commit" + ); +} + +#[test] +fn policy_client_returns_redirects_without_following() { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind local listener"); + let address = listener.local_addr().expect("should read local address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("should accept first request"); + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request); + let response = format!( + "HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{address}/followed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .expect("should write redirect"); + drop(stream); + listener + .set_nonblocking(true) + .expect("should make listener nonblocking"); + std::thread::sleep(Duration::from_millis(300)); + listener.accept().is_ok() + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build runtime"); + let client = plain_http_policy_client_for_tests().expect("should build test client"); + + let response = runtime + .block_on(client.get(format!("http://{address}/policy")).send()) + .expect("should receive the redirect response"); + let followed = server.join().expect("should join local server"); + + assert_eq!( + response.status(), + StatusCode::TEMPORARY_REDIRECT, + "should return the 307 itself" + ); + assert!(!followed, "should never request the redirect target"); +} + +#[test] +fn fixed_endpoints_derive_from_policy_identity() { + assert_eq!( + POLICY_CONTENTS_URL, + format!( + "https://api.{GITHUB_HOST}/repos/{REPOSITORY}/contents/crates/trusted-server-cli/release-policy.json" // allow-domain: api + ), + "should fetch from the policy repository" + ); + assert_eq!( + SIGNER_WORKFLOW_PREFIX, + format!( + "https://{GITHUB_HOST}/{REPOSITORY}/.github/workflows/release-build.yml@refs/tags/" + ), + "should sign from the policy repository" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/gh/argv.rs b/crates/trusted-server-cli/src/release/tests/gh/argv.rs new file mode 100644 index 0000000000..9490b07a73 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/gh/argv.rs @@ -0,0 +1,130 @@ +//! Fixed gh argument vectors. + +use super::*; + +// Fixed argument vectors. + +#[test] +fn attestation_argv_is_fixed_for_both_predicates() { + let subject = Path::new("/verify/trusted-server-fastly.wasm"); + let signer = "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/v1.2.0"; + + for (predicate, uri, bundle) in [ + ( + PredicateKind::Provenance, + "https://slsa.dev/provenance/v1", + "/verify/trusted-server-fastly.wasm.provenance.sigstore.json", + ), + ( + PredicateKind::Spdx, + "https://spdx.dev/Document/v2.3", + "/verify/trusted-server-fastly.wasm.sbom.sigstore.json", + ), + ] { + let argv = attestation_verify_args(subject, Path::new(bundle), &release(), predicate) + .expect("should build fixed argv"); + + assert_eq!( + argv, + args(&[ + "attestation", + "verify", + "/verify/trusted-server-fastly.wasm", + "--bundle", + bundle, + "-R", + "IABTechLab/trusted-server", + "--hostname", + "github.com", + "--cert-identity", + signer, + "--cert-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--source-ref", + "refs/tags/v1.2.0", + "--source-digest", + COMMIT, + "--signer-digest", + COMMIT, + "--deny-self-hosted-runners", + "--predicate-type", + uri, + "--format", + "json", + ]), + "should match the reviewed command for {uri}" + ); + } +} + +#[test] +fn attestation_argv_rejects_unsafe_subject_and_bundle() { + let subject = Path::new("/verify/trusted-server-fastly.wasm"); + let provenance = Path::new("/verify/trusted-server-fastly.wasm.provenance.sigstore.json"); + + for (subject, bundle, predicate, reason) in [ + ( + Path::new("--help"), + provenance, + PredicateKind::Provenance, + "flag-like subject", + ), + ( + subject, + Path::new("relative.provenance.sigstore.json"), + PredicateKind::Provenance, + "relative bundle", + ), + ( + subject, + provenance, + PredicateKind::Spdx, + "provenance bundle for SPDX predicate", + ), + ( + subject, + Path::new("/verify/trusted-server-fastly.wasm.sbom.sigstore.json"), + PredicateKind::Provenance, + "SBOM bundle for provenance predicate", + ), + ] { + assert!( + attestation_verify_args(subject, bundle, &release(), predicate).is_err(), + "should reject {reason}" + ); + } +} + +#[test] +fn release_argv_is_fixed() { + let tag = ReleaseTag::parse("v1.2.0").expect("should parse tag"); + + assert_eq!( + release_verify_args(&tag), + args(&[ + "release", + "verify", + "v1.2.0", + "-R", + "github.com/IABTechLab/trusted-server" + ]), + "should match reviewed release verify" + ); + assert_eq!( + release_verify_asset_args(&tag, Path::new("/verify/release-index.json")) + .expect("should build verify-asset argv"), + args(&[ + "release", + "verify-asset", + "v1.2.0", + "/verify/release-index.json", + "-R", + "github.com/IABTechLab/trusted-server", + ]), + "should match reviewed release verify-asset" + ); + assert!( + release_verify_asset_args(&tag, Path::new("relative.json")).is_err(), + "should require absolute asset path" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/gh/environment.rs b/crates/trusted-server-cli/src/release/tests/gh/environment.rs new file mode 100644 index 0000000000..0cf63c9fbe --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/gh/environment.rs @@ -0,0 +1,305 @@ +//! gh resolution, version qualification, environment isolation and +//! credential redaction. + +use super::*; + +// Resolution and version. + +#[test] +fn resolves_explicit_path_to_canonical_target() { + let fake = Fake::script("exit 0"); + let link = fake.file("gh-link"); + symlink(&fake.gh, &link).expect("should create symlink"); + + let runner = resolve(GhLocation::Explicit(&link), &caller(&[("GH_TOKEN", TOKEN)])) + .expect("should resolve explicit gh"); + + assert_eq!( + runner.executable(), + fake.gh.canonicalize().expect("should canonicalize"), + "should retain the canonical executable target" + ); +} + +#[test] +fn resolves_from_trusted_path_once_ignoring_relative_entries() { + let fake = Fake::script("exit 0"); + let path = format!("relative/bin:{}", fake.directory.path().display()); + + let runner = resolve( + GhLocation::SearchPath, + &caller(&[("GH_TOKEN", TOKEN), ("PATH", &path)]), + ) + .expect("should resolve gh from absolute PATH entry"); + + assert_eq!( + runner.executable(), + fake.gh.canonicalize().expect("should canonicalize"), + "should retain the PATH-resolved target" + ); + assert_eq!( + context(resolve( + GhLocation::SearchPath, + &caller(&[("GH_TOKEN", TOKEN), ("PATH", "relative/bin")]) + )) + .0, + ReleaseError::UnsupportedTool, + "should not resolve gh through relative PATH entries" + ); + assert_eq!( + context(resolve( + GhLocation::Explicit(Path::new("gh")), + &caller(&[("GH_TOKEN", TOKEN)]) + )) + .0, + ReleaseError::UnsupportedTool, + "should require an absolute explicit path" + ); +} + +#[test] +fn rejects_unsupported_gh_version_with_diagnostic() { + for prelude in [ + "echo 'gh version 2.96.0 (2026-01-01)'; exit 0\n", + "echo 'gh version 2.97.0-rc1 (2026-01-01)'; exit 0\n", + "echo 'gh version 2.97.00'; exit 0\n", + "echo 'not gh'; exit 0\n", + "exit 1\n", + ] { + let fake = Fake::with_version(prelude, "exit 0"); + + let (error, text) = context(fake.runner(test_bounds())); + + assert!( + matches!( + error, + ReleaseError::UnsupportedTool | ReleaseError::ExitFailure + ), + "should reject {prelude:?}: {error:?}" + ); + if error == ReleaseError::UnsupportedTool { + assert!( + text.contains("2.97.0") && text.contains("does not authenticate"), + "should explain the exact version requirement: {text}" + ); + } + } +} + +// Environment isolation. + +fn observed_environment(caller_pairs: &[(&str, &str)]) -> BTreeMap { + let fake = Fake::empty(); + let output = fake.file("env.txt"); + fake.install(&format!( + "/usr/bin/env > '{}'\necho '[{{}}]'", + output.display() + )); + let runner = resolve(GhLocation::Explicit(&fake.gh), &caller(caller_pairs)) + .expect("should resolve fake gh"); + + run(&runner, AttemptDeadline::start(), &Cancellation::default()).expect("should run fake gh"); + + fs::read_to_string(&output) + .expect("should read observed environment") + .lines() + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() +} + +#[test] +fn child_environment_contains_only_reviewed_keys() { + let environment = observed_environment(&[ + ("GH_TOKEN", TOKEN), + ("GITHUB_TOKEN", FALLBACK_TOKEN), + ("GH_HOST", "ghe.example.com"), + ("GH_ENTERPRISE_TOKEN", "fictional-enterprise"), + ("GITHUB_ENTERPRISE_TOKEN", "fictional-enterprise"), + ("GH_REPO", "example/other"), + ("GH_CONFIG_DIR", "/tmp/attacker-config"), + ("GH_PATH", "/tmp/attacker-gh"), + ("FASTLY_API_TOKEN", "fictional-provider"), + ("HOME", "/home/operator"), + ("PATH", "/opt/attacker/bin:/usr/bin"), + ]); + let allowed = [ + "DO_NOT_TRACK", + "GH_CONFIG_DIR", + "GH_NO_EXTENSION_UPDATE_NOTIFIER", + "GH_NO_UPDATE_NOTIFIER", + "GH_PROMPT_DISABLED", + "GH_SPINNER_DISABLED", + "GH_TOKEN", + "HOME", + "LC_ALL", + "NO_COLOR", + "PATH", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + // Added by the fake's own shell, not by the runner. + "OLDPWD", + "PWD", + "SHLVL", + "_", + ]; + + for key in environment.keys() { + assert!( + allowed.contains(&key.as_str()), + "should not pass {key} to gh" + ); + } + assert_eq!( + environment["GH_TOKEN"], TOKEN, + "should prefer explicit GH_TOKEN" + ); + assert_eq!( + environment["PATH"], "/usr/bin:/bin", + "should use minimal PATH" + ); + assert_eq!( + environment["GH_PROMPT_DISABLED"], "1", + "should disable prompts" + ); + let home = &environment["HOME"]; + assert!( + !home.is_empty() && home != "/home/operator", + "should isolate HOME" + ); + for key in [ + "GH_CONFIG_DIR", + "XDG_STATE_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + ] { + assert_ne!( + environment[key], "/tmp/attacker-config", + "should isolate {key}" + ); + assert!( + Path::new(&environment[key]).is_absolute(), + "should use absolute private {key}" + ); + } + assert!( + !environment + .values() + .any(|value| value.contains(FALLBACK_TOKEN) + || value.contains("fictional-enterprise") + || value.contains("fictional-provider")), + "should keep other credentials out of gh" + ); +} + +#[test] +fn falls_back_to_github_token_only_as_gh_token() { + let environment = observed_environment(&[("GITHUB_TOKEN", FALLBACK_TOKEN)]); + + assert_eq!( + environment["GH_TOKEN"], FALLBACK_TOKEN, + "should pass fallback as GH_TOKEN" + ); + assert!( + !environment.contains_key("GITHUB_TOKEN"), + "should not pass GITHUB_TOKEN" + ); +} + +#[test] +fn requires_explicit_token() { + let fake = Fake::script("exit 0"); + + for pairs in [vec![], vec![("GH_TOKEN", "")], vec![("GITHUB_TOKEN", "")]] { + let (error, text) = context(resolve(GhLocation::Explicit(&fake.gh), &caller(&pairs))); + + assert_eq!(error, ReleaseError::Environment, "should require a token"); + assert!( + text.contains("GH_TOKEN"), + "should explain explicit token rule: {text}" + ); + } +} + +#[test] +fn rejects_loader_injection_variables() { + let fake = Fake::script("exit 0"); + + for key in [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + ] { + let (error, text) = context(resolve( + GhLocation::Explicit(&fake.gh), + &caller(&[("GH_TOKEN", TOKEN), (key, "/tmp/evil.so")]), + )); + + assert_eq!(error, ReleaseError::Environment, "should reject {key}"); + assert!( + text.contains(key), + "should name the rejected variable: {text}" + ); + assert!(!text.contains("/tmp/evil.so"), "should not echo the value"); + } +} + +#[test] +fn debug_output_never_contains_token() { + let fake = Fake::script("echo '[{}]'"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let failing = Fake::script("echo \"$GH_TOKEN\" >&2\nexit 1"); + let failing_runner = failing + .runner(test_bounds()) + .expect("should resolve fake gh"); + + let report = run( + &failing_runner, + AttemptDeadline::start(), + &Cancellation::default(), + ) + .expect_err("should fail"); + + for text in [ + format!("{runner:?}"), + format!("{runner:#?}"), + format!("{report:?}"), + format!("{report}"), + ] { + assert!(!text.contains(TOKEN), "should redact token from {text}"); + } +} + +#[test] +fn successful_stderr_is_redacted() { + let fake = Fake::script("echo \"warning for $GH_TOKEN\" >&2\necho '[{}]'"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + + let output = runner + .run( + &args(&["attestation", "verify"]), + AttemptDeadline::start(), + &Cancellation::default(), + ) + .expect("should succeed"); + + assert_eq!( + output.stderr, "warning for [redacted]\n", + "should expose only redacted stderr" + ); +} + +#[test] +fn process_snapshot_captures_current_environment_once() { + let snapshot = CallerEnvironment::from_process(); + + assert_eq!( + snapshot.get("PATH"), + std::env::var_os("PATH").as_deref(), + "should capture the process PATH" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs b/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs new file mode 100644 index 0000000000..279c6b7f0f --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs @@ -0,0 +1,293 @@ +//! Deadlines, cancellation, byte caps, termination, reaping and output +//! acceptance with real OS fake children. + +use super::*; + +// Bounds, termination and reaping. + +#[test] +fn child_timeout_kills_and_reaps_group() { + let fake = Fake::empty(); + let script = format!( + "sleep 30 &\necho $! > '{}'\nsleep 30", + fake.file("grandchild.pid").display() + ); + fake.install(&script); + let bounds = ProcessBounds { + child_timeout: Duration::from_millis(300), + ..test_bounds() + }; + let runner = fake.runner(bounds).expect("should resolve fake gh"); + let started = Instant::now(); + + let (error, _) = context(run( + &runner, + AttemptDeadline::start(), + &Cancellation::default(), + )); + + assert_eq!( + error, + ReleaseError::Timeout, + "should report timeout outcome" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "should bound wall time" + ); + assert_dead( + read_pid(&fake.file("grandchild.pid")), + "same-group grandchild", + ); +} + +#[test] +fn attempt_deadline_bounds_child_before_child_timeout() { + let fake = Fake::script("sleep 30"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let started = Instant::now(); + + let (error, _) = context(run( + &runner, + AttemptDeadline::at(Instant::now() + Duration::from_millis(300)), + &Cancellation::default(), + )); + + assert_eq!( + error, + ReleaseError::Timeout, + "should honor attempt deadline" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "should bound wall time" + ); +} + +#[test] +fn cancellation_kills_child() { + let fake = Fake::empty(); + let script = format!( + "echo $$ > '{}'\nsleep 30", + fake.file("leader.pid").display() + ); + fake.install(&script); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let cancellation = Cancellation::default(); + let trigger = cancellation.clone(); + let leader_pid = fake.file("leader.pid"); + let canceller = thread::spawn(move || { + let pid = read_pid(&leader_pid); + trigger.cancel(); + pid + }); + let started = Instant::now(); + + let (error, _) = context(run(&runner, AttemptDeadline::start(), &cancellation)); + + let leader = canceller.join().expect("should join canceller"); + assert_eq!(error, ReleaseError::Cancelled, "should report cancellation"); + assert!( + started.elapsed() < Duration::from_secs(5), + "should bound wall time" + ); + assert_dead(leader, "cancelled leader"); +} + +#[test] +fn simultaneous_stdout_and_stderr_saturation_is_bounded() { + let fake = Fake::empty(); + let script = format!( + "sh -c 'echo $$ > \"$0\"; exec yes stderr-line' '{pid}' >&2 &\nwhile [ ! -s '{pid}' ]; do :; done\nexec yes stdout-line", + pid = fake.file("writer.pid").display() + ); + fake.install(&script); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let started = Instant::now(); + + let (error, _) = context(run( + &runner, + AttemptDeadline::start(), + &Cancellation::default(), + )); + + assert_eq!( + error, + ReleaseError::OutputLimit, + "should stop at byte bound" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "should bound wall time" + ); + assert_dead(read_pid(&fake.file("writer.pid")), "stderr writer"); +} + +#[test] +fn stderr_saturation_alone_is_bounded() { + let fake = Fake::script("exec yes stderr-line >&2"); + let bounds = ProcessBounds { + stdout_bytes: 1024 * 1024, + ..test_bounds() + }; + let runner = fake.runner(bounds).expect("should resolve fake gh"); + + let (error, _) = context(run( + &runner, + AttemptDeadline::start(), + &Cancellation::default(), + )); + + assert_eq!(error, ReleaseError::OutputLimit, "should bound stderr"); +} + +#[test] +fn truncated_json_is_rejected() { + let fake = Fake::script("printf '[{\"verificationResult\":'"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let subject = fake.file("subject.wasm"); + + let (error, _) = context(runner.verify_attestation( + &subject, + &fake.file("subject.wasm.provenance.sigstore.json"), + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default(), + )); + + assert_eq!( + error, + ReleaseError::Document, + "should reject incomplete JSON" + ); +} + +#[test] +fn failed_exit_yields_no_claims_and_redacts_token() { + let fake = Fake::script( + "echo '[{\"verificationResult\":{}}]'\necho \"denied for $GH_TOKEN\" >&2\nexit 1", + ); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let subject = fake.file("subject.wasm"); + + let (error, text) = context(runner.verify_attestation( + &subject, + &fake.file("subject.wasm.provenance.sigstore.json"), + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default(), + )); + + assert_eq!( + error, + ReleaseError::ExitFailure, + "should fail closed on exit 1" + ); + assert!( + text.contains("denied for [redacted]"), + "should keep redacted stderr: {text}" + ); + assert!( + !text.contains(TOKEN), + "should never print the token: {text}" + ); +} + +#[test] +fn requires_exactly_one_attestation_result() { + for (output, accepted) in [ + ("[]", false), + ( + "[{\"verificationResult\":{}},{\"verificationResult\":{}}]", + false, + ), + ("{\"verificationResult\":{}}", false), + ("[{\"verificationResult\":{}}]", true), + ] { + let fake = Fake::script(&format!("echo '{output}'")); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let subject = fake.file("subject.wasm"); + + let result = runner.verify_attestation( + &subject, + &fake.file("subject.wasm.sbom.sigstore.json"), + &release(), + PredicateKind::Spdx, + AttemptDeadline::start(), + &Cancellation::default(), + ); + + if accepted { + assert!(result.is_ok(), "should accept exactly one result"); + } else { + assert_eq!( + context(result).0, + ReleaseError::Output, + "should reject {output}" + ); + } + } +} + +#[test] +fn successful_exit_kills_lingering_same_group_descendant() { + let fake = Fake::empty(); + let script = format!( + "echo $$ > '{leader}'\nsleep 30 &\necho $! > '{grandchild}'\necho '[{{}}]'\nexit 0", + leader = fake.file("leader.pid").display(), + grandchild = fake.file("grandchild.pid").display() + ); + fake.install(&script); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let started = Instant::now(); + + let stdout = run(&runner, AttemptDeadline::start(), &Cancellation::default()) + .expect("should complete after killing lingering group member"); + + assert_eq!(stdout, b"[{}]\n", "should retain exact stdout"); + assert!( + started.elapsed() < Duration::from_secs(5), + "should not wait for sleep" + ); + assert_dead( + read_pid(&fake.file("grandchild.pid")), + "lingering grandchild", + ); + assert!( + !process_exists(read_pid(&fake.file("leader.pid"))), + "should have reaped the leader before returning" + ); +} + +#[test] +fn escaped_descendant_is_reported_not_awaited() { + let fake = Fake::empty(); + let pid_file = fake.file("escaped.pid"); + let script = format!( + "set -m\nsleep 30 &\necho $! > '{}'\nexit 0", + pid_file.display() + ); + fake.install(&script); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let started = Instant::now(); + + let (error, _) = context(run( + &runner, + AttemptDeadline::start(), + &Cancellation::default(), + )); + + let escaped = read_pid(&pid_file); + terminate_process(escaped); + assert_eq!( + error, + ReleaseError::DescendantEscaped, + "should report escaped descendant" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "should not hang on open pipes" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/gh/mod.rs b/crates/trusted-server-cli/src/release/tests/gh/mod.rs new file mode 100644 index 0000000000..97dc9a51ed --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/gh/mod.rs @@ -0,0 +1,206 @@ +//! Bounded, isolated gh runner tests using real OS fake children. +//! +//! Each fake `gh` is a POSIX `sh` script in a private tempdir. The scripts +//! exercise real pipes, process groups, signals and reaping rather than mocks. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::Write as _; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use error_stack::Report; +use tempfile::TempDir; + +use crate::release::bounds::{AttemptDeadline, Cancellation, ProcessBounds}; +use crate::release::documents::{ApprovedCommit, ApprovedRelease, ReleaseTag}; +use crate::release::environment::CallerEnvironment; +use crate::release::error::ReleaseError; +use crate::release::gh::{ + GhLocation, GhRunner, PredicateKind, attestation_verify_args, release_verify_args, + release_verify_asset_args, +}; + +mod argv; +mod environment; +mod lifecycle; + +const TOKEN: &str = "fictional-gh-token-1111"; +const FALLBACK_TOKEN: &str = "fictional-github-token-2222"; +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +const VERSION_PRELUDE: &str = "if [ \"$1\" = \"--version\" ]; then\n echo 'gh version 2.97.0 (2026-01-01)'\n echo 'https://github.com/cli/cli/releases/tag/v2.97.0'\n exit 0\nfi\n"; +/// Spawn attempts tolerated for Linux `ETXTBSY` on a freshly written script. +const SPAWN_ATTEMPTS: u32 = 5; + +/// A fake `gh` whose script is written exactly once. +struct Fake { + directory: TempDir, + gh: PathBuf, +} + +impl Fake { + /// Creates the private directory; [`Self::install`] writes the script. + fn empty() -> Self { + let directory = tempfile::tempdir().expect("should create fake gh directory"); + let gh = directory.path().join("gh"); + Self { directory, gh } + } + + /// Creates a fake that answers `--version` with 2.97.0, then runs `body`. + fn script(body: &str) -> Self { + let fake = Self::empty(); + fake.install(body); + fake + } + + /// Creates a fake with a custom version prelude. + fn with_version(prelude: &str, body: &str) -> Self { + let fake = Self::empty(); + fake.install_with(prelude, body); + fake + } + + fn install(&self, body: &str) { + self.install_with(VERSION_PRELUDE, body); + } + + // Write under a temporary name, sync, mark executable and rename into + // place, so the executable path never refers to a file open for writing. + fn install_with(&self, prelude: &str, body: &str) { + let staging = self.file(".gh.staging"); + let mut file = File::create(&staging).expect("should create staged fake gh"); + file.write_all(format!("#!/bin/sh\n{prelude}{body}\n").as_bytes()) + .expect("should write staged fake gh"); + file.sync_all().expect("should sync staged fake gh"); + drop(file); + fs::set_permissions(&staging, fs::Permissions::from_mode(0o755)) + .expect("should mark fake gh executable"); + fs::rename(&staging, &self.gh).expect("should install fake gh"); + } + + fn file(&self, name: &str) -> PathBuf { + self.directory.path().join(name) + } + + fn runner(&self, bounds: ProcessBounds) -> Result> { + resolve( + GhLocation::Explicit(&self.gh), + &caller(&[("GH_TOKEN", TOKEN)]), + ) + .map(|runner| runner.with_bounds(bounds)) + } +} + +/// Resolves gh with test bounds, retrying only Linux `ETXTBSY`: another test +/// thread may have forked while this script was briefly open for writing. +fn resolve( + location: GhLocation<'_>, + caller: &CallerEnvironment, +) -> Result> { + let mut attempt = 1; + loop { + let result = GhRunner::resolve( + location, + caller, + test_bounds(), + AttemptDeadline::start(), + &Cancellation::default(), + ); + let busy = result + .as_ref() + .err() + .is_some_and(|report| format!("{report:?}").contains("ExecutableFileBusy")); + if !busy || attempt >= SPAWN_ATTEMPTS { + return result; + } + attempt += 1; + thread::sleep(Duration::from_millis(20)); + } +} + +fn caller(pairs: &[(&str, &str)]) -> CallerEnvironment { + CallerEnvironment::from_pairs(pairs.iter().copied()) +} + +fn test_bounds() -> ProcessBounds { + ProcessBounds { + child_timeout: Duration::from_secs(10), + stdout_bytes: 64 * 1024, + stderr_bytes: 16 * 1024, + kill_grace: Duration::from_millis(500), + } +} + +fn release() -> ApprovedRelease { + ApprovedRelease::new( + ReleaseTag::parse("v1.2.0").expect("should parse fictional tag"), + ApprovedCommit::parse(COMMIT).expect("should parse fictional commit"), + ) +} + +fn args(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() +} + +fn run( + runner: &GhRunner, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result, Report> { + runner + .run(&args(&["attestation", "verify"]), deadline, cancellation) + .map(|output| output.stdout) +} + +fn read_pid(path: &Path) -> libc::pid_t { + let started = Instant::now(); + loop { + if let Some(pid) = fs::read_to_string(path) + .ok() + .and_then(|text| text.trim().parse().ok()) + .filter(|pid: &libc::pid_t| *pid > 0) + { + return pid; + } + assert!( + started.elapsed() < Duration::from_secs(5), + "should observe child pid file {}", + path.display() + ); + thread::sleep(Duration::from_millis(10)); + } +} + +/// Returns whether `pid` names a process, including an unreaped zombie. +fn process_exists(pid: libc::pid_t) -> bool { + assert!(pid > 0, "should only probe a single positive pid"); + // SAFETY: signal 0 performs only existence and permission checks. + let result = unsafe { libc::kill(pid, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +/// Kills one escaped test process, never a group or broadcast target. +fn terminate_process(pid: libc::pid_t) { + assert!(pid > 0, "should only kill a single positive pid"); + // SAFETY: `kill` only reads its two integer arguments. + let _ = unsafe { libc::kill(pid, libc::SIGKILL) }; +} + +fn assert_dead(pid: libc::pid_t, what: &str) { + let started = Instant::now(); + while process_exists(pid) { + assert!( + started.elapsed() < Duration::from_secs(5), + "should kill and reap {what} (pid {pid})" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +fn context(result: Result>) -> (ReleaseError, String) { + let report = result.err().expect("should fail"); + (*report.current_context(), format!("{report:?}")) +} diff --git a/crates/trusted-server-cli/src/release/tests/mod.rs b/crates/trusted-server-cli/src/release/tests/mod.rs new file mode 100644 index 0000000000..b862122c54 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/mod.rs @@ -0,0 +1,5 @@ +//! Private release verification tests, registered explicitly so focused runs +//! (`release::tests::documents`, `release::tests::gh`) select real cases. + +mod documents; +mod gh; 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 + } +} From 8a68537344d6c9e1dbd7c8dea3731ea971346376 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 11:27:13 +0530 Subject: [PATCH 044/153] Harden release source authentication and preflight inputs Address independent review of the Task 3 preflight: - Rehash every commit, tree and blob with collision-detecting SHA-1 and parse Git trees in canonical order with depth, entry and object bounds instead of trusting cat-file/ls-tree output. - Compile the complete recipe settings and consumer policy and require exact equality, so candidate inputs cannot amend them silently. - Reject ancestor Cargo configuration layers, additional credential, injection and native-selector variables, ambiguous-case operator prefixes, non-ASCII tracked paths and fail-open metadata errors. - Enforce spec companion file names in the release index. - Report the git executable and rustup selection as not established, bound git output per call and in aggregate, and keep bootstrap closed while checking the repository root for a legacy rust-toolchain file. - Split source, preflight and test modules and introduce CommitId. --- Cargo.lock | 1 + crates/trusted-server-release/Cargo.toml | 1 + .../trusted-server-release/src/documents.rs | 62 +- .../src/documents/tests.rs | 25 +- crates/trusted-server-release/src/error.rs | 5 +- crates/trusted-server-release/src/main.rs | 102 +--- crates/trusted-server-release/src/policy.rs | 209 ++++--- .../src/policy/tests.rs | 96 ++- .../src/policy/tools.rs | 117 +++- .../trusted-server-release/src/preflight.rs | 284 +++++---- .../src/preflight/child.rs | 139 +++++ .../src/preflight/tests.rs | 152 ++++- crates/trusted-server-release/src/source.rs | 568 +++++------------- .../src/source/closure.rs | 105 ++++ .../src/source/config.rs | 95 +++ .../trusted-server-release/src/source/git.rs | 237 +++++--- .../src/source/tests.rs | 557 ++--------------- .../src/source/tests/closure.rs | 122 ++++ .../src/source/tests/layout.rs | 240 ++++++++ .../src/source/tests/objects.rs | 380 ++++++++++++ .../src/source/tests/worktree.rs | 141 +++++ .../trusted-server-release/src/source/tree.rs | 225 +++++++ .../src/source/worktree.rs | 170 ++++++ .../trusted-server-release/tests/commands.rs | 66 +- .../tests/fixtures/README.md | 4 + .../tests/fixtures/sha-mbles-1.bin | Bin 0 -> 640 bytes scripts/release/bootstrap.sh | 15 +- 27 files changed, 2747 insertions(+), 1371 deletions(-) create mode 100644 crates/trusted-server-release/src/preflight/child.rs create mode 100644 crates/trusted-server-release/src/source/closure.rs create mode 100644 crates/trusted-server-release/src/source/config.rs create mode 100644 crates/trusted-server-release/src/source/tests/closure.rs create mode 100644 crates/trusted-server-release/src/source/tests/layout.rs create mode 100644 crates/trusted-server-release/src/source/tests/objects.rs create mode 100644 crates/trusted-server-release/src/source/tests/worktree.rs create mode 100644 crates/trusted-server-release/src/source/tree.rs create mode 100644 crates/trusted-server-release/src/source/worktree.rs create mode 100644 crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin diff --git a/Cargo.lock b/Cargo.lock index 21fd90a4f2..bba74ac73d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6935,6 +6935,7 @@ dependencies = [ "libc", "serde", "serde_json", + "sha1-checked", "sha2 0.10.9", "tempfile", "toml", diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index 59e2ef28eb..8be0ee2584 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -15,6 +15,7 @@ libc.workspace = true jsonschema = { version = "=0.58.6", default-features = false } serde.workspace = true serde_json.workspace = true +sha1-checked = { version = "=0.10.0", default-features = false } sha2.workspace = true tempfile.workspace = true toml.workspace = true diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs index 5b6c4948bc..d1304beec4 100644 --- a/crates/trusted-server-release/src/documents.rs +++ b/crates/trusted-server-release/src/documents.rs @@ -16,6 +16,16 @@ use sha2::{Digest as _, Sha256}; use crate::error::ReleaseError; +/// R-INDEX companion role names and their required `` suffixes. +const COMPANION_SUFFIXES: [(&str, &str); 6] = [ + ("sbom", ".spdx.json"), + ("provenance", ".provenance.sigstore.json"), + ("sbom_attestation", ".sbom.sigstore.json"), + ("build_evidence", ".build-evidence.json"), + ("build_recipe", ".build-recipe.json"), + ("rebuild_check", ".rebuild-check.json"), +]; + /// Byte-limit class selected before a document is read or parsed. #[derive(Clone, Copy, Debug)] pub(crate) enum Role { @@ -369,24 +379,44 @@ fn validate_schema(schema: KnownSchema, value: &Value) -> Result<(), Report Result<(), Report> { + let mut names = BTreeSet::new(); + FileRecord::fields(&value["tsjs_inventory"])?.check(Role::Metadata, &mut names)?; + for artifact in value["artifacts"] + .as_array() + .ok_or_else(|| invalid("missing artifacts"))? + { + let asset = FileRecord::fields(artifact)?; + asset.check(Role::Executable, &mut names)?; + for (kind, companion) in artifact["companions"] + .as_object() + .ok_or_else(|| invalid("missing companions"))? { - FileRecord::fields(artifact)?.check(Role::Executable, &mut names)?; - for (kind, companion) in artifact["companions"] - .as_object() - .ok_or_else(|| invalid("missing companions"))? - { - let role = if kind == "sbom" { - Role::Spdx - } else { - Role::Companion - }; - FileRecord::fields(companion)?.check(role, &mut names)?; + let suffix = COMPANION_SUFFIXES + .iter() + .find(|(role, _)| role == kind) + .map(|(_, suffix)| *suffix) + .ok_or_else(|| invalid("unknown companion role"))?; + let derived = companion["name"] + .as_str() + .and_then(|name| name.strip_prefix(asset.name.as_str())); + if derived != Some(suffix) { + return Err(invalid( + "companion name must be the asset name plus its role suffix", + )); } + let role = if kind == "sbom" { + Role::Spdx + } else { + Role::Companion + }; + FileRecord::fields(companion)?.check(role, &mut names)?; } } Ok(()) diff --git a/crates/trusted-server-release/src/documents/tests.rs b/crates/trusted-server-release/src/documents/tests.rs index 56a6c7ecbc..a5666d8a4a 100644 --- a/crates/trusted-server-release/src/documents/tests.rs +++ b/crates/trusted-server-release/src/documents/tests.rs @@ -611,8 +611,8 @@ fn index_fixture() -> Value { ]; let artifacts: Vec<_> = identities.iter().map(|(name,target,architecture,runtime,minimum)| { let is_server = *runtime == "fastly-compute"; - let companions: Map<_,_> = ["sbom", "provenance", "sbom_attestation", "build_evidence", "build_recipe", "rebuild_check"].iter() - .map(|role| (role.to_string(), json!({"name":format!("{name}.{role}.json"), "size":1, "sha256":"a".repeat(64)}))).collect(); + let companions: Map<_,_> = [("sbom", ".spdx.json"), ("provenance", ".provenance.sigstore.json"), ("sbom_attestation", ".sbom.sigstore.json"), ("build_evidence", ".build-evidence.json"), ("build_recipe", ".build-recipe.json"), ("rebuild_check", ".rebuild-check.json")].iter() + .map(|(role, suffix)| (role.to_string(), json!({"name":format!("{name}{suffix}"), "size":1, "sha256":"a".repeat(64)}))).collect(); json!({"name":name, "size":1, "sha256":"a".repeat(64), "role":if is_server {"server"} else {"operator-cli"}, "kind":if is_server {"wasm-core"} else {"native-executable"}, "package":if is_server {"trusted-server-adapter-fastly"} else {"trusted-server-cli"}, "target":target, "variant":"default", "assurance_profile":if is_server {"provenance-sbom-repeatable"} else {"provenance-sbom"}, @@ -765,3 +765,24 @@ fn failed_rename_removes_private_sibling() { "should leave only existing destination" ); } + +#[test] +fn index_role_checks_require_role_specific_companion_names() { + let good = index_fixture(); + let mut swapped = good.clone(); + let sbom = swapped["artifacts"][0]["companions"]["sbom"]["name"].clone(); + swapped["artifacts"][0]["companions"]["sbom"]["name"] = + swapped["artifacts"][0]["companions"]["provenance"]["name"].clone(); + swapped["artifacts"][0]["companions"]["provenance"]["name"] = sbom; + assert!( + validate_schema(KnownSchema::ReleaseIndex, &swapped).is_err(), + "should reject companion names swapped between roles" + ); + let mut foreign = good; + foreign["artifacts"][0]["companions"]["sbom"]["name"] = + json!("ts-x86_64-unknown-linux-gnu-other.spdx.json"); + assert!( + validate_schema(KnownSchema::ReleaseIndex, &foreign).is_err(), + "should reject companion names not derived from their asset" + ); +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 2d1e7b9c73..f4486de2d1 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -2,9 +2,10 @@ #[derive(Debug, derive_more::Display)] pub(crate) enum ReleaseError { #[display( - "Rust release execution remains disabled pending authenticated bootstrap and preflight" + "Invalid release invocation; Rust release execution remains disabled pending \ + authenticated bootstrap and preflight" )] - Pending, + Usage, #[display("Invalid release document")] Document, #[display("Release input policy rejects the candidate inputs")] diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index dbde3a216f..bf0a633473 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -4,17 +4,19 @@ mod documents; mod error; mod policy; mod preflight; +#[allow(dead_code)] mod source; use std::ffi::OsString; use std::io::Write as _; -use std::path::{Component, Path, PathBuf}; +use std::path::PathBuf; use std::process::ExitCode; use clap::{Parser, Subcommand}; use error::ReleaseError; use error_stack::Report; use error_stack::fmt::ColorMode; +use source::CommitId; /// Exit status of `audit-inputs` when any input is incomplete. const INCOMPLETE_STATUS: u8 = 3; @@ -46,6 +48,16 @@ enum Command { AuditInputs, } +/// Writes one human-readable diagnostic line to stderr. +/// +/// The `log` facade is deliberately not used: no logger is installed before +/// the environment audit runs, and configuring one from inherited variables +/// such as `RUST_LOG` would itself be an unaudited input channel. Write +/// failures are ignored because diagnostics never change the outcome. +pub(crate) fn notice(message: std::fmt::Arguments<'_>) { + let _ = writeln!(std::io::stderr(), "{message}"); +} + fn main() -> ExitCode { let inherited: Vec<(OsString, OsString)> = std::env::vars_os().collect(); Report::set_color_mode(ColorMode::None); @@ -53,7 +65,7 @@ fn main() -> ExitCode { match run(&inherited, &arguments) { Ok(status) => status, Err(report) => { - let _ = writeln!(std::io::stderr(), "{report:?}"); + notice(format_args!("{report:?}")); ExitCode::FAILURE } } @@ -63,88 +75,25 @@ fn run( inherited: &[(OsString, OsString)], arguments: &[OsString], ) -> Result> { - let cli = Cli::try_parse_from(arguments).map_err(|_| Report::new(ReleaseError::Pending))?; + let cli = Cli::try_parse_from(arguments).map_err(|error| { + Report::new(ReleaseError::Usage).attach(format!("invalid invocation: {}", error.kind())) + })?; match cli.command { Command::Preflight { cohort, source_commit, require_qualified, - } => preflight(inherited, &cohort, &source_commit, require_qualified), - Command::AuditInputs => audit_inputs(), - } -} - -fn current_root() -> Result> { - std::env::current_dir().map_err(|error| { - Report::new(ReleaseError::Source).attach(format!("cannot resolve source root: {error}")) - }) -} - -fn names_cohort(root: &Path, cohort: &Path) -> bool { - !cohort.as_os_str().is_empty() - && !cohort - .components() - .any(|component| matches!(component, Component::ParentDir)) - && root - .join(cohort) - .components() - .eq(root.join(policy::COHORT_PATH).components()) -} - -fn preflight( - inherited: &[(OsString, OsString)], - cohort: &Path, - commit: &str, - require_qualified: bool, -) -> Result> { - let audited = preflight::audit_environment(inherited)?; - if !audited.removed_names().is_empty() { - let _ = writeln!( - std::io::stderr(), - "removed inherited operator variables: {}", - audited.removed_names().join(", ") - ); - } - let root = current_root()?; - if !names_cohort(&root, cohort) { - return Err(Report::new(ReleaseError::Policy).attach(format!( - "--cohort must name {} below the current source root", - policy::COHORT_PATH - ))); - } - let source = source::authenticate(&root, commit, &audited)?; - let inputs = policy::load_inputs(&root)?; - for (path, sha256) in inputs.input_digests() { - if source.tracked_sha256(path) != Some(sha256.as_str()) { - return Err(Report::new(ReleaseError::Source) - .attach(format!("{path} does not match the approved commit"))); + } => { + let commit = CommitId::parse(&source_commit)?; + preflight::run_preflight(inherited, &cohort, &commit, require_qualified)?; + Ok(ExitCode::SUCCESS) } + Command::AuditInputs => audit_inputs(), } - let mut stderr = std::io::stderr(); - let _ = writeln!( - stderr, - "source {} authenticated locally (epoch {}, snapshot {}, helper closure {} over {} paths); \ - remote branch-tip ancestry and ruleset validation are not established locally (Task 14)", - source.commit(), - source.epoch(), - source.snapshot().fingerprint(), - source.closure_digest(), - source.closure_paths().len() - ); - if !require_qualified { - let _ = writeln!( - stderr, - "qualified selection is required whether or not --require-qualified is passed" - ); - } - let selection = policy::select_qualified(&inputs, &[])?; - source.snapshot().verify_unchanged()?; - let _ = writeln!(stderr, "{}", selection.summary()); - Ok(ExitCode::SUCCESS) } fn audit_inputs() -> Result> { - let root = current_root()?; + let root = preflight::source_root()?; let inputs = policy::load_inputs(&root)?; let audit = policy::audit_inputs(&inputs); let bytes = documents::canonical_json(&audit.to_json(), documents::Role::Metadata)?; @@ -154,10 +103,9 @@ fn audit_inputs() -> Result> { if audit.is_complete() { return Ok(ExitCode::SUCCESS); } - let _ = writeln!( - std::io::stderr(), + notice(format_args!( "{}: release inputs are incomplete; this diagnostic authorizes nothing", ReleaseError::Incomplete - ); + )); Ok(ExitCode::from(INCOMPLETE_STATUS)) } diff --git a/crates/trusted-server-release/src/policy.rs b/crates/trusted-server-release/src/policy.rs index c5de48a98c..87dc5a6056 100644 --- a/crates/trusted-server-release/src/policy.rs +++ b/crates/trusted-server-release/src/policy.rs @@ -9,6 +9,7 @@ use std::path::Path; use error_stack::Report; use serde::Deserialize; +use serde::de::DeserializeOwned; use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; @@ -48,8 +49,10 @@ const CLI_POLICY_FIELDS: [&str; 13] = [ "repository_owner_id", "tool_versions", ]; -const NOT_ESTABLISHED: [&str; 2] = [ +const NOT_ESTABLISHED: [&str; 4] = [ "authenticated acquisition receipts (Task 8)", + "git executable identity (Task 8)", + "rustup override/toolchain selection (bootstrap, Task 8)", "remote branch-tip ancestry and ruleset validation (Task 14)", ]; @@ -81,6 +84,7 @@ impl ReleaseTarget { } /// Returns whether the target needs the explicit macOS loader floor. + // Only the Task 5/7 child-environment builder (`preflight::child`) calls this. #[allow(dead_code)] pub(crate) fn is_darwin(self) -> bool { matches!(self, Self::Aarch64AppleDarwin | Self::X86_64AppleDarwin) @@ -204,7 +208,6 @@ struct Compatibility { } /// Raw-byte identity of one tracked input file. -#[derive(Clone)] struct InputDigest { path: &'static str, sha256: String, @@ -220,10 +223,10 @@ pub(crate) struct ReleaseInputs { impl ReleaseInputs { /// Returns the source-relative path and raw SHA-256 of each input file. - pub(crate) fn input_digests(&self) -> [(&'static str, String); 3] { + pub(crate) fn input_digests(&self) -> [(&'static str, &str); 3] { self.digests - .clone() - .map(|digest| (digest.path, digest.sha256)) + .each_ref() + .map(|digest| (digest.path, digest.sha256.as_str())) } fn pending_items(&self) -> Vec { @@ -238,18 +241,18 @@ impl ReleaseInputs { pub(crate) enum AcquisitionReceipt {} /// Production tool selection; constructed only by [`select_qualified`]. -pub(crate) struct QualifiedSelection { +pub(crate) struct QualifiedSelection<'a> { receipts: Vec, - inputs: [(&'static str, String); 3], + inputs: &'a ReleaseInputs, } -impl QualifiedSelection { +impl QualifiedSelection<'_> { /// Summarizes the selected inputs for the caller's diagnostics. pub(crate) fn summary(&self) -> String { format!( "selected {} authenticated pins from {} inputs", self.receipts.len(), - self.inputs.len() + self.inputs.digests.len() ) } } @@ -260,10 +263,10 @@ impl QualifiedSelection { /// /// Fails on any pending qualification or null pin, and otherwise fails because /// authenticated acquisition receipts cannot be constructed yet. -pub(crate) fn select_qualified( - inputs: &ReleaseInputs, +pub(crate) fn select_qualified<'a>( + inputs: &'a ReleaseInputs, receipts: &[AcquisitionReceipt], -) -> Result> { +) -> Result, Report> { let pending = inputs.pending_items(); if !pending.is_empty() { return Err(Report::new(ReleaseError::Unqualified).attach(format!( @@ -281,18 +284,21 @@ pub(crate) fn select_qualified( } Ok(QualifiedSelection { receipts: verified, - inputs: inputs.input_digests(), + inputs, }) } /// Diagnostic description of incomplete inputs. It authorizes nothing. -pub(crate) struct InputAudit { - inputs: [(&'static str, String); 3], +pub(crate) struct InputAudit<'a> { + inputs: &'a ReleaseInputs, pending: Vec, } -impl InputAudit { +impl InputAudit<'_> { /// Returns whether every JSON pin and external authentication is present. + /// + /// This is always `false` while [`NOT_ESTABLISHED`] lists facts this tool + /// cannot yet establish, so the diagnostic can never report completion. pub(crate) fn is_complete(&self) -> bool { self.pending.is_empty() && NOT_ESTABLISHED.is_empty() } @@ -302,6 +308,7 @@ impl InputAudit { json!({ "inputs": self .inputs + .input_digests() .iter() .map(|(path, sha256)| json!({"path": path, "sha256": sha256})) .collect::>(), @@ -314,9 +321,9 @@ impl InputAudit { } /// Lists pending and null items for diagnostics without constructing authority. -pub(crate) fn audit_inputs(inputs: &ReleaseInputs) -> InputAudit { +pub(crate) fn audit_inputs(inputs: &ReleaseInputs) -> InputAudit<'_> { InputAudit { - inputs: inputs.input_digests(), + inputs, pending: inputs.pending_items(), } } @@ -365,10 +372,7 @@ pub(crate) fn check_inputs( }) } -fn typed Deserialize<'de>>( - value: &Value, - path: &str, -) -> Result> { +fn typed(value: &Value, path: &str) -> Result> { T::deserialize(value).map_err(|error| rejected(format!("{path}: {error}"))) } @@ -432,8 +436,29 @@ fn check_artifact( "asset {name} must keep default_features true and variant default" ))); } + for (field, expected, actual) in identity_fields(fixed, artifact) { + if expected != actual { + return Err(rejected(format!( + "asset {name} changes {field} from {expected} to {actual}" + ))); + } + } + if artifact.capabilities != [fixed.capability] { + return Err(rejected(format!( + "asset {name} changes capabilities from [{}]", + fixed.capability + ))); + } + Ok(()) +} + +/// Field name, compiled value and candidate value of each fixed identity field. +fn identity_fields<'a>( + fixed: &FixedArtifact, + artifact: &'a CohortArtifact, +) -> [(&'static str, &'static str, &'a str); 9] { let compatibility = &artifact.compatibility; - let comparisons = [ + [ ("package", fixed.package, artifact.package.as_str()), ("bin", fixed.bin, artifact.bin.as_str()), ("target", fixed.target.triple(), artifact.target.as_str()), @@ -459,21 +484,74 @@ fn check_artifact( fixed.runtime, compatibility.runtime.as_str(), ), - ]; - for (field, expected, actual) in comparisons { - if expected != actual { - return Err(rejected(format!( - "asset {name} changes {field} from {expected} to {actual}" - ))); - } - } - if artifact.capabilities != [fixed.capability] { - return Err(rejected(format!( - "asset {name} changes capabilities from [{}]", - fixed.capability - ))); - } - Ok(()) + ] +} + +/// Every non-artifact field of the tracked consumer release policy. +fn compiled_consumer_policy() -> Value { + let workflow = "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/"; + json!({ + "bounds": { + "attempt_seconds": 900, + "bundle_bytes": 33_554_432, + "child_seconds": 120, + "cohort_executables": FIXED_ARTIFACTS.len(), + "companion_bytes": 33_554_432, + "executable_bytes": 536_870_912, + "index_bytes": 1_048_576, + "inventory_bytes": 1_048_576, + "json_depth": 64, + "matching_attestations_per_bundle": 1, + "metadata_bytes": 1_048_576, + "policy_bytes": 1_048_576, + "spdx_bytes": 16_777_216, + "stderr_bytes": 1_048_576, + "stdout_bytes": 134_217_728, + "trusted_roots_bytes": 1_048_576, + }, + "cohort": COHORT_ID, + "github_host": "github.com", + "historical_producer_policies": [], + "identity": { + "builder_id": workflow, + "caller_commit": "same-approved-source-commit", + "caller_path": ".github/workflows/release.yml", + "caller_repository": "https://github.com/IABTechLab/trusted-server", + "certificate_fields": [ + "buildConfigDigest", + "buildConfigURI", + "buildTrigger", + "runInvocationURI", + "sourceRepositoryIdentifier", + "sourceRepositoryOwnerIdentifier", + ], + "certificate_san": workflow, + "event": "push", + "external_parameters": "exact-caller-workflow-only-no-inputs", + "invocation": "same-repository-run-and-attempt-across-certificate-and-provenance", + "issuer": "https://token.actions.githubusercontent.com", + "runner": "github-hosted", + "signer_commit": "same-approved-source-commit", + "source_commit": "independently-approved-40-lowercase-hex", + "source_dependency": "git+https://github.com/IABTechLab/trusted-server@refs/tags/", + "source_ref": "refs/tags/", + }, + "policy_version": 1, + "predicates": { + "build_type": "https://actions.github.io/buildtypes/workflow/v1", + "provenance": "https://slsa.dev/provenance/v1", + "sbom": "https://spdx.dev/Document/v2.3", + }, + "public_actions": READONLY_ACTIONS, + "repository": "IABTechLab/trusted-server", + "repository_id": "950860906", + "repository_owner_id": "82903538", + "tool_versions": { + "actions_attest": ATTEST_VERSION, + "actions_attest_revision": ATTEST_REVISION, + "gh": GH_VERSION, + }, + }) } fn check_cli_policy(policy: &Value, cohort: &Value) -> Result<(), Report> { @@ -486,33 +564,20 @@ fn check_cli_policy(policy: &Value, cohort: &Value) -> Result<(), Report Result<(), Report> { + let policy_artifacts = policy .as_array() .ok_or_else(|| rejected("release-policy artifacts must be an array"))?; let cohort_artifacts = cohort["artifacts"] @@ -523,18 +588,24 @@ fn check_cli_policy(policy: &Value, cohort: &Value) -> Result<(), Report>, needle: #[test] fn accepts_tracked_inputs_and_preserves_raw_digests() { let inputs = check_inputs(COHORT, TOOLS, CLI_POLICY).expect("should accept tracked inputs"); - let digests = inputs.input_digests(); + let digests = inputs + .input_digests() + .map(|(path, sha256)| (path, sha256.to_owned())); assert_eq!( digests, [ @@ -544,3 +546,95 @@ fn audit_stays_incomplete_for_qualified_looking_json() { "should remain incomplete without authenticated receipts" ); } + +fn recipe_index(tools: &Value, id: &str) -> usize { + tools["recipes"] + .as_array() + .expect("should contain recipes") + .iter() + .position(|recipe| recipe["id"] == id) + .expect("should contain recipe") +} + +#[test] +fn requires_exact_recipe_settings() { + let mutations: [(&str, &str, Value); 4] = [ + ("tsjs", "mandatory_tests", json!(false)), + ("tsjs", "npm_lifecycle", json!("unrestricted")), + ( + "trusted-server-fastly.wasm", + "cargo_args", + json!(["--offline"]), + ), + ("ts-aarch64-apple-darwin", "release_strip", json!("symbols")), + ]; + for (id, key, replacement) in mutations { + expect_rejected( + tools_with(|tools| { + let index = recipe_index(tools, id); + tools["recipes"][index]["settings"][key] = replacement; + }), + "settings", + ); + } + expect_rejected( + tools_with(|tools| { + let index = recipe_index(tools, "ts-x86_64-unknown-linux-gnu"); + tools["recipes"][index]["settings"]["ambient_profiles"]["B"]["umask"] = json!("000"); + }), + "settings", + ); + expect_rejected( + tools_with(|tools| { + let index = recipe_index(tools, "tsjs"); + tools["recipes"][index]["settings"] + .as_object_mut() + .expect("should be settings") + .remove("npm_offline"); + }), + "settings", + ); +} + +#[test] +fn requires_exact_consumer_policy_fields() { + for (pointer, replacement) in [ + ("/repository_id", json!("1")), + ("/repository_owner_id", json!("2")), + ("/repository", json!("example/fictional")), + ("/github_host", json!("github.example.com")), + ("/identity/issuer", json!("https://issuer.example.com")), + ("/identity/builder_id", json!("https://example.com/builder")), + ("/predicates/sbom", json!("https://example.com/sbom")), + ("/predicates/build_type", json!("https://example.com/build")), + ("/bounds/child_seconds", json!(121)), + ( + "/historical_producer_policies", + json!([{"policy_sha256": "a".repeat(64)}]), + ), + ("/policy_version", json!(2)), + ] { + expect_rejected( + policy_with(|policy| { + *policy + .pointer_mut(pointer) + .expect("should address a tracked policy field") = replacement; + }), + "release-policy", + ); + } +} + +#[test] +fn audit_completion_stays_false_while_facts_are_not_established() { + assert!( + !NOT_ESTABLISHED.is_empty(), + "should still list facts this tool cannot establish" + ); + let inputs = check_inputs(COHORT, &encode(&qualified_looking_tools()), CLI_POLICY) + .expect("should parse structurally complete fictional pins"); + assert!( + !audit_inputs(&inputs).is_complete(), + "should never report completion while any fact is not established" + ); +} diff --git a/crates/trusted-server-release/src/policy/tools.rs b/crates/trusted-server-release/src/policy/tools.rs index 6812b09a30..8ba6d6eecc 100644 --- a/crates/trusted-server-release/src/policy/tools.rs +++ b/crates/trusted-server-release/src/policy/tools.rs @@ -409,21 +409,88 @@ struct FixedRecipe { id: &'static str, environment: &'static str, script: &'static str, - settings: Vec<(&'static str, Value)>, + settings: Value, } -fn expected_recipes() -> Vec { - let artifact_settings = || { - vec![ - ("aws_lc_use_system", json!(false)), - ("cargo_config", json!(".cargo/config.toml")), - ("cargo_frozen", json!(true)), - ("cargo_locked", json!(true)), - ("rust_toolchain", json!(RUST_VERSION)), - ("sqlite_use_pkg_config", json!(false)), - ("vite_mode", json!("production")), - ] +/// Complete effective settings shared by every executable recipe. +fn artifact_settings() -> Value { + let ambient = |checkout, user, jobs, locale, timezone, umask| { + json!({ + "checkout_root": checkout, + "execution_user": user, + "jobs": jobs, + "locale": locale, + "timezone": timezone, + "umask": umask, + }) }; + json!({ + "ambient_profiles": { + "A": ambient("external-release-A", "qualification-selected", 4, "C", "UTC", "022"), + "B": ambient( + "external-release-B", + "qualified-alternate-or-unsupported", + 2, + "qualification-selected-alternate", + "qualification-selected-alternate", + "027", + ), + }, + "aws_lc_target_selector": true, + "aws_lc_use_system": false, + "cargo_config": ".cargo/config.toml", + "cargo_frozen": true, + "cargo_home": "private-prepared", + "cargo_locked": true, + "cargo_message_format": "json-render-diagnostics", + "download_control": "actual-cargo-build-script-required", + "fresh_target": true, + "network_boundary": "source-owned-os-denial-v1", + "normalized_inputs": [ + "compiler-locale-C", + "compiler-timezone-UTC", + "source-commit-epoch", + "remapped-paths", + ], + "npm_lifecycle": "reviewed-preparation-only", + "release_debug": 1, + "release_strip": "none", + "rust_toolchain": RUST_VERSION, + "sqlite_features": ["bundled"], + "sqlite_use_pkg_config": false, + "sysroot": "authenticated-sdk-required", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production", + }) +} + +/// Complete effective settings of the shared JS recipe. +fn js_settings() -> Value { + json!({ + "compare": "all-tracked-final-module-bytes", + "fresh_installations": 2, + "fresh_private_npm_caches": true, + "fresh_source": "tracked-objects-only", + "mandatory_tests": true, + "network_boundary": "source-owned-os-denial-v1", + "node_version": NODE_VERSION, + "npm_audit": false, + "npm_fund": false, + "npm_include_dev": true, + "npm_lifecycle": "reviewed-observed-foreground", + "npm_offline": true, + "npm_version": NPM_VERSION, + "publish": "A-only-after-equality", + "tool_paths": "authenticated-absolute-paths-required", + "vite_config_loading": false, + "vite_env_loading": false, + "vite_mode": "production", + }) +} + +fn expected_recipes() -> Vec { let mut recipes: Vec<_> = FIXED_ARTIFACTS .iter() .map(|fixed| FixedRecipe { @@ -437,15 +504,21 @@ fn expected_recipes() -> Vec { id: JS_RECIPE, environment: BUILDER_ENVIRONMENT, script: JS_SCRIPT, - settings: vec![ - ("node_version", json!(NODE_VERSION)), - ("npm_version", json!(NPM_VERSION)), - ("vite_mode", json!("production")), - ], + settings: js_settings(), }); recipes } +/// Names the first settings key whose presence or value differs. +fn first_settings_difference(expected: &Value, actual: &Map) -> Option { + let expected = expected.as_object()?; + expected + .keys() + .chain(actual.keys()) + .find(|key| expected.get(*key) != actual.get(*key)) + .cloned() +} + fn check_recipes(recipes: &[Recipe]) -> Result<(), Report> { let expected = expected_recipes(); let mut seen = BTreeSet::new(); @@ -471,12 +544,10 @@ fn check_recipes(recipes: &[Recipe]) -> Result<(), Report> { if recipe.script != *script { return Err(rejected(format!("recipe {id} script must be {script}"))); } - for (key, value) in settings { - if recipe.settings.get(*key) != Some(value) { - return Err(rejected(format!( - "recipe {id} settings.{key} must be {value}" - ))); - } + if let Some(key) = first_settings_difference(settings, &recipe.settings) { + return Err(rejected(format!( + "recipe {id} settings.{key} differs from the compiled recipe settings" + ))); } check_digest(&format!("recipe {id}"), "sha256", recipe.sha256.as_deref())?; } diff --git a/crates/trusted-server-release/src/preflight.rs b/crates/trusted-server-release/src/preflight.rs index 400e06e467..cf8f907cf7 100644 --- a/crates/trusted-server-release/src/preflight.rs +++ b/crates/trusted-server-release/src/preflight.rs @@ -4,62 +4,72 @@ //! cleared, so an empty starting environment cannot hide a rejected channel. //! Errors name variables but never print their values, which may be secrets. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::ffi::OsString; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use error_stack::Report; -use crate::documents::lower_hex; use crate::error::ReleaseError; -use crate::policy::ReleaseTarget; -use crate::source::ApprovedSource; +use crate::policy; +use crate::source::{self, CommitId}; const RELEASE_BUILD: &str = "TRUSTED_SERVER__RELEASE_BUILD"; const REMOVED_PREFIXES: [&str; 2] = ["TRUSTED_SERVER__", "EDGEZERO__"]; -const SKIP_BUILD: &str = "TSJS_SKIP_BUILD"; -// The child-environment builder below is consumed by recipe execution (Task 5); -// until then only tests and the typed preflight interface reference it. -#[allow(dead_code)] -const MACOS_DEPLOYMENT_TARGET: &str = "13.0"; -/// Exact (uppercase) names that select compilers, flags, loaders, sources or credentials. -const REJECTED_EXACT: [&str; 27] = [ +/// Exact (uppercase) names that select compilers, flags, loaders, sources or +/// credentials. Sorted, unique, and never covered by [`REJECTED_PREFIXES`] or +/// [`NATIVE_SELECTORS`]. +const REJECTED_EXACT: &[&str] = &[ + "ACTIONS_CACHE_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_RESULTS_URL", "ACTIONS_RUNTIME_TOKEN", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", + "ACTIONS_RUNTIME_URL", + "BASH_ENV", "CARGO", + "CLANG_PATH", "CRATE_CC_NO_DEFAULTS", + "CROSS_COMPILE", "DEVELOPER_DIR", + "ENV", + "GH_ENTERPRISE_TOKEN", "GH_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", "GITHUB_TOKEN", + "LIBCLANG_PATH", + "LLVM_CONFIG_PATH", "MACOSX_DEPLOYMENT_TARGET", + "NASM", + "NODE_AUTH_TOKEN", + "NODE_ENV", + "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", "NODE_PATH", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NPM_TOKEN", + "PKG_CONFIG", + "PROTOC", + "PROTOC_INCLUDE", "RUSTC", "RUSTDOC", "RUSTDOCFLAGS", "RUSTFLAGS", "SDKROOT", "SOURCE_DATE_EPOCH", + "SSH_AUTH_SOCK", "TSJS_SKIP_BUILD", "VCPKG_INSTALLATION_ROOT", "VCPKG_ROOT", - "LD_PRELOAD", - "LD_LIBRARY_PATH", - "LD_AUDIT", - "PKG_CONFIG", ]; /// Uppercase prefixes whose whole family is recipe-selected or forbidden. -const REJECTED_PREFIXES: [&str; 23] = [ - "AWS_LC_FIPS_SYS_", - "AWS_LC_SYS_", +const REJECTED_PREFIXES: &[&str] = &[ + "AWS_", "BINDGEN_", "CARGO_", + "CLOUDFLARE_", "CMAKE", "DYLD_", "FASTLY_", @@ -82,15 +92,26 @@ const REJECTED_PREFIXES: [&str; 23] = [ ]; /// Native tool selectors; `_`, `-`, `TARGET_` and `HOST_` variants are rejected too. -const NATIVE_SELECTORS: [&str; 10] = [ - "AR", "ARFLAGS", "CC", "CFLAGS", "CPPFLAGS", "CXX", "CXXFLAGS", "LD", "LDFLAGS", "RANLIB", +const NATIVE_SELECTORS: [&str; 11] = [ + "AR", + "ARFLAGS", + "CC", + "CFLAGS", + "CPPFLAGS", + "CXX", + "CXXFLAGS", + "CXXSTDLIB", + "LD", + "LDFLAGS", + "RANLIB", ]; -/// A hosted setup export accepted only for an exact value and resolved path identity. +/// A hosted setup export accepted only for an exact value whose path resolves to a +/// mandatory reviewed identity; value-only exceptions are not representable. pub(crate) struct ReviewedException { name: &'static str, value: &'static str, - path_identity: Option<&'static str>, + path_identity: &'static str, } /// Reviewed hosted setup values. Empty until values and paths are qualified. @@ -143,6 +164,11 @@ pub(crate) fn audit_environment( .any(|prefix| name.starts_with(prefix)) { removed.insert(name.to_owned()); + } else if REMOVED_PREFIXES + .iter() + .any(|prefix| name.to_ascii_uppercase().starts_with(prefix)) + { + rejected.insert(format!("{name} (ambiguous operator prefix casing)")); } else if is_rejected(name) && !is_reviewed(name, value) { rejected.insert(name.to_owned()); } else if name == "PATH" { @@ -163,8 +189,7 @@ pub(crate) fn audit_environment( fn is_rejected(name: &str) -> bool { let upper = name.to_ascii_uppercase(); - upper == SKIP_BUILD - || REJECTED_EXACT.contains(&upper.as_str()) + REJECTED_EXACT.contains(&upper.as_str()) || REJECTED_PREFIXES .iter() .any(|prefix| upper.starts_with(prefix)) @@ -184,137 +209,106 @@ fn is_native_variant(upper: &str, selector: &str) -> bool { .is_some_and(|suffix| suffix.starts_with(['_', '-'])) } -fn is_reviewed(name: &str, value: &str) -> bool { - REVIEWED_SETUP_EXCEPTIONS.iter().any(|exception| { - exception.name == name - && exception.value == value - && exception.path_identity.is_none_or(|identity| { - std::fs::canonicalize(value).is_ok_and(|path| path == Path::new(identity)) - }) - }) -} - -/// A tool directory produced by authenticated acquisition (Task 8). -#[allow(dead_code)] -pub(crate) struct ToolDirectory(PathBuf); - -/// Recipe-selected private locations for one child build. -#[allow(dead_code)] -pub(crate) struct ChildRequest { - pub(crate) target: ReleaseTarget, - pub(crate) home: PathBuf, - pub(crate) cargo_home: PathBuf, - pub(crate) tool_directories: Vec, +impl ReviewedException { + /// Accepts only the exact name and value whose path resolves to the reviewed identity. + fn matches(&self, name: &str, value: &str) -> bool { + self.name == name + && self.value == value + && std::fs::canonicalize(value).is_ok_and(|path| path == Path::new(self.path_identity)) + } } -/// An allowlisted child environment constructed from scratch. -#[allow(dead_code)] -pub(crate) struct ChildEnvironment { - variables: BTreeMap, - removed: Vec, +fn is_reviewed(name: &str, value: &str) -> bool { + REVIEWED_SETUP_EXCEPTIONS + .iter() + .any(|exception| exception.matches(name, value)) } -#[allow(dead_code)] -impl ChildEnvironment { - /// Every variable the child receives; nothing is inherited. - pub(crate) fn variables(&self) -> &BTreeMap { - &self.variables - } - - /// Sorted names injected by the recipe, recorded as input facts. - pub(crate) fn injected_names(&self) -> Vec<&str> { - self.variables.keys().map(String::as_str).collect() - } - - /// Inherited operator names removed during the audit. - pub(crate) fn removed_names(&self) -> &[String] { - &self.removed - } +/// Resolves the source root, which is always the current directory. +/// +/// # Errors +/// +/// Fails when the current directory cannot be resolved. +pub(crate) fn source_root() -> Result> { + std::env::current_dir().map_err(|error| { + Report::new(ReleaseError::Source).attach(format!("cannot resolve source root: {error}")) + }) } -#[allow(dead_code)] -fn absolute_text<'a>(label: &str, path: &'a Path) -> Result<&'a str, Report> { - path.to_str() - .filter(|text| path.is_absolute() && !text.contains([':', '\0'])) - .ok_or_else(|| { - Report::new(ReleaseError::Environment).attach(format!( - "{label} must be an absolute UTF-8 path without ':' or NUL" - )) - }) +fn names_cohort(root: &Path, cohort: &Path) -> bool { + !cohort.as_os_str().is_empty() + && !cohort + .components() + .any(|component| matches!(component, Component::ParentDir)) + && root + .join(cohort) + .components() + .eq(root.join(policy::COHORT_PATH).components()) } -#[allow(dead_code)] -fn build_child( - audited: &AuditedEnvironment, - commit: &str, - epoch: u64, - request: &ChildRequest, -) -> Result> { - if !lower_hex(commit, 40) { - return Err(Report::new(ReleaseError::Environment) - .attach("approved commit must be 40 lowercase hex digits")); +/// Runs the complete fail-closed preflight over the current-directory source. +/// +/// The qualified selector always runs, so omitting `--require-qualified` +/// can never succeed where passing it would fail. +/// +/// # Errors +/// +/// Fails on any rejected inherited variable, a cohort path other than the +/// source-owned one, unauthenticated source, inputs that differ from the +/// approved commit, or the closed qualified selector. +pub(crate) fn run_preflight( + inherited: &[(OsString, OsString)], + cohort: &Path, + commit: &CommitId, + require_qualified: bool, +) -> Result<(), Report> { + let audited = audit_environment(inherited)?; + if !audited.removed_names().is_empty() { + crate::notice(format_args!( + "removed inherited operator variables: {}", + audited.removed_names().join(", ") + )); } - let mut variables = BTreeMap::from([ - ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), - ( - format!( - "AWS_LC_SYS_USE_SYSTEM_{}", - request.target.triple().replace('-', "_") - ), - "0".to_owned(), - ), - ( - "CARGO_HOME".to_owned(), - absolute_text("CARGO_HOME", &request.cargo_home)?.to_owned(), - ), - ( - "HOME".to_owned(), - absolute_text("HOME", &request.home)?.to_owned(), - ), - ("LC_ALL".to_owned(), "C".to_owned()), - ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), - ("SOURCE_DATE_EPOCH".to_owned(), epoch.to_string()), - ("TRUSTED_SERVER__GIT_VERSION".to_owned(), commit.to_owned()), - (RELEASE_BUILD.to_owned(), "1".to_owned()), - ("TZ".to_owned(), "UTC".to_owned()), - ]); - if !request.tool_directories.is_empty() { - let entries = request - .tool_directories - .iter() - .map(|directory| absolute_text("tool directory", &directory.0)) - .collect::, _>>()?; - variables.insert("PATH".to_owned(), entries.join(":")); + let root = source_root()?; + if !names_cohort(&root, cohort) { + return Err(Report::new(ReleaseError::Policy).attach(format!( + "--cohort must name {} below the current source root", + policy::COHORT_PATH + ))); } - if request.target.is_darwin() { - variables.insert( - "MACOSX_DEPLOYMENT_TARGET".to_owned(), - MACOS_DEPLOYMENT_TARGET.to_owned(), - ); + let source = source::authenticate(&root, commit, &audited)?; + let inputs = policy::load_inputs(&root)?; + for (path, sha256) in inputs.input_digests() { + if source.tracked_sha256(path) != Some(sha256) { + return Err(Report::new(ReleaseError::Source) + .attach(format!("{path} does not match the approved commit"))); + } } - Ok(ChildEnvironment { - variables, - removed: audited.removed.clone(), - }) + crate::notice(format_args!( + "source matched locally; git executable and remote eligibility not established \ + (commit {}, epoch {}, snapshot {}, helper closure {} over {} paths; \ + git executable identity: Task 8; remote branch-tip ancestry and rulesets: Task 14)", + source.commit(), + source.epoch(), + source.snapshot().fingerprint(), + source.closure_digest(), + source.closure_paths().len() + )); + if !require_qualified { + crate::notice(format_args!( + "qualified selection is required whether or not --require-qualified is passed" + )); + } + let selection = policy::select_qualified(&inputs, &[])?; + source.snapshot().verify_unchanged()?; + crate::notice(format_args!("{}", selection.summary())); + Ok(()) } -/// Builds the allowlisted child environment for one recipe execution. -/// -/// Requires both the audited inherited environment and the locally -/// authenticated source, whose commit and committer epoch are injected. -/// -/// # Errors -/// -/// Fails when a private location or tool directory is not an absolute UTF-8 -/// path without `:` or NUL. +// Recipe execution (Tasks 5 and 7) is the only production consumer of the +// child-environment builder; until it lands, only tests reference it. #[allow(dead_code)] -pub(crate) fn child_environment( - audited: &AuditedEnvironment, - source: &ApprovedSource, - request: &ChildRequest, -) -> Result> { - build_child(audited, source.commit(), source.epoch(), request) -} +pub(crate) mod child; #[cfg(test)] mod tests; diff --git a/crates/trusted-server-release/src/preflight/child.rs b/crates/trusted-server-release/src/preflight/child.rs new file mode 100644 index 0000000000..c441c01e8d --- /dev/null +++ b/crates/trusted-server-release/src/preflight/child.rs @@ -0,0 +1,139 @@ +//! Allowlisted child-environment construction for recipe execution. +//! +//! The child environment is built from scratch: nothing inherited passes +//! through, and only recipe-selected facts and private locations are set. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::{AuditedEnvironment, RELEASE_BUILD}; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::source::{ApprovedSource, CommitId}; + +const MACOS_DEPLOYMENT_TARGET: &str = "13.0"; + +/// A tool directory produced by authenticated acquisition (Task 8). +pub(crate) struct ToolDirectory(pub(super) PathBuf); + +/// Recipe-selected private locations for one child build. +pub(crate) struct ChildRequest { + pub(crate) target: ReleaseTarget, + pub(crate) home: PathBuf, + pub(crate) cargo_home: PathBuf, + pub(crate) tool_directories: Vec, +} + +/// An allowlisted child environment constructed from scratch. +pub(crate) struct ChildEnvironment { + variables: BTreeMap, + removed: Vec, +} + +impl ChildEnvironment { + /// Every variable the child receives; nothing is inherited. + pub(crate) fn variables(&self) -> &BTreeMap { + &self.variables + } + + /// Sorted names injected by the recipe, recorded as input facts. + pub(crate) fn injected_names(&self) -> Vec<&str> { + self.variables.keys().map(String::as_str).collect() + } + + /// Inherited operator names removed during the audit. + pub(crate) fn removed_names(&self) -> &[String] { + &self.removed + } +} + +fn absolute_text<'a>(label: &str, path: &'a Path) -> Result<&'a str, Report> { + path.to_str() + .filter(|text| path.is_absolute() && !text.contains([':', '\0'])) + .ok_or_else(|| { + Report::new(ReleaseError::Environment).attach(format!( + "{label} must be an absolute UTF-8 path without ':' or NUL" + )) + }) +} + +/// Joins authenticated tool directories into a child `PATH`, if any are given. +fn tool_path(directories: &[ToolDirectory]) -> Result, Report> { + if directories.is_empty() { + return Ok(None); + } + let entries = directories + .iter() + .map(|directory| absolute_text("tool directory", &directory.0)) + .collect::, _>>()?; + Ok(Some(entries.join(":"))) +} + +/// Builds the child environment from explicit approved facts. +pub(super) fn build_child( + audited: &AuditedEnvironment, + commit: &CommitId, + epoch: u64, + request: &ChildRequest, +) -> Result> { + let mut variables = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), + ( + format!( + "AWS_LC_SYS_USE_SYSTEM_{}", + request.target.triple().replace('-', "_") + ), + "0".to_owned(), + ), + ( + "CARGO_HOME".to_owned(), + absolute_text("CARGO_HOME", &request.cargo_home)?.to_owned(), + ), + ( + "HOME".to_owned(), + absolute_text("HOME", &request.home)?.to_owned(), + ), + ("LC_ALL".to_owned(), "C".to_owned()), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), + ("SOURCE_DATE_EPOCH".to_owned(), epoch.to_string()), + ( + "TRUSTED_SERVER__GIT_VERSION".to_owned(), + commit.as_str().to_owned(), + ), + (RELEASE_BUILD.to_owned(), "1".to_owned()), + ("TZ".to_owned(), "UTC".to_owned()), + ]); + if let Some(path) = tool_path(&request.tool_directories)? { + variables.insert("PATH".to_owned(), path); + } + if request.target.is_darwin() { + variables.insert( + "MACOSX_DEPLOYMENT_TARGET".to_owned(), + MACOS_DEPLOYMENT_TARGET.to_owned(), + ); + } + Ok(ChildEnvironment { + variables, + removed: audited.removed.clone(), + }) +} + +/// Builds the allowlisted child environment for one recipe execution. +/// +/// This is the production entry point: it requires both the audited +/// inherited environment and the locally authenticated source, so the +/// injected commit and committer epoch can only come from authentication. +/// +/// # Errors +/// +/// Fails when a private location or tool directory is not an absolute UTF-8 +/// path without `:` or NUL. +pub(crate) fn child_environment( + audited: &AuditedEnvironment, + source: &ApprovedSource, + request: &ChildRequest, +) -> Result> { + build_child(audited, source.commit(), source.epoch(), request) +} diff --git a/crates/trusted-server-release/src/preflight/tests.rs b/crates/trusted-server-release/src/preflight/tests.rs index def196a376..d05e7e0b16 100644 --- a/crates/trusted-server-release/src/preflight/tests.rs +++ b/crates/trusted-server-release/src/preflight/tests.rs @@ -1,9 +1,17 @@ use std::os::unix::ffi::OsStringExt as _; +use std::collections::BTreeMap; + +use super::child::{ChildRequest, ToolDirectory, build_child}; use super::*; +use crate::policy::ReleaseTarget; const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +fn commit() -> CommitId { + CommitId::parse(COMMIT).expect("should parse fictional commit") +} + fn snapshot(entries: &[(&str, &str)]) -> Vec<(OsString, OsString)> { entries .iter() @@ -334,7 +342,6 @@ fn removes_operator_prefixes_by_full_prefix() { ("EDGEZERO__STORES__CONFIG__ID", "fictional"), ("MY_TRUSTED_SERVER__SETTING", "kept-out"), ("TRUSTED_SERVERX", "kept-out"), - ("trusted_server__lowercase", "kept-out"), ])) .expect("should accept removable operator variables"); assert_eq!( @@ -383,7 +390,7 @@ fn child_environment_contains_exactly_expected_linux_keys() { .expect("should audit benign environment"); let child = build_child( &audited, - COMMIT, + &commit(), 1_700_000_000, &request(ReleaseTarget::X86_64UnknownLinuxGnu), ) @@ -432,8 +439,8 @@ fn child_environment_sets_macos_floor_only_for_darwin() { ReleaseTarget::Aarch64AppleDarwin, ReleaseTarget::X86_64AppleDarwin, ] { - let child = - build_child(&audited, COMMIT, 1, &request(target)).expect("should build darwin child"); + let child = build_child(&audited, &commit(), 1, &request(target)) + .expect("should build darwin child"); assert_eq!( child .variables() @@ -447,7 +454,7 @@ fn child_environment_sets_macos_floor_only_for_darwin() { ReleaseTarget::Wasm32Wasip1, ReleaseTarget::X86_64UnknownLinuxGnu, ] { - let child = build_child(&audited, COMMIT, 1, &request(target)) + let child = build_child(&audited, &commit(), 1, &request(target)) .expect("should build non-darwin child"); assert!( !child.variables().contains_key("MACOSX_DEPLOYMENT_TARGET"), @@ -462,19 +469,21 @@ fn child_environment_rejects_unsafe_paths_and_commits() { let mut relative = request(ReleaseTarget::Wasm32Wasip1); relative.home = PathBuf::from("home"); assert!( - build_child(&audited, COMMIT, 1, &relative).is_err(), + build_child(&audited, &commit(), 1, &relative).is_err(), "should reject relative HOME" ); let mut colon = request(ReleaseTarget::Wasm32Wasip1); colon.tool_directories = vec![ToolDirectory(PathBuf::from("/tools:/usr/bin"))]; assert!( - build_child(&audited, COMMIT, 1, &colon).is_err(), + build_child(&audited, &commit(), 1, &colon).is_err(), "should reject PATH separator inside a tool directory" ); - assert!( - build_child(&audited, "ABC", 1, &request(ReleaseTarget::Wasm32Wasip1)).is_err(), - "should reject a malformed commit" - ); + for malformed in ["ABC", "--help", &COMMIT.to_uppercase(), &COMMIT[..39]] { + assert!( + CommitId::parse(malformed).is_err(), + "should reject malformed commit {malformed}" + ); + } } #[test] @@ -494,7 +503,7 @@ fn child_environment_sets_target_suffixed_aws_lc_selector() { "AWS_LC_SYS_USE_SYSTEM_x86_64_apple_darwin", ), ] { - let child = build_child(&audited, COMMIT, 1, &request(target)) + let child = build_child(&audited, &commit(), 1, &request(target)) .expect("should build child environment"); assert_eq!( child.variables().get(name).map(String::as_str), @@ -512,3 +521,122 @@ fn child_environment_sets_target_suffixed_aws_lc_selector() { ); } } + +#[test] +fn rejects_ambiguous_case_operator_prefixes() { + for name in [ + "trusted_server__lowercase", + "Trusted_Server__Mixed", + "edgezero__stores", + "EdgeZero__Stores", + ] { + let text = rejection(&[(name, "fictional-value")]); + assert!(text.contains(name), "should reject ambiguous casing {name}"); + } +} + +#[test] +fn rejects_package_registry_and_cloud_credentials() { + assert_each_rejected( + "registry-credential", + &[ + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "AWS_REGION", + "AWS_PROFILE", + "AWS_CONFIG_FILE", + "SSH_AUTH_SOCK", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ACCOUNT_ID", + ], + ); +} + +#[test] +fn rejects_hosted_runner_service_endpoints() { + assert_each_rejected( + "runner-service", + &[ + "ACTIONS_CACHE_URL", + "ACTIONS_RESULTS_URL", + "ACTIONS_RUNTIME_URL", + ], + ); +} + +#[test] +fn rejects_shell_startup_injection() { + assert_each_rejected("shell", &["BASH_ENV", "ENV"]); +} + +#[test] +fn rejects_native_toolchain_locators() { + assert_each_rejected( + "native-locator", + &[ + "CXXSTDLIB", + "CXXSTDLIB_x86_64_unknown_linux_gnu", + "TARGET_CXXSTDLIB", + "CROSS_COMPILE", + "LIBCLANG_PATH", + "CLANG_PATH", + "LLVM_CONFIG_PATH", + "PROTOC", + "PROTOC_INCLUDE", + "NASM", + ], + ); +} + +#[test] +fn rejects_node_trust_and_mode_overrides() { + assert_each_rejected( + "node-trust", + &[ + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_ENV", + ], + ); +} + +#[test] +fn reviewed_exceptions_require_a_path_identity() { + let exception = ReviewedException { + name: "VCPKG_INSTALLATION_ROOT", + value: "/fictional/vcpkg", + path_identity: "/fictional/vcpkg", + }; + assert!( + !exception.matches("VCPKG_INSTALLATION_ROOT", "/fictional/vcpkg"), + "should not accept a value whose path identity cannot be resolved" + ); + assert!( + !exception.matches("VCPKG_ROOT", "/fictional/vcpkg"), + "should not accept a different name" + ); +} + +#[test] +fn rejected_exact_names_are_sorted_unique_and_not_covered_elsewhere() { + assert!( + REJECTED_EXACT.windows(2).all(|pair| pair[0] < pair[1]), + "should keep exact names strictly sorted, which also rules out duplicates" + ); + for name in REJECTED_EXACT { + assert!( + !REJECTED_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)), + "{name} is already covered by a rejected prefix" + ); + assert!( + !NATIVE_SELECTORS + .iter() + .any(|selector| is_native_variant(name, selector)), + "{name} is already covered by a native selector" + ); + } +} diff --git a/crates/trusted-server-release/src/source.rs b/crates/trusted-server-release/src/source.rs index b633096a42..02769b6766 100644 --- a/crates/trusted-server-release/src/source.rs +++ b/crates/trusted-server-release/src/source.rs @@ -3,15 +3,13 @@ //! The repository layout and configuration are inspected with bounded direct //! reads before Git runs, because Git itself would execute or redirect through //! several configuration channels. Only read-only plumbing then runs under a -//! cleared environment. Remote branch-tip ancestry and ruleset validation are -//! hosted operations (Task 14) and are not established here. +//! cleared environment, and every object Git returns is rehashed. Remote +//! branch-tip ancestry and ruleset validation are hosted operations (Task 14) +//! and are not established here. use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsString; use std::fs; use std::io; -use std::os::unix::ffi::OsStrExt as _; -use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; use error_stack::Report; @@ -20,12 +18,17 @@ use sha2::{Digest as _, Sha256}; use crate::documents::{Role, lower_hex, parse_json, read_bounded}; use crate::error::ReleaseError; use crate::preflight::AuditedEnvironment; +use closure::helper_closure; +use config::check_git_config; use git::Git; +use tree::{TREE_LIMITS, TreeEntry, walk_tree}; +use worktree::compare_worktree; +mod closure; +mod config; mod git; - -const PROBLEMS_SHOWN: usize = 20; -const RELEASE_CRATE_PREFIX: &str = "crates/trusted-server-release/"; +mod tree; +mod worktree; /// Repository channels that rewrite history, objects or checkout attributes. const FORBIDDEN_GIT_PATHS: [&str; 7] = [ @@ -41,26 +44,35 @@ const FORBIDDEN_GIT_PATHS: [&str; 7] = [ /// Tracked symlinks reviewed for release sources, with their exact targets. const REVIEWED_SYMLINKS: [(&str, &str); 1] = [("CLAUDE.md", "AGENTS.md")]; -/// Untracked coordination paths reviewed for release sources. Empty for now. -const REVIEWED_UNTRACKED: [&str; 0] = []; - -/// Root files always included in the helper-source closure. -const CLOSURE_FILES: [&str; 6] = [ - ".cargo/config.toml", - ".tool-versions", - "Cargo.lock", - "Cargo.toml", - "rust-toolchain.toml", - "scripts/release/bootstrap.sh", -]; - -/// Path-dependency manifests that must be listed as workspace members. -const REQUIRED_MEMBER_MANIFESTS: [&str; 1] = ["crates/trusted-server-openrtb/Cargo.toml"]; - fn source_error(message: impl Into) -> Report { Report::new(ReleaseError::Source).attach(message.into()) } +/// A Git SHA-1 commit id: exactly 40 lowercase hexadecimal digits. +#[derive(Clone, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) struct CommitId(String); + +impl CommitId { + /// Parses an approved commit argument without any normalization. + /// + /// # Errors + /// + /// Fails unless `value` is exactly 40 lowercase hexadecimal digits. + pub(crate) fn parse(value: &str) -> Result> { + if !lower_hex(value, 40) { + return Err(source_error( + "approved source commit must be exactly 40 lowercase hex digits", + )); + } + Ok(Self(value.to_owned())) + } + + /// The commit id text. + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Mode { Regular, @@ -107,7 +119,7 @@ impl SourceSnapshot { /// A locally authenticated source checkout; constructed only by [`authenticate`]. pub(crate) struct ApprovedSource { - commit: String, + commit: CommitId, epoch: u64, snapshot: SourceSnapshot, closure_digest: String, @@ -115,8 +127,8 @@ pub(crate) struct ApprovedSource { } impl ApprovedSource { - /// The approved 40-hex source commit. - pub(crate) fn commit(&self) -> &str { + /// The approved source commit. + pub(crate) fn commit(&self) -> &CommitId { &self.commit } @@ -153,45 +165,33 @@ impl ApprovedSource { /// /// # Errors /// -/// Fails on an extensionless `rust-toolchain`, malformed commit, unsupported -/// repository layout or configuration, a HEAD other than the commit, any -/// tracked-path difference, untracked path, or incomplete helper closure. +/// Fails on an extensionless `rust-toolchain`, an ancestor Cargo +/// configuration layer, unsupported repository layout or configuration, a +/// HEAD other than the commit, any object whose content does not hash to its +/// id, any tracked-path difference, untracked path, or incomplete closure. pub(crate) fn authenticate( root: &Path, - commit: &str, + commit: &CommitId, audited: &AuditedEnvironment, ) -> Result> { - match fs::symlink_metadata(root.join("rust-toolchain")) { - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - _ => { - return Err(source_error( - "extensionless rust-toolchain is rejected before toolchain selection", - )); - } - } - if !lower_hex(commit, 40) { + if !is_absent(&root.join("rust-toolchain"))? { return Err(source_error( - "approved source commit must be exactly 40 lowercase hex digits", + "extensionless rust-toolchain is rejected before toolchain selection", )); } + check_ancestor_cargo_config(root)?; check_layout(root)?; let git = Git::locate(root, audited)?; - let object_type = git.run(&["cat-file", "-t", commit])?; - if object_type != b"commit\n" { - return Err(source_error(format!("{commit} is not a commit object"))); - } - let head = git.run(&["rev-parse", "--verify", "HEAD"])?; - if head.strip_suffix(b"\n") != Some(commit.as_bytes()) { - return Err(source_error("HEAD does not resolve to the approved commit")); - } - let epoch = committer_epoch(&git.run(&["cat-file", "commit", commit])?)?; - let entries = parse_tree(&git.run(&["ls-tree", "-r", "-z", "--full-tree", commit])?)?; + check_head(&git, commit)?; + let commit_object = read_single(&git, "commit", commit.as_str())?; + let epoch = committer_epoch(&commit_object)?; + let entries = walk_tree(&git, &commit_tree(&commit_object)?, &TREE_LIMITS)?; let (records, manifest) = tracked_records(&git, &entries)?; compare_worktree(root, &records)?; let (closure_digest, closure_paths) = helper_closure(&records, &manifest)?; let fingerprint = digest_records(records.iter()); Ok(ApprovedSource { - commit: commit.to_owned(), + commit: commit.clone(), epoch, snapshot: SourceSnapshot { root: root.to_path_buf(), @@ -203,6 +203,38 @@ pub(crate) fn authenticate( }) } +/// Returns whether `path` is absent; any inspection error other than +/// `NotFound` fails closed rather than being treated as absence. +fn is_absent(path: &Path) -> Result> { + match fs::symlink_metadata(path) { + Ok(_) => Ok(false), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(source_error(format!( + "{} cannot be inspected: {error}", + path.display() + ))), + } +} + +/// Cargo merges `.cargo/config(.toml)` from every ancestor of its working +/// directory, so any such layer above the source root is rejected. +fn check_ancestor_cargo_config(root: &Path) -> Result<(), Report> { + let canonical = fs::canonicalize(root) + .map_err(|error| source_error(format!("cannot resolve source root: {error}")))?; + for ancestor in canonical.ancestors().skip(1) { + for name in ["config", "config.toml"] { + let candidate = ancestor.join(".cargo").join(name); + if !is_absent(&candidate)? { + return Err(source_error(format!( + "ancestor Cargo configuration layer {} is rejected", + candidate.display() + ))); + } + } + } + Ok(()) +} + fn check_layout(root: &Path) -> Result<(), Report> { let git_dir = root.join(".git"); let metadata = fs::symlink_metadata(&git_dir) @@ -214,123 +246,49 @@ fn check_layout(root: &Path) -> Result<(), Report> { )); } for relative in FORBIDDEN_GIT_PATHS { - match fs::symlink_metadata(git_dir.join(relative)) { - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Ok(_) => { - return Err(source_error(format!( - ".git/{relative} is present; repository indirection or rewriting is unsupported" - ))); - } - Err(error) => { - return Err(source_error(format!( - ".git/{relative} cannot be inspected: {error}" - ))); - } - } - } - let packed = git_dir.join("packed-refs"); - if fs::symlink_metadata(&packed).is_ok() { - let bytes = read_bounded(&packed, Role::Metadata)?.bytes; - let replaced = bytes.split(|byte| *byte == b'\n').any(|line| { - line.splitn(2, |byte| *byte == b' ') - .nth(1) - .is_some_and(|name| name.starts_with(b"refs/replace/")) - }); - if replaced { - return Err(source_error( - ".git/packed-refs lists refs/replace references", - )); + if !is_absent(&git_dir.join(relative))? { + return Err(source_error(format!( + ".git/{relative} is present; repository indirection or rewriting is unsupported" + ))); } } + check_packed_refs(&git_dir.join("packed-refs"))?; let config = read_bounded(&git_dir.join("config"), Role::Metadata)?; let text = std::str::from_utf8(&config.bytes).map_err(|_| source_error("git config must be UTF-8"))?; check_git_config(text) } -/// Accepts only allowlisted repository configuration keys and values. -/// -/// # Errors -/// -/// Fails on any key outside the allowlist, escapes or continuations, unknown -/// section syntax, or unreviewed values. Values are never echoed. -fn check_git_config(text: &str) -> Result<(), Report> { - let mut section: Option<(String, bool)> = None; - for (index, raw) in text.lines().enumerate() { - let line = raw.trim(); - let number = index + 1; - if line.is_empty() || line.starts_with(['#', ';']) { - continue; - } - if line.contains('\\') { - return Err(source_error(format!( - "git config line {number} uses escapes or continuations" - ))); - } - if let Some(header) = line.strip_prefix('[') { - section = Some(config_section(header, number)?); - continue; - } - let (name, subsection) = section.as_ref().ok_or_else(|| { - source_error(format!("git config line {number} is outside a section")) - })?; - let (key, value) = match line.split_once('=') { - Some((key, value)) => (key.trim().to_ascii_lowercase(), Some(value.trim())), - None => (line.to_ascii_lowercase(), None), - }; - if !config_value_allowed(name, *subsection, &key, value) { - return Err(source_error(format!( - "git config key {name}.{key} on line {number} is not allowlisted or has an unreviewed value" - ))); - } +fn check_packed_refs(packed: &Path) -> Result<(), Report> { + if is_absent(packed)? { + return Ok(()); + } + let bytes = read_bounded(packed, Role::Metadata) + .map_err(|report| report.attach(".git/packed-refs cannot be read"))? + .bytes; + let replaced = bytes.split(|byte| *byte == b'\n').any(|line| { + line.splitn(2, |byte| *byte == b' ') + .nth(1) + .is_some_and(|name| name.starts_with(b"refs/replace/")) + }); + if replaced { + return Err(source_error( + ".git/packed-refs lists refs/replace references", + )); } Ok(()) } -fn config_section(header: &str, number: usize) -> Result<(String, bool), Report> { - let invalid = || { - source_error(format!( - "git config line {number} has an unsupported section" - )) - }; - let header = header.strip_suffix(']').ok_or_else(invalid)?; - let (name, subsection) = match header.split_once(' ') { - Some((name, quoted)) => { - let inner = quoted - .strip_prefix('"') - .and_then(|rest| rest.strip_suffix('"')) - .ok_or_else(invalid)?; - if inner.contains('"') { - return Err(invalid()); - } - (name, true) - } - None => (header, false), - }; - if name.is_empty() - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - { - return Err(invalid()); +fn check_head(git: &Git, commit: &CommitId) -> Result<(), Report> { + let object_type = git.run(&["cat-file", "-t", commit.as_str()], 64)?; + if object_type != b"commit\n" { + return Err(source_error(format!("{commit} is not a commit object"))); } - Ok((name.to_ascii_lowercase(), subsection)) -} - -fn config_value_allowed(section: &str, subsection: bool, key: &str, value: Option<&str>) -> bool { - let boolean = matches!(value, Some("true" | "false")); - let plain = value.is_some_and(|value| !value.is_empty() && !value.contains(['"', '#', ';'])); - match (section, subsection, key) { - ("core", false, "repositoryformatversion") => value == Some("0"), - ("core", false, "bare") => value == Some("false"), - ( - "core", - false, - "filemode" | "logallrefupdates" | "ignorecase" | "precomposeunicode" | "symlinks", - ) => boolean, - ("remote", true, "url" | "fetch") | ("branch", true, "remote" | "merge") => plain, - _ => false, + let head = git.run(&["rev-parse", "--verify", "HEAD"], 128)?; + if head.strip_suffix(b"\n") != Some(commit.as_str().as_bytes()) { + return Err(source_error("HEAD does not resolve to the approved commit")); } + Ok(()) } /// Parses the committer timestamp from a raw commit object header. @@ -360,78 +318,53 @@ fn committer_epoch(raw: &[u8]) -> Result> { .ok_or_else(invalid)?; let zone_valid = zone.len() == 5 && zone.starts_with(['+', '-']) - && zone[1..].bytes().all(|byte| byte.is_ascii_digit()); + && zone[1..].bytes().all(|byte| byte.is_ascii_digit()) + && zone[3..] <= *"59"; if epoch.is_empty() || !epoch.bytes().all(|byte| byte.is_ascii_digit()) || !zone_valid { return Err(invalid()); } epoch.parse().map_err(|_| invalid()) } -struct TreeEntry { - path: String, - mode: Mode, - oid: String, +/// Reads one object of `kind` whose content must hash to `oid`. +fn read_single(git: &Git, kind: &'static str, oid: &str) -> Result, Report> { + let mut found = None; + git.read_objects(kind, &[oid], |_, bytes| { + found = Some(bytes.to_vec()); + Ok(()) + })?; + found.ok_or_else(|| source_error(format!("{kind} {oid} was not returned"))) } -fn parse_tree(output: &[u8]) -> Result, Report> { - let mut entries = Vec::new(); - for record in output - .split(|byte| *byte == 0) - .filter(|record| !record.is_empty()) - { - let text = - std::str::from_utf8(record).map_err(|_| source_error("tracked paths must be UTF-8"))?; - let (meta, path) = text - .split_once('\t') - .ok_or_else(|| source_error("git ls-tree record is malformed"))?; - check_tracked_path(path)?; - let fields: Vec<&str> = meta.split(' ').collect(); - let [mode, kind, oid] = fields.as_slice() else { - return Err(source_error("git ls-tree record is malformed")); - }; - let mode = match (*mode, *kind) { - ("100644", "blob") => Mode::Regular, - ("100755", "blob") => Mode::Executable, - ("120000", "blob") => Mode::Symlink, - ("160000", _) => { - return Err(source_error(format!( - "tracked submodule {path} is not reviewed" - ))); - } - _ => { - return Err(source_error(format!( - "tracked path {path} has unsupported mode {mode}" - ))); - } - }; - if !lower_hex(oid, 40) { - return Err(source_error("git ls-tree object id is malformed")); - } - if mode == Mode::Symlink && !REVIEWED_SYMLINKS.iter().any(|(link, _)| *link == path) { - return Err(source_error(format!( - "tracked symlink {path} is not reviewed" - ))); - } - entries.push(TreeEntry { - path: path.to_owned(), - mode, - oid: (*oid).to_owned(), - }); - } - Ok(entries) +/// Returns the root tree id from a verified raw commit object. +fn commit_tree(raw: &[u8]) -> Result> { + let first = raw.split(|byte| *byte == b'\n').next().unwrap_or_default(); + std::str::from_utf8(first) + .ok() + .and_then(|line| line.strip_prefix("tree ")) + .filter(|oid| lower_hex(oid, 40)) + .map(str::to_owned) + .ok_or_else(|| source_error("commit object must start with a SHA-1 tree line")) } -fn check_tracked_path(path: &str) -> Result<(), Report> { - let unsafe_component = path.split('/').any(|component| { - component.is_empty() - || component == "." - || component == ".." - || component.eq_ignore_ascii_case(".git") - }); - if unsafe_component { - return Err(source_error("tracked path has an unsafe component")); +/// Collision-checked SHA-1 over the concatenation of `parts`. +/// +/// # Errors +/// +/// Fails when SHA-1 collision detection flags the input, including inputs +/// the detector would otherwise silently mitigate. +fn checked_sha1(parts: &[&[u8]]) -> Result> { + let mut hasher = sha1_checked::Sha1::new(); + for part in parts { + sha1_checked::Digest::update(&mut hasher, part); + } + let result = hasher.try_finalize(); + if result.has_collision() { + return Err(source_error( + "SHA-1 collision detection flags a Git object; its identity is not trusted", + )); } - Ok(()) + Ok(hex::encode(result.hash())) } fn tracked_records( @@ -440,7 +373,9 @@ fn tracked_records( ) -> Result<(Records, Vec), Report> { let mut records = Records::new(); let mut manifest = None; - git.read_blobs(entries, |entry, bytes| { + let oids: Vec<&str> = entries.iter().map(|entry| entry.oid.as_str()).collect(); + git.read_objects("blob", &oids, |index, bytes| { + let entry = &entries[index]; if entry.mode == Mode::Symlink { let reviewed = REVIEWED_SYMLINKS .iter() @@ -478,186 +413,6 @@ fn digest_records<'a>(records: impl Iterator Result<(), Report> { - let mut ancestors = BTreeSet::new(); - for path in expected.keys() { - let mut prefix = path.as_str(); - while let Some((parent, _)) = prefix.rsplit_once('/') { - ancestors.insert(parent); - prefix = parent; - } - } - let mut problems = Vec::new(); - let mut seen = BTreeSet::new(); - let mut directories = vec![String::new()]; - while let Some(directory) = directories.pop() { - let absolute = root.join(&directory); - let mut names = fs::read_dir(&absolute) - .and_then(|entries| { - entries - .map(|entry| entry.map(|entry| entry.file_name())) - .collect::>>() - }) - .map_err(|error| source_error(format!("cannot list {directory:?}: {error}")))?; - names.sort(); - for name in names { - let Some(name) = name.to_str() else { - problems.push(format!( - "untracked non-UTF-8 path {}", - name.to_string_lossy() - )); - continue; - }; - let relative = if directory.is_empty() { - name.to_owned() - } else { - format!("{directory}/{name}") - }; - if relative == ".git" { - continue; - } - observe_path( - root, - &relative, - expected, - &ancestors, - &mut directories, - &mut problems, - ); - seen.insert(relative); - } - } - for path in expected.keys().filter(|path| !seen.contains(*path)) { - problems.push(format!("tracked path {path} is missing")); - } - if problems.is_empty() { - return Ok(()); - } - let count = problems.len(); - problems.truncate(PROBLEMS_SHOWN); - Err(source_error(format!( - "worktree differs from the approved commit ({count} problems): {}", - problems.join("; ") - ))) -} - -fn observe_path( - root: &Path, - relative: &str, - expected: &Records, - ancestors: &BTreeSet<&str>, - directories: &mut Vec, - problems: &mut Vec, -) { - let path = root.join(relative); - let metadata = match fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(error) => { - problems.push(format!("{relative} cannot be inspected: {error}")); - return; - } - }; - if metadata.is_dir() && ancestors.contains(relative) { - directories.push(relative.to_owned()); - return; - } - let Some(record) = expected.get(relative) else { - if !REVIEWED_UNTRACKED.contains(&relative) { - problems.push(format!("untracked path {relative}")); - } - return; - }; - let observed = if metadata.file_type().is_symlink() { - fs::read_link(&path) - .map(|target| { - ( - Mode::Symlink, - hex::encode(Sha256::digest(target.as_os_str().as_bytes())), - ) - }) - .map_err(|error| error.to_string()) - } else if metadata.is_file() && metadata.nlink() != 1 { - Err(format!("has {} hard links", metadata.nlink())) - } else if metadata.is_file() { - let mode = if metadata.permissions().mode() & 0o100 == 0 { - Mode::Regular - } else { - Mode::Executable - }; - read_bounded(&path, Role::Executable) - .map(|raw| (mode, raw.sha256)) - .map_err(|report| format!("{report:?}")) - } else { - Err("has an unsupported file type".to_owned()) - }; - match observed { - Ok(observed) if observed == *record => {} - Ok(_) => problems.push(format!( - "{relative} differs from the approved commit in mode, type or content" - )), - Err(reason) => problems.push(format!("{relative} {reason}")), - } -} - -fn helper_closure( - records: &Records, - manifest: &[u8], -) -> Result<(String, Vec), Report> { - let text = - std::str::from_utf8(manifest).map_err(|_| source_error("root Cargo.toml must be UTF-8"))?; - let table: toml::Table = toml::from_str(text) - .map_err(|error| source_error(format!("root Cargo.toml is invalid: {error}")))?; - let members = table - .get("workspace") - .and_then(|workspace| workspace.get("members")) - .and_then(toml::Value::as_array) - .ok_or_else(|| source_error("root Cargo.toml must list [workspace] members"))?; - let mut paths = BTreeSet::new(); - let mut member_manifests = BTreeSet::new(); - for member in members { - let member = member - .as_str() - .ok_or_else(|| source_error("workspace members must be strings"))?; - if member.contains(['*', '?', '[', ']']) { - return Err(source_error(format!( - "workspace member {member} uses a glob; the closure requires explicit members" - ))); - } - let manifest = format!("{member}/Cargo.toml"); - check_tracked_path(&manifest)?; - if !records.contains_key(&manifest) { - return Err(source_error(format!( - "workspace member manifest {manifest} is not tracked" - ))); - } - member_manifests.insert(manifest); - } - for required in REQUIRED_MEMBER_MANIFESTS { - if !member_manifests.contains(required) { - return Err(source_error(format!( - "{required} must be a listed workspace member manifest" - ))); - } - } - for path in CLOSURE_FILES { - if !records.contains_key(path) { - return Err(source_error(format!( - "helper closure file {path} is not tracked" - ))); - } - paths.insert(path.to_owned()); - } - paths.extend(member_manifests); - paths.extend( - records - .keys() - .filter(|path| path.starts_with(RELEASE_CRATE_PREFIX)) - .cloned(), - ); - let digest = digest_records(paths.iter().filter_map(|path| records.get_key_value(path))); - Ok((digest, paths.into_iter().collect())) -} - /// Joins a JS input inventory to the approved source commit. /// /// # Errors @@ -675,15 +430,14 @@ pub(crate) fn join_inventory( .as_object() .ok_or_else(|| source_error("inventory must be an object"))?; let keys: BTreeSet<&str> = object.keys().map(String::as_str).collect(); - if keys - != BTreeSet::from([ - "materials", - "modules", - "schema_version", - "source_commit", - "tools", - ]) - { + let reviewed = BTreeSet::from([ + "materials", + "modules", + "schema_version", + "source_commit", + "tools", + ]); + if keys != reviewed { return Err(source_error( "inventory must contain exactly the reviewed fields", )); @@ -697,7 +451,7 @@ pub(crate) fn join_inventory( )); } match object["source_commit"].as_str() { - Some(commit) if commit == source.commit => Ok(()), + Some(commit) if commit == source.commit.as_str() => Ok(()), _ => Err(source_error( "inventory source_commit does not match the approved commit", )), diff --git a/crates/trusted-server-release/src/source/closure.rs b/crates/trusted-server-release/src/source/closure.rs new file mode 100644 index 0000000000..6e17905184 --- /dev/null +++ b/crates/trusted-server-release/src/source/closure.rs @@ -0,0 +1,105 @@ +//! Audit fingerprint of the helper-source closure at the approved commit. + +use std::collections::BTreeSet; + +use error_stack::Report; + +use super::tree::check_tracked_path; +use super::{Records, digest_records, source_error}; +use crate::error::ReleaseError; + +const RELEASE_CRATE_PREFIX: &str = "crates/trusted-server-release/"; + +/// Root files always included in the helper-source closure. +const CLOSURE_FILES: [&str; 6] = [ + ".cargo/config.toml", + ".tool-versions", + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", + "scripts/release/bootstrap.sh", +]; + +/// Path-dependency manifests that must be listed as workspace members. +const REQUIRED_MEMBER_MANIFESTS: [&str; 1] = ["crates/trusted-server-openrtb/Cargo.toml"]; + +/// Digests the release crate, root build inputs and every member manifest. +/// +/// Returns the digest and the sorted covered paths. This is an audit +/// fingerprint, not authority. +pub(super) fn helper_closure( + records: &Records, + manifest: &[u8], +) -> Result<(String, Vec), Report> { + let mut paths = member_manifests(records, &workspace_members(manifest)?)?; + for path in CLOSURE_FILES { + if !records.contains_key(path) { + return Err(source_error(format!( + "helper closure file {path} is not tracked" + ))); + } + paths.insert(path.to_owned()); + } + paths.extend( + records + .keys() + .filter(|path| path.starts_with(RELEASE_CRATE_PREFIX)) + .cloned(), + ); + let digest = digest_records(paths.iter().filter_map(|path| records.get_key_value(path))); + Ok((digest, paths.into_iter().collect())) +} + +/// Explicit `[workspace] members` from the tracked root manifest. +fn workspace_members(manifest: &[u8]) -> Result, Report> { + let text = + std::str::from_utf8(manifest).map_err(|_| source_error("root Cargo.toml must be UTF-8"))?; + let table: toml::Table = toml::from_str(text) + .map_err(|error| source_error(format!("root Cargo.toml is invalid: {error}")))?; + let members = table + .get("workspace") + .and_then(|workspace| workspace.get("members")) + .and_then(toml::Value::as_array) + .ok_or_else(|| source_error("root Cargo.toml must list [workspace] members"))?; + members + .iter() + .map(|member| { + let member = member + .as_str() + .ok_or_else(|| source_error("workspace members must be strings"))?; + if member.contains(['*', '?', '[', ']']) { + return Err(source_error(format!( + "workspace member {member} uses a glob; the closure requires explicit members" + ))); + } + Ok(member.to_owned()) + }) + .collect() +} + +/// Tracked manifests for every member, including the required path dependencies. +fn member_manifests( + records: &Records, + members: &[String], +) -> Result, Report> { + let mut manifests = BTreeSet::new(); + for member in members { + let manifest = format!("{member}/Cargo.toml"); + check_tracked_path(&manifest)?; + if !records.contains_key(&manifest) { + return Err(source_error(format!( + "workspace member manifest {manifest} is not tracked" + ))); + } + manifests.insert(manifest); + } + if let Some(required) = REQUIRED_MEMBER_MANIFESTS + .iter() + .find(|required| !manifests.contains(**required)) + { + return Err(source_error(format!( + "{required} must be a listed workspace member manifest" + ))); + } + Ok(manifests) +} diff --git a/crates/trusted-server-release/src/source/config.rs b/crates/trusted-server-release/src/source/config.rs new file mode 100644 index 0000000000..4a987f6d89 --- /dev/null +++ b/crates/trusted-server-release/src/source/config.rs @@ -0,0 +1,95 @@ +//! Strict allowlist parser for the repository's `.git/config`. +//! +//! Git would execute or redirect through many configuration keys, so only a +//! small set of inert keys and values is accepted. Values are never echoed +//! because remote URLs may embed credentials. + +use error_stack::Report; + +use super::source_error; +use crate::error::ReleaseError; + +/// Accepts only allowlisted repository configuration keys and values. +/// +/// # Errors +/// +/// Fails on any key outside the allowlist, escapes or continuations, unknown +/// section syntax, or unreviewed values. +pub(super) fn check_git_config(text: &str) -> Result<(), Report> { + let mut section: Option<(String, bool)> = None; + for (index, raw) in text.lines().enumerate() { + let line = raw.trim(); + let number = index + 1; + if line.is_empty() || line.starts_with(['#', ';']) { + continue; + } + if line.contains('\\') { + return Err(source_error(format!( + "git config line {number} uses escapes or continuations" + ))); + } + if let Some(header) = line.strip_prefix('[') { + section = Some(config_section(header, number)?); + continue; + } + let (name, subsection) = section.as_ref().ok_or_else(|| { + source_error(format!("git config line {number} is outside a section")) + })?; + let (key, value) = match line.split_once('=') { + Some((key, value)) => (key.trim().to_ascii_lowercase(), Some(value.trim())), + None => (line.to_ascii_lowercase(), None), + }; + if !config_value_allowed(name, *subsection, &key, value) { + return Err(source_error(format!( + "git config key {name}.{key} on line {number} is not allowlisted or has an unreviewed value" + ))); + } + } + Ok(()) +} + +fn config_section(header: &str, number: usize) -> Result<(String, bool), Report> { + let invalid = || { + source_error(format!( + "git config line {number} has an unsupported section" + )) + }; + let header = header.strip_suffix(']').ok_or_else(invalid)?; + let (name, subsection) = match header.split_once(' ') { + Some((name, quoted)) => { + let inner = quoted + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .ok_or_else(invalid)?; + if inner.contains('"') { + return Err(invalid()); + } + (name, true) + } + None => (header, false), + }; + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(invalid()); + } + Ok((name.to_ascii_lowercase(), subsection)) +} + +fn config_value_allowed(section: &str, subsection: bool, key: &str, value: Option<&str>) -> bool { + let boolean = matches!(value, Some("true" | "false")); + let plain = value.is_some_and(|value| !value.is_empty() && !value.contains(['"', '#', ';'])); + match (section, subsection, key) { + ("core", false, "repositoryformatversion") => value == Some("0"), + ("core", false, "bare") => value == Some("false"), + ( + "core", + false, + "filemode" | "logallrefupdates" | "ignorecase" | "precomposeunicode" | "symlinks", + ) => boolean, + ("remote", true, "url" | "fetch") | ("branch", true, "remote" | "merge") => plain, + _ => false, + } +} diff --git a/crates/trusted-server-release/src/source/git.rs b/crates/trusted-server-release/src/source/git.rs index 8ca02475dd..1d1b638f3f 100644 --- a/crates/trusted-server-release/src/source/git.rs +++ b/crates/trusted-server-release/src/source/git.rs @@ -1,5 +1,6 @@ //! Bounded read-only Git plumbing under a cleared, hardened environment. +use std::cell::Cell; use std::ffi::OsString; use std::fs; use std::io::{self, BufRead, BufReader, Read, Write as _}; @@ -10,21 +11,28 @@ use std::thread; use error_stack::Report; -use super::{TreeEntry, source_error}; +use super::{checked_sha1, source_error}; use crate::error::ReleaseError; use crate::preflight::AuditedEnvironment; -const STDOUT_LIMIT: u64 = 64 * 1024 * 1024; const STDERR_LIMIT: u64 = 64 * 1024; const STDERR_SHOWN: usize = 4 * 1024; -const BLOB_LIMIT: u64 = 64 * 1024 * 1024; +/// Largest single object read; far above any tracked source file. +const OBJECT_LIMIT: u64 = 64 * 1024 * 1024; +/// Total bytes of all objects read in one authentication. The tracked tree is +/// currently tens of MiB; 2 GiB leaves ample growth while bounding the work a +/// hostile repository can cause, since objects are held one at a time. +const AGGREGATE_OBJECT_BUDGET: u64 = 2 * 1024 * 1024 * 1024; /// Read-only Git plumbing under a cleared, hardened environment. pub(super) struct Git { program: PathBuf, - search_path: String, + /// Child `PATH`: only the resolved git executable's own directory. + child_path: String, root: PathBuf, home: tempfile::TempDir, + /// Remaining bytes of the aggregate object budget. + object_budget: Cell, } impl Git { @@ -45,15 +53,21 @@ impl Git { }) }) .ok_or_else(|| source_error("git was not found on an absolute PATH entry"))?; + let child_path = program + .parent() + .and_then(Path::to_str) + .ok_or_else(|| source_error("git directory must be UTF-8"))? + .to_owned(); let home = tempfile::Builder::new() .prefix("release-git-home-") .tempdir() .map_err(|error| source_error(format!("cannot create private git HOME: {error}")))?; Ok(Self { program, - search_path: search_path.to_owned(), + child_path, root: root.to_path_buf(), home, + object_budget: Cell::new(AGGREGATE_OBJECT_BUDGET), }) } @@ -80,7 +94,7 @@ impl Git { .args(args) .current_dir(&self.root) .env_clear() - .env("PATH", &self.search_path) + .env("PATH", &self.child_path) .env("HOME", self.home.path()) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") @@ -104,115 +118,159 @@ impl Git { .map_err(|error| source_error(format!("cannot run git {}: {error}", args.join(" ")))) } - pub(super) fn run(&self, args: &[&str]) -> Result, Report> { + /// Replaces the aggregate object budget so tests can exercise exhaustion. + #[cfg(test)] + pub(super) fn with_object_budget(self, bytes: u64) -> Self { + self.object_budget.set(bytes); + self + } + + /// Runs one plumbing command whose stdout must fit `stdout_limit` bytes. + pub(super) fn run( + &self, + args: &[&str], + stdout_limit: u64, + ) -> Result, Report> { let mut child = self.spawn(args, false)?; let stderr = capture_stderr(&mut child); let mut output = Vec::new(); let read = match child.stdout.take() { Some(stdout) => stdout - .take(STDOUT_LIMIT + 1) + .take(stdout_limit + 1) .read_to_end(&mut output) - .map_err(|error| error.to_string()), - None => Err("stdout is unavailable".to_owned()), + .map(|_| ()) + .map_err(|error| source_error(format!("cannot read stdout: {error}"))), + None => Err(source_error("stdout is unavailable")), }; - let oversized = output.len() as u64 > STDOUT_LIMIT; - finish( - child, - stderr, - args, - read.and_then(|_| { - if oversized { - Err("stdout exceeds its byte limit".to_owned()) - } else { - Ok(()) - } - }), - )?; + let read = read.and_then(|()| { + if output.len() as u64 > stdout_limit { + Err(source_error("stdout exceeds its byte limit")) + } else { + Ok(()) + } + }); + finish(child, stderr, args, read)?; Ok(output) } - pub(super) fn read_blobs( + /// Reads objects of one `kind` through `cat-file --batch`, rehashing each. + /// + /// Every returned object must hash to its requested id as + /// `sha1(" \0" + bytes)` with collision detection; Git's + /// loose-object reads do not verify this themselves. + pub(super) fn read_objects( &self, - entries: &[TreeEntry], - mut visit: impl FnMut(&TreeEntry, &[u8]) -> Result<(), Report>, + kind: &'static str, + oids: &[&str], + mut visit: impl FnMut(usize, &[u8]) -> Result<(), Report>, ) -> Result<(), Report> { let args = ["cat-file", "--batch"]; let mut child = self.spawn(&args, true)?; let stderr = capture_stderr(&mut child); - let request: Vec = entries + let request: Vec = oids .iter() - .flat_map(|entry| entry.oid.bytes().chain(std::iter::once(b'\n'))) + .flat_map(|oid| oid.bytes().chain(std::iter::once(b'\n'))) .collect(); let writer = child .stdin .take() .map(|mut stdin| thread::spawn(move || stdin.write_all(&request))); - let mut visited = Ok(()); let read = match child.stdout.take() { - Some(stdout) => { - let mut reader = BufReader::new(stdout); - read_batch(&mut reader, entries, &mut |entry, bytes| { - visited = visit(entry, bytes); - visited.is_ok() - }) - } - None => Err("stdout is unavailable".to_owned()), + Some(stdout) => read_batch( + &mut BufReader::new(stdout), + &Batch { + kind, + oids, + budget: &self.object_budget, + }, + &mut visit, + ), + None => Err(source_error("stdout is unavailable")), }; if read.is_err() { let _ = child.kill(); } let written = match writer.map(thread::JoinHandle::join) { Some(Ok(Ok(()))) => Ok(()), - Some(Ok(Err(error))) => Err(error.to_string()), - Some(Err(_)) => Err("stdin writer panicked".to_owned()), - None => Err("stdin is unavailable".to_owned()), + Some(Ok(Err(error))) => Err(source_error(format!("cannot write stdin: {error}"))), + Some(Err(_)) => Err(source_error("stdin writer panicked")), + None => Err(source_error("stdin is unavailable")), }; - let finished = finish(child, stderr, &args, read.and(written)); - visited?; - finished + finish(child, stderr, &args, read.and(written)) } } +/// Collision-checked Git SHA-1 object id of `bytes` stored as an object of `kind`. +fn object_id(kind: &str, bytes: &[u8]) -> Result> { + let header = format!("{kind} {}\0", bytes.len()); + checked_sha1(&[header.as_bytes(), bytes]) +} + +/// One `cat-file --batch` request and the shared aggregate byte budget. +struct Batch<'a> { + kind: &'a str, + oids: &'a [&'a str], + budget: &'a Cell, +} + fn read_batch( reader: &mut impl BufRead, - entries: &[TreeEntry], - visit: &mut impl FnMut(&TreeEntry, &[u8]) -> bool, -) -> Result<(), String> { - for entry in entries { - let mut header = Vec::new(); - reader - .take(128) - .read_until(b'\n', &mut header) - .map_err(|error| error.to_string())?; - let header = std::str::from_utf8(&header).map_err(|_| "batch header is not UTF-8")?; - let fields: Vec<&str> = header.trim_end_matches('\n').split(' ').collect(); - let size = match fields.as_slice() { - [oid, "blob", size] if *oid == entry.oid => size - .parse::() - .map_err(|_| "batch blob size is malformed".to_owned())?, - _ => return Err(format!("object for {} is not a readable blob", entry.path)), - }; - if size > BLOB_LIMIT { - return Err(format!( - "{} exceeds the tracked blob byte limit", - entry.path - )); + batch: &Batch<'_>, + visit: &mut impl FnMut(usize, &[u8]) -> Result<(), Report>, +) -> Result<(), Report> { + let io_error = |error: io::Error| source_error(format!("cannot read batch output: {error}")); + let kind = batch.kind; + for (index, oid) in batch.oids.iter().enumerate() { + let size = read_header(reader, kind, oid)?; + let remaining = batch.budget.get(); + if size > OBJECT_LIMIT || size > remaining { + return Err(source_error(format!( + "object {oid} exceeds the per-object or aggregate object byte budget" + ))); } - let mut bytes = vec![0; usize::try_from(size).map_err(|_| "blob size overflows")?]; - reader - .read_exact(&mut bytes) - .map_err(|error| error.to_string())?; + batch.budget.set(remaining - size); + let length = usize::try_from(size).map_err(|_| source_error("object size overflows"))?; + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).map_err(io_error)?; let mut terminator = [0]; - reader - .read_exact(&mut terminator) - .map_err(|error| error.to_string())?; - if terminator != *b"\n" || !visit(entry, &bytes) { - return Err(format!("batch output for {} is rejected", entry.path)); + reader.read_exact(&mut terminator).map_err(io_error)?; + if terminator != *b"\n" { + return Err(source_error(format!("batch output for {oid} is malformed"))); + } + if object_id(kind, &bytes)? != *oid { + return Err(source_error(format!( + "object identity mismatch: {kind} {oid} content does not hash to its id" + ))); } + visit(index, &bytes)?; } Ok(()) } +/// Reads one ` ` header and returns the object size. +fn read_header( + reader: &mut impl BufRead, + kind: &str, + oid: &str, +) -> Result> { + let mut header = Vec::new(); + reader + .take(128) + .read_until(b'\n', &mut header) + .map_err(|error| source_error(format!("cannot read batch header: {error}")))?; + let header = + std::str::from_utf8(&header).map_err(|_| source_error("batch header is not UTF-8"))?; + let fields: Vec<&str> = header.trim_end_matches('\n').split(' ').collect(); + match fields.as_slice() { + [returned, returned_kind, size] if *returned == oid && *returned_kind == kind => size + .parse::() + .map_err(|_| source_error("batch object size is malformed")), + _ => Err(source_error(format!( + "object {oid} is not a readable {kind}" + ))), + } +} + type StderrCapture = Option>>; fn capture_stderr(child: &mut Child) -> StderrCapture { @@ -227,22 +285,34 @@ fn capture_stderr(child: &mut Child) -> StderrCapture { }) } +/// Reaps the child and attaches its bounded stderr to any failure. fn finish( mut child: Child, stderr: StderrCapture, args: &[&str], - read: Result<(), String>, + read: Result<(), Report>, ) -> Result<(), Report> { if read.is_err() { let _ = child.kill(); } + // Task 7 bounded process module replaces this unbounded wait. let status = child .wait() .map_err(|error| source_error(format!("cannot wait for git: {error}")))?; + let detail = bounded_detail(stderr); + let context = format!("git {}; stderr: {detail}", args.join(" ")); + read.map_err(|report| report.attach(context.clone()))?; + if !status.success() { + return Err(source_error(format!("{context}; exit {status}"))); + } + Ok(()) +} + +fn bounded_detail(stderr: StderrCapture) -> String { let detail = stderr .and_then(|handle| handle.join().ok()) .unwrap_or_default(); - let mut detail = String::from_utf8_lossy(&detail).into_owned(); + let mut detail = String::from_utf8_lossy(&detail).trim().to_owned(); if detail.len() > STDERR_SHOWN { let mut cut = STDERR_SHOWN; while !detail.is_char_boundary(cut) { @@ -251,18 +321,5 @@ fn finish( detail.truncate(cut); detail.push_str(" [truncated]"); } - let command = format!("git {}", args.join(" ")); - if let Err(reason) = read { - return Err(source_error(format!( - "{command} output is rejected: {reason}; stderr: {}", - detail.trim() - ))); - } - if !status.success() { - return Err(source_error(format!( - "{command} failed with {status}; stderr: {}", - detail.trim() - ))); - } - Ok(()) + detail } diff --git a/crates/trusted-server-release/src/source/tests.rs b/crates/trusted-server-release/src/source/tests.rs index ead2848fa8..531c95c924 100644 --- a/crates/trusted-server-release/src/source/tests.rs +++ b/crates/trusted-server-release/src/source/tests.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fs; use std::os::unix::fs::{PermissionsExt as _, symlink}; use std::process::Command; @@ -5,9 +6,13 @@ use std::process::Command; use serde_json::{Value, json}; use tempfile::{TempDir, tempdir}; +use super::config::check_git_config; +use super::git::Git; +use super::tree::{TreeLimits, parse_tree_object}; use super::*; use crate::policy::ReleaseTarget; -use crate::preflight::{ChildRequest, audit_environment, child_environment}; +use crate::preflight::audit_environment; +use crate::preflight::child::{ChildRequest, child_environment}; const EPOCH: u64 = 1_700_000_000; @@ -17,6 +22,22 @@ struct Fixture { commit: String, } +/// Fixtures fail preflight if any temporary-directory ancestor holds a Cargo +/// configuration layer, so report that host precondition explicitly. +fn assert_clean_ancestry(path: &Path) { + let canonical = fs::canonicalize(path).expect("should resolve fixture directory"); + for ancestor in canonical.ancestors() { + for name in ["config", "config.toml"] { + let layer = ancestor.join(".cargo").join(name); + assert!( + !layer.exists(), + "fixture temporary directory ancestry must not contain {}; set TMPDIR to a clean directory", + layer.display() + ); + } + } +} + fn host_path() -> OsString { std::env::var_os("PATH").expect("should have a PATH to locate git") } @@ -59,6 +80,7 @@ fn write(root: &Path, path: &str, bytes: &[u8]) { impl Fixture { fn new() -> Self { let parent = tempdir().expect("should create fixture parent"); + assert_clean_ancestry(parent.path()); let root = parent.path().join("source"); fs::create_dir(&root).expect("should create fixture root"); write( @@ -134,7 +156,7 @@ impl Fixture { } fn authenticate(&self) -> Result> { - authenticate(&self.root, &self.commit, &self.audited()) + approve(&self.root, &self.commit, &self.audited()) } fn rejection(&self) -> String { @@ -147,6 +169,15 @@ impl Fixture { } } +/// Parses `commit` like the CLI does, then authenticates `root`. +fn approve( + root: &Path, + commit: &str, + audited: &AuditedEnvironment, +) -> Result> { + authenticate(root, &CommitId::parse(commit)?, audited) +} + fn assert_rejects(fixture: &Fixture, needle: &str) { let text = fixture.rejection(); assert!( @@ -155,521 +186,7 @@ fn assert_rejects(fixture: &Fixture, needle: &str) { ); } -#[test] -fn authenticates_clean_fixture_with_commit_epoch_and_closure() { - let fixture = Fixture::new(); - let source = fixture - .authenticate() - .expect("should authenticate clean fixture"); - assert_eq!( - source.commit(), - fixture.commit, - "should keep approved commit" - ); - assert_eq!(source.epoch(), EPOCH, "should derive committer epoch"); - assert!( - lower_hex(source.snapshot().fingerprint(), 64), - "should fingerprint the tracked snapshot" - ); - assert!( - lower_hex(source.closure_digest(), 64), - "should digest closure" - ); - assert_eq!( - source.closure_paths(), - [ - ".cargo/config.toml", - ".tool-versions", - "Cargo.lock", - "Cargo.toml", - "crates/trusted-server-openrtb/Cargo.toml", - "crates/trusted-server-release/Cargo.toml", - "crates/trusted-server-release/src/main.rs", - "rust-toolchain.toml", - "scripts/release/bootstrap.sh", - ], - "should record the complete helper-source closure" - ); - assert_eq!( - source.tracked_sha256("AGENTS.md"), - Some(hex::encode(Sha256::digest(b"# Fictional agents\n")).as_str()), - "should expose tracked content digests" - ); - source - .snapshot() - .verify_unchanged() - .expect("should verify unchanged worktree"); -} - -#[test] -fn rejects_malformed_commit_arguments() { - let fixture = Fixture::new(); - for commit in [ - "ABC", - "--help", - &fixture.commit.to_uppercase(), - &fixture.commit[..39], - ] { - assert!( - authenticate(&fixture.root, commit, &fixture.audited()).is_err(), - "should reject commit argument {commit}" - ); - } -} - -#[test] -fn rejects_unknown_commit_with_bounded_git_detail() { - let fixture = Fixture::new(); - let text = format!( - "{:?}", - authenticate(&fixture.root, &"0".repeat(40), &fixture.audited()) - .err() - .expect("should reject unknown commit") - ); - assert!( - text.contains("git cat-file"), - "should name the failing git step: {text}" - ); -} - -#[test] -fn rejects_non_commit_objects() { - let fixture = Fixture::new(); - let tree = fixture.git(&["rev-parse", "HEAD^{tree}"]); - let text = format!( - "{:?}", - authenticate(&fixture.root, &tree, &fixture.audited()) - .err() - .expect("should reject tree object") - ); - assert!(text.contains("not a commit"), "should reject tree: {text}"); -} - -#[test] -fn rejects_head_that_differs_from_approved_commit() { - let mut fixture = Fixture::new(); - let approved = fixture.commit.clone(); - write(&fixture.root, "AGENTS.md", b"# Changed\n"); - fixture.commit_all(); - let text = format!( - "{:?}", - authenticate(&fixture.root, &approved, &fixture.audited()) - .err() - .expect("should reject HEAD mismatch") - ); - assert!( - text.contains("HEAD"), - "should explain HEAD mismatch: {text}" - ); -} - -#[test] -fn rejects_extensionless_rust_toolchain_before_other_checks() { - let fixture = Fixture::new(); - write(&fixture.root, "rust-toolchain", b"nightly\n"); - write( - &fixture.root, - ".git/objects/info/alternates", - b"/elsewhere\n", - ); - assert_rejects(&fixture, "rust-toolchain"); -} - -#[test] -fn rejects_linked_worktree_gitfile_indirection() { - let fixture = Fixture::new(); - fs::rename( - fixture.root.join(".git"), - fixture.parent.path().join("moved-git"), - ) - .expect("should move git directory"); - write(&fixture.root, ".git", b"gitdir: ../moved-git\n"); - assert_rejects(&fixture, "unsupported"); -} - -#[test] -fn rejects_commondir_indirection() { - let fixture = Fixture::new(); - write(&fixture.root, ".git/commondir", b"../other\n"); - assert_rejects(&fixture, "commondir"); -} - -#[test] -fn rejects_object_and_history_rewriting_channels() { - for (path, needle) in [ - ("objects/info/alternates", ".git/objects/info/alternates"), - ( - "objects/info/http-alternates", - ".git/objects/info/http-alternates", - ), - ("info/grafts", ".git/info/grafts"), - ("shallow", ".git/shallow"), - ("info/attributes", ".git/info/attributes"), - ( - "refs/replace/0000000000000000000000000000000000000000", - ".git/refs/replace", - ), - ] { - let fixture = Fixture::new(); - write(&fixture.root.join(".git"), path, b"fictional\n"); - assert_rejects(&fixture, needle); - } -} - -#[test] -fn rejects_packed_replace_refs() { - let fixture = Fixture::new(); - let line = format!( - "# pack-refs with: peeled fully-peeled sorted\n{} refs/replace/{}\n", - fixture.commit, fixture.commit - ); - write(&fixture.root, ".git/packed-refs", line.as_bytes()); - assert_rejects(&fixture, "refs/replace"); -} - -#[test] -fn rejects_unreviewed_git_config_keys() { - for addition in [ - "[include]\n\tpath = /dev/null\n", - "[includeIf \"gitdir:/\"]\n\tpath = /dev/null\n", - "[core]\n\tfsmonitor = true\n", - "[core]\n\thooksPath = /dev/null\n", - "[core]\n\tsshCommand = ssh\n", - "[core]\n\tworktree = /\n", - "[filter \"lfs\"]\n\tclean = cat\n", - "[diff \"x\"]\n\ttextconv = cat\n", - "[extensions]\n\tobjectformat = sha256\n", - "[user]\n\tname = Fictional\n", - ] { - let fixture = Fixture::new(); - let config = fixture.root.join(".git/config"); - let mut text = fs::read_to_string(&config).expect("should read fixture config"); - text.push_str(addition); - fs::write(&config, text).expect("should write fixture config"); - assert_rejects(&fixture, "git config"); - } -} - -#[test] -fn parses_only_allowlisted_config_values() { - assert!( - check_git_config("[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://example.com/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n").is_ok(), - "should accept allowlisted keys" - ); - for text in [ - "[core]\n\trepositoryformatversion = 1\n", - "[core]\n\tbare = true\n", - "[core]\n\tfilemode = maybe\n", - "[core]\n\tsymlinks = \"true\"\n", - "[core]\n\tfilemode = true \\\n", - "[remote \"origin\"]\n\tpushurl = https://example.com\n", - "[core.x]\n\tfilemode = true\n", - "\tfilemode = true\n", - ] { - assert!( - check_git_config(text).is_err(), - "should reject config {text:?}" - ); - } -} - -#[test] -fn rejects_fsmonitor_without_executing_it() { - let fixture = Fixture::new(); - let marker = fixture.parent.path().join("fsmonitor-ran"); - let hook = fixture.parent.path().join("fsmonitor.sh"); - fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display())) - .expect("should write hook"); - fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).expect("should chmod hook"); - let config = fixture.root.join(".git/config"); - let mut text = fs::read_to_string(&config).expect("should read config"); - text.push_str(&format!("[core]\n\tfsmonitor = {}\n", hook.display())); - fs::write(&config, text).expect("should write config"); - assert_rejects(&fixture, "git config"); - assert!( - !marker.exists(), - "should never execute the fsmonitor command" - ); -} - -#[test] -fn rejects_modified_tracked_file() { - let fixture = Fixture::new(); - write(&fixture.root, "AGENTS.md", b"# Modified\n"); - assert_rejects(&fixture, "AGENTS.md"); -} - -#[test] -fn rejects_changed_executable_bit() { - let fixture = Fixture::new(); - fs::set_permissions( - fixture.root.join("scripts/release/bootstrap.sh"), - fs::Permissions::from_mode(0o644), - ) - .expect("should chmod"); - assert_rejects(&fixture, "scripts/release/bootstrap.sh"); -} - -#[test] -fn rejects_file_replaced_by_symlink() { - let fixture = Fixture::new(); - let outside = fixture.parent.path().join("outside.md"); - fs::write(&outside, b"# Fictional agents\n").expect("should write outside file"); - fs::remove_file(fixture.root.join("AGENTS.md")).expect("should remove tracked file"); - symlink(&outside, fixture.root.join("AGENTS.md")).expect("should create symlink"); - assert_rejects(&fixture, "AGENTS.md"); -} - -#[test] -fn rejects_preexisting_hard_link() { - let fixture = Fixture::new(); - fs::hard_link( - fixture.root.join("Cargo.lock"), - fixture.parent.path().join("lock-link"), - ) - .expect("should create hard link outside source"); - assert_rejects(&fixture, "Cargo.lock"); -} - -#[test] -fn rejects_missing_tracked_file() { - let fixture = Fixture::new(); - fs::remove_file(fixture.root.join("crates/other/src/lib.rs")).expect("should remove file"); - assert_rejects(&fixture, "crates/other/src/lib.rs"); -} - -#[test] -fn rejects_untracked_and_ignored_paths() { - for (path, needle) in [ - (".DS_Store", ".DS_Store"), - ( - "crates/trusted-server-js/lib/node_modules/x/index.js", - "crates/trusted-server-js", - ), - ( - "crates/other/src/extra_test.rs", - "crates/other/src/extra_test.rs", - ), - ("target/debug/fictional", "target"), - ] { - let mut fixture = Fixture::new(); - write( - &fixture.root, - ".gitignore", - b".DS_Store\nnode_modules\ntarget\n*_test.rs\n", - ); - fixture.commit_all(); - write(&fixture.root, path, b"untracked\n"); - assert_rejects(&fixture, needle); - } -} - -#[test] -fn rejects_empty_untracked_directory() { - let fixture = Fixture::new(); - fs::create_dir(fixture.root.join("empty")).expect("should create directory"); - assert_rejects(&fixture, "empty"); -} - -#[test] -fn rejects_unreviewed_tracked_symlink() { - let mut fixture = Fixture::new(); - symlink("Cargo.toml", fixture.root.join("manifest-link")).expect("should create symlink"); - fixture.commit_all(); - assert_rejects(&fixture, "manifest-link"); -} - -#[test] -fn rejects_tracked_submodule() { - let mut fixture = Fixture::new(); - let gitlink = format!("160000,{},vendor/module", fixture.commit); - fixture.git(&["update-index", "--add", "--cacheinfo", &gitlink]); - fixture.git(&["commit", "-q", "-m", "submodule"]); - fixture.commit = fixture.git(&["rev-parse", "HEAD"]); - assert_rejects(&fixture, "vendor/module"); -} - -type Mutation = fn(&Fixture); - -#[test] -fn verify_unchanged_detects_mutations_after_snapshot() { - let mutations: [(&str, Mutation); 5] = [ - ("content", |fixture| { - write(&fixture.root, "AGENTS.md", b"# Changed\n") - }), - ("mode", |fixture| { - fs::set_permissions( - fixture.root.join("Cargo.lock"), - fs::Permissions::from_mode(0o755), - ) - .expect("should chmod"); - }), - ("symlink", |fixture| { - fs::remove_file(fixture.root.join("Cargo.lock")).expect("should remove"); - symlink("Cargo.toml", fixture.root.join("Cargo.lock")).expect("should link"); - }), - ("hard link", |fixture| { - fs::hard_link( - fixture.root.join("Cargo.lock"), - fixture.parent.path().join("late-link"), - ) - .expect("should hard link"); - }), - ("new file", |fixture| { - write(&fixture.root, "late.txt", b"late\n") - }), - ]; - for (label, mutate) in mutations { - let fixture = Fixture::new(); - let source = fixture.authenticate().expect("should authenticate"); - mutate(&fixture); - assert!( - source.snapshot().verify_unchanged().is_err(), - "should detect {label} mutation" - ); - } -} - -#[test] -fn closure_requires_every_listed_member_manifest() { - let mut fixture = Fixture::new(); - write( - &fixture.root, - "Cargo.toml", - b"[workspace]\nmembers = [\"crates/trusted-server-openrtb\", \"crates/missing\"]\n", - ); - fixture.commit_all(); - assert_rejects(&fixture, "crates/missing/Cargo.toml"); - write( - &fixture.root, - "Cargo.toml", - b"[workspace]\nmembers = [\"crates/trusted-server-release\"]\n", - ); - fixture.commit_all(); - assert_rejects(&fixture, "crates/trusted-server-openrtb/Cargo.toml"); - write( - &fixture.root, - "Cargo.toml", - b"[workspace]\nmembers = [\"crates/*\"]\n", - ); - fixture.commit_all(); - assert_rejects(&fixture, "glob"); -} - -#[test] -fn closure_digest_changes_with_helper_sources() { - let mut fixture = Fixture::new(); - let before = fixture.authenticate().expect("should authenticate"); - write( - &fixture.root, - "crates/trusted-server-release/src/main.rs", - b"fn main() { }\n", - ); - fixture.commit_all(); - let after = fixture - .authenticate() - .expect("should authenticate changed helper"); - assert_ne!( - before.closure_digest(), - after.closure_digest(), - "should invalidate the closure when helper source changes" - ); - write( - &fixture.root, - "crates/other/src/lib.rs", - b"// changed outside closure\n", - ); - fixture.commit_all(); - let outside = fixture - .authenticate() - .expect("should authenticate outside change"); - assert_eq!( - after.closure_digest(), - outside.closure_digest(), - "should ignore sources outside the helper closure" - ); -} - -#[test] -fn parses_committer_epoch_strictly() { - let header = - "tree 0000000000000000000000000000000000000000\nauthor A 1 +0000\n"; - let valid = format!("{header}committer C 1700000000 -0130\n\nmessage\n"); - assert_eq!( - committer_epoch(valid.as_bytes()).expect("should parse committer epoch"), - EPOCH - ); - for committer in [ - "", - "committer C +1700000000 +0000\n", - "committer C 17e8 +0000\n", - "committer C 1700000000 0000\n", - "committer C 1700000000\n", - "committer C 1 +0000\ncommitter D 2 +0000\n", - ] { - let raw = format!("{header}{committer}\nmessage\n"); - assert!( - committer_epoch(raw.as_bytes()).is_err(), - "should reject committer line {committer:?}" - ); - } -} - -#[test] -fn joins_inventory_only_to_the_approved_commit() { - let fixture = Fixture::new(); - let source = fixture.authenticate().expect("should authenticate"); - let inventory = |commit: &str| { - serde_json::to_vec(&json!({ - "materials": [], - "modules": [], - "schema_version": 1, - "source_commit": commit, - "tools": [], - })) - .expect("should encode inventory") - }; - join_inventory(&source, &inventory(&fixture.commit)).expect("should join matching inventory"); - assert!( - join_inventory(&source, &inventory(&"a".repeat(40))).is_err(), - "should reject a different inventory commit" - ); - let mut extra: Value = - serde_json::from_slice(&inventory(&fixture.commit)).expect("should decode inventory"); - extra["approved"] = json!(true); - assert!( - join_inventory(&source, &serde_json::to_vec(&extra).expect("should encode")).is_err(), - "should reject unknown inventory fields" - ); -} - -#[test] -fn child_environment_takes_commit_and_epoch_from_approved_source() { - let fixture = Fixture::new(); - let audited = fixture.audited(); - let source = fixture.authenticate().expect("should authenticate"); - let child = child_environment( - &audited, - &source, - &ChildRequest { - target: ReleaseTarget::Wasm32Wasip1, - home: PathBuf::from("/private/release/home"), - cargo_home: PathBuf::from("/private/release/cargo-home"), - tool_directories: Vec::new(), - }, - ) - .expect("should build child environment"); - assert_eq!( - child.variables().get("TRUSTED_SERVER__GIT_VERSION"), - Some(&fixture.commit), - "should inject the approved commit" - ); - assert_eq!( - child - .variables() - .get("SOURCE_DATE_EPOCH") - .map(String::as_str), - Some("1700000000"), - "should inject the approved committer epoch" - ); -} +mod closure; +mod layout; +mod objects; +mod worktree; diff --git a/crates/trusted-server-release/src/source/tests/closure.rs b/crates/trusted-server-release/src/source/tests/closure.rs new file mode 100644 index 0000000000..4c4f3afa98 --- /dev/null +++ b/crates/trusted-server-release/src/source/tests/closure.rs @@ -0,0 +1,122 @@ +//! Helper closure, inventory join and child-environment checks. + +use super::*; + +#[test] +fn closure_requires_every_listed_member_manifest() { + let mut fixture = Fixture::new(); + write( + &fixture.root, + "Cargo.toml", + b"[workspace]\nmembers = [\"crates/trusted-server-openrtb\", \"crates/missing\"]\n", + ); + fixture.commit_all(); + assert_rejects(&fixture, "crates/missing/Cargo.toml"); + write( + &fixture.root, + "Cargo.toml", + b"[workspace]\nmembers = [\"crates/trusted-server-release\"]\n", + ); + fixture.commit_all(); + assert_rejects(&fixture, "crates/trusted-server-openrtb/Cargo.toml"); + write( + &fixture.root, + "Cargo.toml", + b"[workspace]\nmembers = [\"crates/*\"]\n", + ); + fixture.commit_all(); + assert_rejects(&fixture, "glob"); +} + +#[test] +fn closure_digest_changes_with_helper_sources() { + let mut fixture = Fixture::new(); + let before = fixture.authenticate().expect("should authenticate"); + write( + &fixture.root, + "crates/trusted-server-release/src/main.rs", + b"fn main() { }\n", + ); + fixture.commit_all(); + let after = fixture + .authenticate() + .expect("should authenticate changed helper"); + assert_ne!( + before.closure_digest(), + after.closure_digest(), + "should invalidate the closure when helper source changes" + ); + write( + &fixture.root, + "crates/other/src/lib.rs", + b"// changed outside closure\n", + ); + fixture.commit_all(); + let outside = fixture + .authenticate() + .expect("should authenticate outside change"); + assert_eq!( + after.closure_digest(), + outside.closure_digest(), + "should ignore sources outside the helper closure" + ); +} + +#[test] +fn joins_inventory_only_to_the_approved_commit() { + let fixture = Fixture::new(); + let source = fixture.authenticate().expect("should authenticate"); + let inventory = |commit: &str| { + serde_json::to_vec(&json!({ + "materials": [], + "modules": [], + "schema_version": 1, + "source_commit": commit, + "tools": [], + })) + .expect("should encode inventory") + }; + join_inventory(&source, &inventory(&fixture.commit)).expect("should join matching inventory"); + assert!( + join_inventory(&source, &inventory(&"a".repeat(40))).is_err(), + "should reject a different inventory commit" + ); + let mut extra: Value = + serde_json::from_slice(&inventory(&fixture.commit)).expect("should decode inventory"); + extra["approved"] = json!(true); + assert!( + join_inventory(&source, &serde_json::to_vec(&extra).expect("should encode")).is_err(), + "should reject unknown inventory fields" + ); +} + +#[test] +fn child_environment_takes_commit_and_epoch_from_approved_source() { + let fixture = Fixture::new(); + let audited = fixture.audited(); + let source = fixture.authenticate().expect("should authenticate"); + let child = child_environment( + &audited, + &source, + &ChildRequest { + target: ReleaseTarget::Wasm32Wasip1, + home: PathBuf::from("/private/release/home"), + cargo_home: PathBuf::from("/private/release/cargo-home"), + tool_directories: Vec::new(), + }, + ) + .expect("should build child environment"); + assert_eq!( + child.variables().get("TRUSTED_SERVER__GIT_VERSION"), + Some(&fixture.commit), + "should inject the approved commit" + ); + assert_eq!( + child + .variables() + .get("SOURCE_DATE_EPOCH") + .map(String::as_str), + Some("1700000000"), + "should inject the approved committer epoch" + ); +} diff --git a/crates/trusted-server-release/src/source/tests/layout.rs b/crates/trusted-server-release/src/source/tests/layout.rs new file mode 100644 index 0000000000..34b8e7e572 --- /dev/null +++ b/crates/trusted-server-release/src/source/tests/layout.rs @@ -0,0 +1,240 @@ +//! Repository layout, configuration and Git lookup checks. + +use super::*; + +#[test] +fn rejects_extensionless_rust_toolchain_before_other_checks() { + let fixture = Fixture::new(); + write(&fixture.root, "rust-toolchain", b"nightly\n"); + write( + &fixture.root, + ".git/objects/info/alternates", + b"/elsewhere\n", + ); + assert_rejects(&fixture, "rust-toolchain"); +} + +#[test] +fn rejects_linked_worktree_gitfile_indirection() { + let fixture = Fixture::new(); + fs::rename( + fixture.root.join(".git"), + fixture.parent.path().join("moved-git"), + ) + .expect("should move git directory"); + write(&fixture.root, ".git", b"gitdir: ../moved-git\n"); + assert_rejects(&fixture, "unsupported"); +} + +#[test] +fn rejects_commondir_indirection() { + let fixture = Fixture::new(); + write(&fixture.root, ".git/commondir", b"../other\n"); + assert_rejects(&fixture, "commondir"); +} + +#[test] +fn rejects_object_and_history_rewriting_channels() { + for (path, needle) in [ + ("objects/info/alternates", ".git/objects/info/alternates"), + ( + "objects/info/http-alternates", + ".git/objects/info/http-alternates", + ), + ("info/grafts", ".git/info/grafts"), + ("shallow", ".git/shallow"), + ("info/attributes", ".git/info/attributes"), + ( + "refs/replace/0000000000000000000000000000000000000000", + ".git/refs/replace", + ), + ] { + let fixture = Fixture::new(); + write(&fixture.root.join(".git"), path, b"fictional\n"); + assert_rejects(&fixture, needle); + } +} + +#[test] +fn rejects_packed_replace_refs() { + let fixture = Fixture::new(); + let line = format!( + "# pack-refs with: peeled fully-peeled sorted\n{} refs/replace/{}\n", + fixture.commit, fixture.commit + ); + write(&fixture.root, ".git/packed-refs", line.as_bytes()); + assert_rejects(&fixture, "refs/replace"); +} + +#[test] +fn rejects_unreviewed_git_config_keys() { + for addition in [ + "[include]\n\tpath = /dev/null\n", + "[includeIf \"gitdir:/\"]\n\tpath = /dev/null\n", + "[core]\n\tfsmonitor = true\n", + "[core]\n\thooksPath = /dev/null\n", + "[core]\n\tsshCommand = ssh\n", + "[core]\n\tworktree = /\n", + "[filter \"lfs\"]\n\tclean = cat\n", + "[diff \"x\"]\n\ttextconv = cat\n", + "[extensions]\n\tobjectformat = sha256\n", + "[user]\n\tname = Fictional\n", + ] { + let fixture = Fixture::new(); + let config = fixture.root.join(".git/config"); + let mut text = fs::read_to_string(&config).expect("should read fixture config"); + text.push_str(addition); + fs::write(&config, text).expect("should write fixture config"); + assert_rejects(&fixture, "git config"); + } +} + +#[test] +fn parses_only_allowlisted_config_values() { + assert!( + check_git_config("[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://example.com/r.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n").is_ok(), + "should accept allowlisted keys" + ); + for text in [ + "[core]\n\trepositoryformatversion = 1\n", + "[core]\n\tbare = true\n", + "[core]\n\tfilemode = maybe\n", + "[core]\n\tsymlinks = \"true\"\n", + "[core]\n\tfilemode = true \\\n", + "[remote \"origin\"]\n\tpushurl = https://example.com\n", + "[core.x]\n\tfilemode = true\n", + "\tfilemode = true\n", + ] { + assert!( + check_git_config(text).is_err(), + "should reject config {text:?}" + ); + } +} + +#[test] +fn rejects_fsmonitor_without_executing_it() { + let fixture = Fixture::new(); + let marker = fixture.parent.path().join("fsmonitor-ran"); + let hook = fixture.parent.path().join("fsmonitor.sh"); + fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display())) + .expect("should write hook"); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).expect("should chmod hook"); + let config = fixture.root.join(".git/config"); + let mut text = fs::read_to_string(&config).expect("should read config"); + text.push_str(&format!("[core]\n\tfsmonitor = {}\n", hook.display())); + fs::write(&config, text).expect("should write config"); + assert_rejects(&fixture, "git config"); + assert!( + !marker.exists(), + "should never execute the fsmonitor command" + ); +} + +/// Audits an environment whose only PATH entry holds a fake `git` that leaves a marker. +fn fake_git_environment(fixture: &Fixture) -> (AuditedEnvironment, PathBuf) { + let bin = fixture.parent.path().join("fake-bin"); + fs::create_dir_all(&bin).expect("should create fake bin"); + let marker = fixture.parent.path().join("fake-git-ran"); + let script = bin.join("git"); + fs::write( + &script, + format!("#!/bin/sh\n: > '{}'\nexit 1\n", marker.display()), + ) + .expect("should write fake git"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("should chmod"); + let audited = audit_environment(&[(OsString::from("PATH"), bin.into_os_string())]) + .expect("should audit fake PATH"); + (audited, marker) +} + +#[test] +fn config_audit_rejects_before_any_git_process_spawns() { + let control = Fixture::new(); + let (audited, marker) = fake_git_environment(&control); + assert!( + approve(&control.root, &control.commit, &audited).is_err(), + "should fail when the fake git exits nonzero" + ); + assert!( + marker.exists(), + "control should prove the fake git is reachable" + ); + + let fixture = Fixture::new(); + let (audited, marker) = fake_git_environment(&fixture); + let config = fixture.root.join(".git/config"); + let mut text = fs::read_to_string(&config).expect("should read config"); + text.push_str("[core]\n\tfsmonitor = true\n"); + fs::write(&config, text).expect("should write config"); + let text = format!( + "{:?}", + approve(&fixture.root, &fixture.commit, &audited) + .err() + .expect("should reject fsmonitor config") + ); + assert!( + text.contains("git config"), + "should name the config audit: {text}" + ); + assert!( + !marker.exists(), + "should reject before spawning any git process" + ); +} + +#[test] +fn ignores_relative_path_entries_when_locating_git() { + let fixture = Fixture::new(); + let (_, marker) = fake_git_environment(&fixture); + let audited = audit_environment(&[(OsString::from("PATH"), OsString::from("fake-bin"))]) + .expect("should audit relative PATH"); + let text = format!( + "{:?}", + approve( + &fixture.parent.path().join("source"), + &fixture.commit, + &audited + ) + .err() + .expect("should not find git on a relative entry") + ); + assert!( + text.contains("absolute PATH"), + "should explain git lookup: {text}" + ); + assert!( + !marker.exists(), + "should never run a relatively located git" + ); +} + +#[test] +fn rejects_cargo_config_in_any_ancestor_directory() { + for name in ["config", "config.toml"] { + let fixture = Fixture::new(); + write( + fixture.parent.path(), + &format!(".cargo/{name}"), + b"[build]\n", + ); + assert_rejects(&fixture, ".cargo"); + } +} + +#[test] +fn rejects_unreadable_packed_refs() { + let fixture = Fixture::new(); + let packed = fixture.root.join(".git/packed-refs"); + write( + &fixture.root, + ".git/packed-refs", + b"# pack-refs with: peeled\n", + ); + fs::set_permissions(&packed, fs::Permissions::from_mode(0o000)).expect("should chmod"); + if fs::read(&packed).is_ok() { + // Privileged runs can read mode-000 files, so the denial cannot be exercised. + return; + } + assert_rejects(&fixture, "packed-refs"); +} diff --git a/crates/trusted-server-release/src/source/tests/objects.rs b/crates/trusted-server-release/src/source/tests/objects.rs new file mode 100644 index 0000000000..fc52f71682 --- /dev/null +++ b/crates/trusted-server-release/src/source/tests/objects.rs @@ -0,0 +1,380 @@ +//! Commit, tree and blob identity and parsing checks. + +use super::*; + +#[test] +fn authenticates_clean_fixture_with_commit_epoch_and_closure() { + let fixture = Fixture::new(); + let source = fixture + .authenticate() + .expect("should authenticate clean fixture"); + assert_eq!( + source.commit().as_str(), + fixture.commit, + "should keep approved commit" + ); + assert_eq!(source.epoch(), EPOCH, "should derive committer epoch"); + assert!( + lower_hex(source.snapshot().fingerprint(), 64), + "should fingerprint the tracked snapshot" + ); + assert!( + lower_hex(source.closure_digest(), 64), + "should digest closure" + ); + assert_eq!( + source.closure_paths(), + [ + ".cargo/config.toml", + ".tool-versions", + "Cargo.lock", + "Cargo.toml", + "crates/trusted-server-openrtb/Cargo.toml", + "crates/trusted-server-release/Cargo.toml", + "crates/trusted-server-release/src/main.rs", + "rust-toolchain.toml", + "scripts/release/bootstrap.sh", + ], + "should record the complete helper-source closure" + ); + assert_eq!( + source.tracked_sha256("AGENTS.md"), + Some(hex::encode(Sha256::digest(b"# Fictional agents\n")).as_str()), + "should expose tracked content digests" + ); + source + .snapshot() + .verify_unchanged() + .expect("should verify unchanged worktree"); +} + +#[test] +fn rejects_malformed_commit_arguments() { + let fixture = Fixture::new(); + for commit in [ + "ABC", + "--help", + &fixture.commit.to_uppercase(), + &fixture.commit[..39], + ] { + assert!( + approve(&fixture.root, commit, &fixture.audited()).is_err(), + "should reject commit argument {commit}" + ); + } +} + +#[test] +fn rejects_unknown_commit_with_bounded_git_detail() { + let fixture = Fixture::new(); + let text = format!( + "{:?}", + approve(&fixture.root, &"0".repeat(40), &fixture.audited()) + .err() + .expect("should reject unknown commit") + ); + assert!( + text.contains("git cat-file"), + "should name the failing git step: {text}" + ); +} + +#[test] +fn rejects_non_commit_objects() { + let fixture = Fixture::new(); + let tree = fixture.git(&["rev-parse", "HEAD^{tree}"]); + let text = format!( + "{:?}", + approve(&fixture.root, &tree, &fixture.audited()) + .err() + .expect("should reject tree object") + ); + assert!(text.contains("not a commit"), "should reject tree: {text}"); +} + +#[test] +fn rejects_head_that_differs_from_approved_commit() { + let mut fixture = Fixture::new(); + let approved = fixture.commit.clone(); + write(&fixture.root, "AGENTS.md", b"# Changed\n"); + fixture.commit_all(); + let text = format!( + "{:?}", + approve(&fixture.root, &approved, &fixture.audited()) + .err() + .expect("should reject HEAD mismatch") + ); + assert!( + text.contains("HEAD"), + "should explain HEAD mismatch: {text}" + ); +} + +#[test] +fn parses_committer_epoch_strictly() { + let header = + "tree 0000000000000000000000000000000000000000\nauthor A 1 +0000\n"; + let valid = format!("{header}committer C 1700000000 -0130\n\nmessage\n"); + assert_eq!( + committer_epoch(valid.as_bytes()).expect("should parse committer epoch"), + EPOCH + ); + for committer in [ + "", + "committer C +1700000000 +0000\n", + "committer C 17e8 +0000\n", + "committer C 1700000000 0000\n", + "committer C 1700000000 +0060\n", + "committer C 1700000000 +01000\n", + "committer C 1700000000\n", + "committer C 1 +0000\ncommitter D 2 +0000\n", + ] { + let raw = format!("{header}{committer}\nmessage\n"); + assert!( + committer_epoch(raw.as_bytes()).is_err(), + "should reject committer line {committer:?}" + ); + } +} + +fn object_path(fixture: &Fixture, oid: &str) -> PathBuf { + fixture + .root + .join(".git/objects") + .join(&oid[..2]) + .join(&oid[2..]) +} + +/// Replaces the loose object file for `target` with the bytes stored for `source`. +fn substitute_object(fixture: &Fixture, target: &str, source: &str) { + let destination = object_path(fixture, target); + assert!(destination.exists(), "should have a loose target object"); + fs::remove_file(&destination).expect("should remove read-only loose object"); + fs::copy(object_path(fixture, source), &destination).expect("should copy loose object"); +} + +#[test] +fn rejects_substituted_blob_object() { + let fixture = Fixture::new(); + let target = fixture.git(&["rev-parse", "HEAD:AGENTS.md"]); + let source = fixture.git(&["rev-parse", "HEAD:Cargo.lock"]); + substitute_object(&fixture, &target, &source); + assert_rejects(&fixture, "object identity"); +} + +#[test] +fn rejects_substituted_tree_object() { + let fixture = Fixture::new(); + let target = fixture.git(&["rev-parse", "HEAD^{tree}"]); + let source = fixture.git(&["rev-parse", "HEAD:crates"]); + substitute_object(&fixture, &target, &source); + assert_rejects(&fixture, "object identity"); +} + +#[test] +fn rejects_substituted_nested_tree_object() { + let fixture = Fixture::new(); + let target = fixture.git(&["rev-parse", "HEAD:crates/other/src"]); + let source = fixture.git(&["rev-parse", "HEAD:crates/trusted-server-release/src"]); + substitute_object(&fixture, &target, &source); + assert_rejects(&fixture, "object identity"); +} + +#[test] +fn rejects_substituted_commit_object() { + let fixture = Fixture::new(); + let tree = fixture.git(&["rev-parse", "HEAD^{tree}"]); + let other = fixture.git(&["commit-tree", &tree, "-p", &fixture.commit, "-m", "other"]); + substitute_object(&fixture, &fixture.commit, &other); + assert_rejects(&fixture, "object identity"); +} + +fn tree_entry(mode: &str, name: &[u8]) -> Vec { + let mut bytes = format!("{mode} ").into_bytes(); + bytes.extend_from_slice(name); + bytes.push(0); + bytes.extend_from_slice(&[0xab; 20]); + bytes +} + +type ParsedTree = (Vec, Vec<(String, String)>); + +fn parse_tree_bytes(bytes: &[u8]) -> Result { + let mut entries = Vec::new(); + let mut subtrees = Vec::new(); + parse_tree_object("", bytes, &mut entries, &mut subtrees) + .map(|()| (entries, subtrees)) + .map_err(|report| format!("{report:?}")) +} + +#[test] +fn parses_canonical_tree_objects() { + let bytes = [ + tree_entry("100644", b"a"), + tree_entry("40000", b"a-dir"), + tree_entry("100755", b"b"), + tree_entry("100644", b"c.txt"), + tree_entry("40000", b"c"), + ] + .concat(); + let (entries, subtrees) = parse_tree_bytes(&bytes).expect("should parse canonical tree"); + assert_eq!(entries.len(), 3, "should collect blob entries"); + assert_eq!(subtrees.len(), 2, "should collect subtrees"); + assert_eq!(subtrees[0].1, "ab".repeat(20), "should hex-encode raw ids"); +} + +#[test] +fn orders_tree_names_as_if_suffixed_by_slash() { + let canonical = [tree_entry("100644", b"c.txt"), tree_entry("40000", b"c")].concat(); + assert!( + parse_tree_bytes(&canonical).is_ok(), + "should accept `c.txt` before tree `c` because `c/` sorts after `c.txt`" + ); + let swapped = [tree_entry("40000", b"c"), tree_entry("100644", b"c.txt")].concat(); + assert!( + parse_tree_bytes(&swapped).is_err(), + "should reject tree `c` before `c.txt`" + ); +} + +#[test] +fn rejects_malformed_tree_objects() { + let mut missing_nul = b"100644 name".to_vec(); + missing_nul.extend_from_slice(&[0xab; 20]); + let mut truncated = b"100644 name\0".to_vec(); + truncated.extend_from_slice(&[0xab; 19]); + let cases: [(&str, Vec); 15] = [ + ("missing NUL", missing_nul), + ("truncated id", truncated), + ("missing space", b"100644name\0".to_vec()), + ("zero-padded tree mode", tree_entry("040000", b"dir")), + ("legacy group-writable mode", tree_entry("100664", b"file")), + ("unknown mode", tree_entry("100600", b"file")), + ("slash in name", tree_entry("100644", b"a/b")), + ("empty name", tree_entry("100644", b"")), + ("dot name", tree_entry("40000", b".")), + ("dot-dot name", tree_entry("40000", b"..")), + ("git name", tree_entry("40000", b".git")), + ("uppercase git name", tree_entry("40000", b".GIT")), + ( + "duplicate name", + [tree_entry("100644", b"a"), tree_entry("100644", b"a")].concat(), + ), + ( + "file and tree with one name", + [tree_entry("100644", b"a"), tree_entry("40000", b"a")].concat(), + ), + ( + "out of order", + [tree_entry("100644", b"b"), tree_entry("100644", b"a")].concat(), + ), + ]; + for (label, bytes) in cases { + assert!(parse_tree_bytes(&bytes).is_err(), "should reject {label}"); + } +} + +fn walk_with(limits: &TreeLimits) -> Result, Report> { + let fixture = Fixture::new(); + let git = Git::locate(&fixture.root, &fixture.audited()).expect("should locate git"); + let tree = fixture.git(&["rev-parse", "HEAD^{tree}"]); + walk_tree(&git, &tree, limits) +} + +#[test] +fn enforces_tree_depth_limit_exactly() { + let exact = TreeLimits { + depth: 4, + entries: 1_000, + objects: 1_000, + }; + assert!( + walk_with(&exact).is_ok(), + "should accept a tree exactly four levels deep" + ); + let shallow = TreeLimits { depth: 3, ..exact }; + let text = format!( + "{:?}", + walk_with(&shallow).expect_err("should reject depth") + ); + assert!( + text.contains("depth"), + "should name the depth limit: {text}" + ); +} + +#[test] +fn enforces_tree_entry_and_object_limits() { + let entries = TreeLimits { + depth: 64, + entries: 5, + objects: 1_000, + }; + let text = format!( + "{:?}", + walk_with(&entries).expect_err("should reject entry count") + ); + assert!( + text.contains("entry limit"), + "should name the entry limit: {text}" + ); + let objects = TreeLimits { + depth: 64, + entries: 1_000, + objects: 3, + }; + let text = format!( + "{:?}", + walk_with(&objects).expect_err("should reject tree object count") + ); + assert!( + text.contains("object limit"), + "should name the object limit: {text}" + ); +} + +#[test] +fn rejects_detected_sha1_collision_input() { + let vector = include_bytes!("../../../tests/fixtures/sha-mbles-1.bin"); + let text = format!( + "{:?}", + checked_sha1(&[vector.as_slice()]) + .expect_err("should detect the SHA-mbles chosen-prefix collision") + ); + assert!( + text.contains("collision"), + "should report a collision: {text}" + ); + assert_eq!( + checked_sha1(&[b"blob 0\0".as_slice()]).expect("should hash ordinary input"), + "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "should compute Git's empty-blob id" + ); +} + +#[test] +fn rejects_non_ascii_tracked_names() { + let bytes = tree_entry("100644", "caf\u{e9}.txt".as_bytes()); + let text = parse_tree_bytes(&bytes).expect_err("should reject non-ASCII names"); + assert!( + text.contains("ASCII"), + "should explain the ASCII requirement: {text}" + ); +} + +#[test] +fn enforces_aggregate_object_byte_budget() { + let fixture = Fixture::new(); + let git = Git::locate(&fixture.root, &fixture.audited()) + .expect("should locate git") + .with_object_budget(64); + let tree = fixture.git(&["rev-parse", "HEAD^{tree}"]); + let text = format!( + "{:?}", + walk_tree(&git, &tree, &TREE_LIMITS).expect_err("should exhaust the byte budget") + ); + assert!( + text.contains("aggregate"), + "should name the aggregate bound: {text}" + ); +} diff --git a/crates/trusted-server-release/src/source/tests/worktree.rs b/crates/trusted-server-release/src/source/tests/worktree.rs new file mode 100644 index 0000000000..ef39219a7d --- /dev/null +++ b/crates/trusted-server-release/src/source/tests/worktree.rs @@ -0,0 +1,141 @@ +//! Worktree comparison and mutation checks. + +use super::*; + +#[test] +fn rejects_modified_tracked_file() { + let fixture = Fixture::new(); + write(&fixture.root, "AGENTS.md", b"# Modified\n"); + assert_rejects(&fixture, "AGENTS.md"); +} + +#[test] +fn rejects_changed_executable_bit() { + let fixture = Fixture::new(); + fs::set_permissions( + fixture.root.join("scripts/release/bootstrap.sh"), + fs::Permissions::from_mode(0o644), + ) + .expect("should chmod"); + assert_rejects(&fixture, "scripts/release/bootstrap.sh"); +} + +#[test] +fn rejects_file_replaced_by_symlink() { + let fixture = Fixture::new(); + let outside = fixture.parent.path().join("outside.md"); + fs::write(&outside, b"# Fictional agents\n").expect("should write outside file"); + fs::remove_file(fixture.root.join("AGENTS.md")).expect("should remove tracked file"); + symlink(&outside, fixture.root.join("AGENTS.md")).expect("should create symlink"); + assert_rejects(&fixture, "AGENTS.md"); +} + +#[test] +fn rejects_preexisting_hard_link() { + let fixture = Fixture::new(); + fs::hard_link( + fixture.root.join("Cargo.lock"), + fixture.parent.path().join("lock-link"), + ) + .expect("should create hard link outside source"); + assert_rejects(&fixture, "Cargo.lock"); +} + +#[test] +fn rejects_missing_tracked_file() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root.join("crates/other/src/lib.rs")).expect("should remove file"); + assert_rejects(&fixture, "crates/other/src/lib.rs"); +} + +#[test] +fn rejects_untracked_and_ignored_paths() { + for (path, needle) in [ + (".DS_Store", ".DS_Store"), + ( + "crates/trusted-server-js/lib/node_modules/x/index.js", + "crates/trusted-server-js", + ), + ( + "crates/other/src/extra_test.rs", + "crates/other/src/extra_test.rs", + ), + ("target/debug/fictional", "target"), + ] { + let mut fixture = Fixture::new(); + write( + &fixture.root, + ".gitignore", + b".DS_Store\nnode_modules\ntarget\n*_test.rs\n", + ); + fixture.commit_all(); + write(&fixture.root, path, b"untracked\n"); + assert_rejects(&fixture, needle); + } +} + +#[test] +fn rejects_empty_untracked_directory() { + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("empty")).expect("should create directory"); + assert_rejects(&fixture, "empty"); +} + +#[test] +fn rejects_unreviewed_tracked_symlink() { + let mut fixture = Fixture::new(); + symlink("Cargo.toml", fixture.root.join("manifest-link")).expect("should create symlink"); + fixture.commit_all(); + assert_rejects(&fixture, "manifest-link"); +} + +#[test] +fn rejects_tracked_submodule() { + let mut fixture = Fixture::new(); + let gitlink = format!("160000,{},vendor/module", fixture.commit); + fixture.git(&["update-index", "--add", "--cacheinfo", &gitlink]); + fixture.git(&["commit", "-q", "-m", "submodule"]); + fixture.commit = fixture.git(&["rev-parse", "HEAD"]); + assert_rejects(&fixture, "vendor/module"); +} + +type Mutation = fn(&Fixture); + +#[test] +fn verify_unchanged_detects_mutations_after_snapshot() { + let mutations: [(&str, Mutation); 5] = [ + ("content", |fixture| { + write(&fixture.root, "AGENTS.md", b"# Changed\n") + }), + ("mode", |fixture| { + fs::set_permissions( + fixture.root.join("Cargo.lock"), + fs::Permissions::from_mode(0o755), + ) + .expect("should chmod"); + }), + ("symlink", |fixture| { + fs::remove_file(fixture.root.join("Cargo.lock")).expect("should remove"); + symlink("Cargo.toml", fixture.root.join("Cargo.lock")).expect("should link"); + }), + ("hard link", |fixture| { + fs::hard_link( + fixture.root.join("Cargo.lock"), + fixture.parent.path().join("late-link"), + ) + .expect("should hard link"); + }), + ("new file", |fixture| { + write(&fixture.root, "late.txt", b"late\n") + }), + ]; + for (label, mutate) in mutations { + let fixture = Fixture::new(); + let source = fixture.authenticate().expect("should authenticate"); + mutate(&fixture); + assert!( + source.snapshot().verify_unchanged().is_err(), + "should detect {label} mutation" + ); + } +} diff --git a/crates/trusted-server-release/src/source/tree.rs b/crates/trusted-server-release/src/source/tree.rs new file mode 100644 index 0000000000..58d9538826 --- /dev/null +++ b/crates/trusted-server-release/src/source/tree.rs @@ -0,0 +1,225 @@ +//! Verified walking of Git tree objects in their canonical binary form. + +use std::collections::BTreeSet; + +use error_stack::Report; + +use super::git::Git; +use super::{Mode, REVIEWED_SYMLINKS, source_error}; +use crate::error::ReleaseError; + +const TREE_MODE: &[u8] = b"40000"; + +/// One tracked non-tree entry at the approved commit. +#[derive(Debug)] +pub(super) struct TreeEntry { + pub(super) path: String, + pub(super) mode: Mode, + pub(super) oid: String, +} + +/// Bounds for walking the approved commit's trees. +#[derive(Clone, Copy)] +pub(super) struct TreeLimits { + /// Maximum tree levels, counting the root tree as level one. + pub(super) depth: usize, + /// Maximum blob and subtree entries across all trees. + pub(super) entries: usize, + /// Maximum tree objects read. + pub(super) objects: usize, +} + +/// Production tree bounds, far above the current repository's size. +pub(super) const TREE_LIMITS: TreeLimits = TreeLimits { + depth: 256, + entries: 1_000_000, + objects: 100_000, +}; + +/// Walks every tree from `root_tree`, rehashing each tree object and parsing +/// Git's binary tree format directly instead of trusting `ls-tree` output. +pub(super) fn walk_tree( + git: &Git, + root_tree: &str, + limits: &TreeLimits, +) -> Result, Report> { + let mut entries = Vec::new(); + let mut level = vec![(String::new(), root_tree.to_owned())]; + let mut depth = 0; + let mut objects = 0; + let mut subtrees_seen = 0; + while !level.is_empty() { + depth += 1; + objects += level.len(); + if depth > limits.depth { + return Err(source_error("tracked tree exceeds the depth limit")); + } + if objects > limits.objects { + return Err(source_error("tracked tree exceeds the tree object limit")); + } + let oids: Vec<&str> = level.iter().map(|(_, oid)| oid.as_str()).collect(); + let mut next = Vec::new(); + git.read_objects("tree", &oids, |index, bytes| { + parse_tree_object(&level[index].0, bytes, &mut entries, &mut next)?; + if entries.len() + subtrees_seen + next.len() > limits.entries { + return Err(source_error("tracked tree exceeds the entry limit")); + } + Ok(()) + })?; + subtrees_seen += next.len(); + level = next; + } + Ok(entries) +} + +/// One raw entry decoded from a tree object. +struct RawEntry<'a> { + mode: &'a [u8], + name: &'a str, + oid: String, +} + +/// Parses one binary tree object in Git's canonical form. +/// +/// Entries must use canonical modes and names and appear in strictly +/// increasing order, comparing tree names as if suffixed by `/`; this also +/// rejects duplicate names, including a file and tree sharing one name. +pub(super) fn parse_tree_object( + prefix: &str, + mut bytes: &[u8], + entries: &mut Vec, + subtrees: &mut Vec<(String, String)>, +) -> Result<(), Report> { + let mut order = TreeOrder::default(); + while !bytes.is_empty() { + let entry = next_entry(&mut bytes, prefix)?; + order.admit(&entry, prefix)?; + let path = if prefix.is_empty() { + entry.name.to_owned() + } else { + format!("{prefix}/{}", entry.name) + }; + check_tracked_path(&path)?; + match classify_mode(entry.mode, &path)? { + None => subtrees.push((path, entry.oid)), + Some(mode) => entries.push(TreeEntry { + path, + mode, + oid: entry.oid, + }), + } + } + Ok(()) +} + +fn next_entry<'a>( + bytes: &mut &'a [u8], + prefix: &str, +) -> Result, Report> { + let malformed = || source_error(format!("tree object for {prefix:?} is malformed")); + let data: &'a [u8] = bytes; + let space = data + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(malformed)?; + let rest = &data[space + 1..]; + let nul = rest + .iter() + .position(|byte| *byte == 0) + .ok_or_else(malformed)?; + let raw_oid = rest.get(nul + 1..nul + 21).ok_or_else(malformed)?; + let name = std::str::from_utf8(&rest[..nul]) + .map_err(|_| source_error("tracked paths must be UTF-8"))?; + if name.is_empty() || name.contains('/') { + return Err(malformed()); + } + *bytes = &rest[nul + 21..]; + Ok(RawEntry { + mode: &data[..space], + name, + oid: hex::encode(raw_oid), + }) +} + +/// Enforces Git's canonical strictly increasing tree entry order. +#[derive(Default)] +struct TreeOrder<'a> { + previous: Option>, + names: BTreeSet<&'a str>, +} + +impl<'a> TreeOrder<'a> { + fn admit(&mut self, entry: &RawEntry<'a>, prefix: &str) -> Result<(), Report> { + if !self.names.insert(entry.name) { + return Err(source_error(format!( + "tree object for {prefix:?} repeats the name {}", + entry.name + ))); + } + let mut key = entry.name.as_bytes().to_vec(); + if entry.mode == TREE_MODE { + key.push(b'/'); + } + if self + .previous + .as_ref() + .is_some_and(|previous| *previous >= key) + { + return Err(source_error(format!( + "tree object for {prefix:?} is not in canonical order" + ))); + } + self.previous = Some(key); + Ok(()) + } +} + +/// Maps a canonical mode to a tracked file mode, or `None` for a subtree. +fn classify_mode(mode: &[u8], path: &str) -> Result, Report> { + let mode = match mode { + TREE_MODE => return Ok(None), + b"100644" => Mode::Regular, + b"100755" => Mode::Executable, + b"120000" => Mode::Symlink, + b"160000" => { + return Err(source_error(format!( + "tracked submodule {path} is not reviewed" + ))); + } + _ => { + return Err(source_error(format!( + "tracked path {path} has an unsupported mode" + ))); + } + }; + if mode == Mode::Symlink && !REVIEWED_SYMLINKS.iter().any(|(link, _)| *link == path) { + return Err(source_error(format!( + "tracked symlink {path} is not reviewed" + ))); + } + Ok(Some(mode)) +} + +/// Rejects unsafe tracked path components. +/// +/// Components must be ASCII: case- and normalization-folding filesystems +/// (HFS+/APFS Unicode normalization, NTFS and FAT aliasing) can map distinct +/// non-ASCII tree names onto one worktree path, and no tracked path currently +/// needs them. +pub(super) fn check_tracked_path(path: &str) -> Result<(), Report> { + if !path.is_ascii() { + return Err(source_error( + "tracked path components must be ASCII to avoid filesystem name aliasing", + )); + } + let unsafe_component = path.split('/').any(|component| { + component.is_empty() + || component == "." + || component == ".." + || component.eq_ignore_ascii_case(".git") + }); + if unsafe_component { + return Err(source_error("tracked path has an unsafe component")); + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/source/worktree.rs b/crates/trusted-server-release/src/source/worktree.rs new file mode 100644 index 0000000000..3abe749bdb --- /dev/null +++ b/crates/trusted-server-release/src/source/worktree.rs @@ -0,0 +1,170 @@ +//! Comparison of the checked-out worktree against approved tracked records. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs::{self, Metadata}; +use std::io; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::{Mode, Records, source_error}; +use crate::documents::{Role, read_bounded}; +use crate::error::ReleaseError; + +const PROBLEMS_SHOWN: usize = 20; + +/// Untracked coordination paths reviewed for release sources. Empty for now. +const REVIEWED_UNTRACKED: [&str; 0] = []; + +/// Walks the worktree without following symlinks and fails on any difference. +pub(super) fn compare_worktree( + root: &Path, + expected: &Records, +) -> Result<(), Report> { + let mut scan = Scan { + root, + expected, + ancestors: ancestor_directories(expected), + directories: vec![String::new()], + problems: Vec::new(), + seen: BTreeSet::new(), + }; + while let Some(directory) = scan.directories.pop() { + for name in list_directory(root, &directory)? { + scan.visit(&directory, &name); + } + } + let missing: Vec = expected + .keys() + .filter(|path| !scan.seen.contains(*path)) + .map(|path| format!("tracked path {path} is missing")) + .collect(); + scan.problems.extend(missing); + report_problems(scan.problems) +} + +/// Every proper ancestor directory of a tracked path. +fn ancestor_directories(expected: &Records) -> BTreeSet<&str> { + let mut ancestors = BTreeSet::new(); + for path in expected.keys() { + let mut prefix = path.as_str(); + while let Some((parent, _)) = prefix.rsplit_once('/') { + ancestors.insert(parent); + prefix = parent; + } + } + ancestors +} + +fn list_directory(root: &Path, directory: &str) -> Result, Report> { + let mut names = fs::read_dir(root.join(directory)) + .and_then(|entries| { + entries + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::>>() + }) + .map_err(|error| source_error(format!("cannot list {directory:?}: {error}")))?; + names.sort(); + Ok(names) +} + +fn report_problems(mut problems: Vec) -> Result<(), Report> { + if problems.is_empty() { + return Ok(()); + } + let count = problems.len(); + problems.truncate(PROBLEMS_SHOWN); + Err(source_error(format!( + "worktree differs from the approved commit ({count} problems): {}", + problems.join("; ") + ))) +} + +struct Scan<'a> { + root: &'a Path, + expected: &'a Records, + ancestors: BTreeSet<&'a str>, + directories: Vec, + problems: Vec, + seen: BTreeSet, +} + +impl Scan<'_> { + fn visit(&mut self, directory: &str, name: &OsString) { + let Some(name) = name.to_str() else { + self.problems.push(format!( + "untracked non-UTF-8 path {}", + name.to_string_lossy() + )); + return; + }; + let relative = if directory.is_empty() { + name.to_owned() + } else { + format!("{directory}/{name}") + }; + if relative == ".git" { + return; + } + self.observe(&relative); + self.seen.insert(relative); + } + + fn observe(&mut self, relative: &str) { + let path = self.root.join(relative); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + self.problems + .push(format!("{relative} cannot be inspected: {error}")); + return; + } + }; + if metadata.is_dir() && self.ancestors.contains(relative) { + self.directories.push(relative.to_owned()); + return; + } + let Some(record) = self.expected.get(relative) else { + if !REVIEWED_UNTRACKED.contains(&relative) { + self.problems.push(format!("untracked path {relative}")); + } + return; + }; + match observe_record(&path, &metadata) { + Ok(observed) if observed == *record => {} + Ok(_) => self.problems.push(format!( + "{relative} differs from the approved commit in mode, type or content" + )), + Err(reason) => self.problems.push(format!("{relative} {reason}")), + } + } +} + +/// Observes the mode and content digest of one tracked worktree path. +fn observe_record(path: &Path, metadata: &Metadata) -> Result<(Mode, String), String> { + if metadata.file_type().is_symlink() { + let target = fs::read_link(path).map_err(|error| error.to_string())?; + return Ok(( + Mode::Symlink, + hex::encode(Sha256::digest(target.as_os_str().as_bytes())), + )); + } + if !metadata.is_file() { + return Err("has an unsupported file type".to_owned()); + } + if metadata.nlink() != 1 { + return Err(format!("has {} hard links", metadata.nlink())); + } + let mode = if metadata.permissions().mode() & 0o100 == 0 { + Mode::Regular + } else { + Mode::Executable + }; + read_bounded(path, Role::Executable) + .map(|raw| (mode, raw.sha256)) + .map_err(|report| format!("{report:?}")) +} diff --git a/crates/trusted-server-release/tests/commands.rs b/crates/trusted-server-release/tests/commands.rs index 21530fd476..c8af879ad3 100644 --- a/crates/trusted-server-release/tests/commands.rs +++ b/crates/trusted-server-release/tests/commands.rs @@ -15,6 +15,24 @@ const TOOLS: &str = "scripts/release/release-tools.json"; const CLI_POLICY: &str = "crates/trusted-server-cli/release-policy.json"; const BOOTSTRAP: &str = "scripts/release/bootstrap.sh"; +/// Preflight rejects any ancestor Cargo configuration layer, so fixtures need a +/// temporary directory whose ancestry has none; report that host precondition. +/// Integration tests cannot import the crate-private unit-test fixture, so +/// this helper and the Git fixture setup are intentionally duplicated here. +fn assert_clean_ancestry(path: &Path) { + let canonical = fs::canonicalize(path).expect("should resolve fixture directory"); + for ancestor in canonical.ancestors() { + for name in ["config", "config.toml"] { + let layer = ancestor.join(".cargo").join(name); + assert!( + !layer.exists(), + "fixture temporary directory ancestry must not contain {}; set TMPDIR to a clean directory", + layer.display() + ); + } + } +} + fn host_path() -> OsString { std::env::var_os("PATH").expect("should have a PATH to locate git") } @@ -39,6 +57,7 @@ struct SourceFixture { impl SourceFixture { fn new(customize: impl FnOnce(&Path)) -> Self { let parent = tempdir().expect("should create fixture parent"); + assert_clean_ancestry(parent.path()); let root = parent.path().join("source"); for path in [COHORT, TOOLS, CLI_POLICY] { write(&root, path, &tracked_input(path)); @@ -204,6 +223,16 @@ fn producer_dispatch_remains_closed_for_every_invocation() { stderr(&output).contains("release execution remains disabled"), "should explain pending execution gate" ); + if !arguments.is_empty() { + assert!( + stderr(&output).contains("Invalid release invocation"), + "should report a usage error for {arguments:?}" + ); + assert!( + stderr(&output).contains("invalid invocation:"), + "should surface the argument error kind for {arguments:?}" + ); + } assert_eq!( fs::read_dir(root.path()) .expect("should inspect process writes") @@ -241,8 +270,10 @@ fn clean_fixture_preflight_fails_at_closed_selector_without_writes() { let output = fixture.preflight(true, &[]); assert_failed_without_output(&output, "Production release selection is not qualified"); assert!( - stderr(&output).contains("authenticated locally"), - "should pass every earlier check before the selector" + stderr(&output).contains( + "source matched locally; git executable and remote eligibility not established" + ), + "should pass every earlier check before the selector without overclaiming" ); assert_eq!(fixture.listing(), before, "should write no files"); } @@ -374,6 +405,16 @@ fn audit_inputs_reports_incomplete_with_distinct_status() { ); let report: Value = serde_json::from_slice(&output.stdout).expect("should print JSON report"); assert_eq!(report["status"], json!("incomplete")); + let not_established = report["not_established"].to_string(); + for fact in [ + "git executable identity (Task 8)", + "rustup override/toolchain selection (bootstrap, Task 8)", + ] { + assert!( + not_established.contains(fact), + "should list {fact} as not established" + ); + } assert!( report["pending"] .as_array() @@ -413,3 +454,24 @@ fn bootstrap_rejects_extensionless_rust_toolchain_before_closing() { "should name the extensionless toolchain file" ); } + +#[test] +fn bootstrap_checks_rust_toolchain_at_its_repository_root() { + let tree = tempdir().expect("should create bootstrap tree"); + let script = tree.path().join(BOOTSTRAP); + fs::create_dir_all(script.parent().expect("should have parent")).expect("should create dirs"); + fs::copy(Path::new(REPOSITORY).join(BOOTSTRAP), &script).expect("should copy bootstrap"); + fs::write(tree.path().join("rust-toolchain"), b"nightly\n").expect("should write pin"); + let elsewhere = tempdir().expect("should create unrelated working directory"); + let output = Command::new("/bin/sh") + .arg(&script) + .current_dir(elsewhere.path()) + .env_clear() + .output() + .expect("should run bootstrap"); + assert!(!output.status.success(), "should stay closed"); + assert!( + stderr(&output).contains("rust-toolchain"), + "should reject the repository-root pin even from another working directory" + ); +} diff --git a/crates/trusted-server-release/tests/fixtures/README.md b/crates/trusted-server-release/tests/fixtures/README.md index 22960c3f8a..097f776213 100644 --- a/crates/trusted-server-release/tests/fixtures/README.md +++ b/crates/trusted-server-release/tests/fixtures/README.md @@ -9,3 +9,7 @@ Source: [SPDX specification schema](https://github.com/spdx/spdx-spec/blob/aadf3 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). diff --git a/crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin b/crates/trusted-server-release/tests/fixtures/sha-mbles-1.bin new file mode 100644 index 0000000000000000000000000000000000000000..5a7c30e97646c66422abe0a9793a5fcb9f1cf8d6 GIT binary patch literal 640 zcmV-`0)PFP1Pug#=of$iAOQbMWqBZJb0BbGa&#bXW*}i8V{dG1X>)0BZXqB^bSHBl zVIXvJVQ?XN#v1Ui%mqai*(XkO2VzSd$NM9gi@4s4S6#Y$o~tpzXG?9DLwKksb1(IU z9Co7S2XeKfeBtWE3%QfQEsSvDN>7bn&F#UnES&M4F|Q;kb)7=w-`g>9pICMy?o}x{ zw%puBpUP8JJ8<}Z-Y}v^>N@tvS)%d_G7WYOwomkV2v5_@v(40lV%ch(Lk1Vh|7Z|qdJ4b`+{(G#SY32i+svyyDTet0$KULm2lmSL^q=mU$6JW%D|e^Qf38QClE(f7mlv~ zf?6!9D$ljvWX^U$+*zeTsr;OEXIA3kJ;xKs!c3Qts%s87r}bYHMJgPkg$>=6V*Q#J ztwKp^sc;DQMsoI!^kM6Wu$eQ~Cban&bezB^{oQOrU&JA3HP91H6)0P)EVqQD_sg{V zQB6zm_9J}o3Z9=6E1CvwZ_$5jLYQ=TSa0@Gua`F!H%LccW0YmW1WR(AfS%&6t}-k`d&K|M|24(A@=9~LXyZ62@LCFZWWu4*+- z@qF?Hqy+oh68uF?LH*fW@4;KF^V3*EX*WC9JhR6N@N literal 0 HcmV?d00001 diff --git a/scripts/release/bootstrap.sh b/scripts/release/bootstrap.sh index de3d445720..e02fd3f659 100644 --- a/scripts/release/bootstrap.sh +++ b/scripts/release/bootstrap.sh @@ -3,10 +3,15 @@ # Keep release execution closed while the replacement is implemented and reviewed. set -eu # An extensionless pin overrides rust-toolchain.toml during rustup selection, -# so reject it before any future toolchain selection can happen. -if [ -e rust-toolchain ] || [ -L rust-toolchain ]; then - printf '%s\n' 'Extensionless rust-toolchain is rejected before toolchain selection' >&2 - exit 1 -fi +# so reject it in the invocation directory and in the repository root that +# owns this script before any future toolchain selection can happen. +script_directory=$(dirname -- "$0") +repository_root=$(CDPATH='' cd -P -- "$script_directory/../.." && pwd -P) +for candidate in rust-toolchain "$repository_root/rust-toolchain"; do + if [ -e "$candidate" ] || [ -L "$candidate" ]; then + printf '%s\n' 'Extensionless rust-toolchain is rejected before toolchain selection' >&2 + exit 1 + fi +done printf '%s\n' 'Release bootstrap is pending the reviewed Rust tooling replacement' >&2 exit 1 From 019d7913d615968b8f7f79b40f08487462786cf0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 14:11:03 +0530 Subject: [PATCH 045/153] Make escaped gh descendant test portable to Linux --- .../src/release/tests/gh/lifecycle.rs | 180 ++++++++++++++++-- 1 file changed, 167 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs b/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs index 279c6b7f0f..7674a59f59 100644 --- a/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs +++ b/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs @@ -1,6 +1,9 @@ //! Deadlines, cancellation, byte caps, termination, reaping and output //! acceptance with real OS fake children. +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + use super::*; // Bounds, termination and reaping. @@ -265,29 +268,180 @@ fn successful_exit_kills_lingering_same_group_descendant() { fn escaped_descendant_is_reported_not_awaited() { let fake = Fake::empty(); let pid_file = fake.file("escaped.pid"); - let script = format!( - "set -m\nsleep 30 &\necho $! > '{}'\nexit 0", - pid_file.display() - ); - fake.install(&script); + fake.install(&helper_exec("escaped", fake.directory.path())); let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); let started = Instant::now(); - let (error, _) = context(run( - &runner, - AttemptDeadline::start(), - &Cancellation::default(), - )); - + let result = run(&runner, AttemptDeadline::start(), &Cancellation::default()); + let elapsed = started.elapsed(); let escaped = read_pid(&pid_file); terminate_process(escaped); + assert_eq!( - error, + context(result).0, ReleaseError::DescendantEscaped, "should report escaped descendant" ); assert!( - started.elapsed() < Duration::from_secs(5), + elapsed < Duration::from_secs(5), "should not hang on open pipes" ); } + +// Host-independent helper children. +// +// `/bin/sh` cannot leave its process group portably (dash ignores `set -m` +// without a terminal and macOS has no `setsid` binary), so the fake gh execs +// this test binary in a hidden helper mode that uses libc directly. + +/// Environment selector `:`; absent in normal test runs. +const HELPER_SELECTOR: &str = "TS_CLI_GH_HELPER"; +/// Environment variable carrying the nonce the selector must match. +const HELPER_NONCE: &str = "TS_CLI_GH_HELPER_NONCE"; +/// Environment variable naming the directory where helpers record pids. +const HELPER_PIDS: &str = "TS_CLI_GH_HELPER_PIDS"; +/// Exact libtest name of [`helper_entry`]. +const HELPER_TEST: &str = "release::tests::gh::lifecycle::helper_entry"; +/// Exit status of a helper whose fork or pipe setup failed. +const HELPER_SETUP_FAILED: i32 = 90; + +/// Returns a fake gh body that execs this test binary in helper `mode`. +fn helper_exec(mode: &str, pids: &Path) -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let sequence = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let nonce = format!("{}-{sequence}-{nanos}", std::process::id()); + let binary = std::env::current_exe().expect("should locate the test binary"); + let (binary, pids) = (binary.display().to_string(), pids.display().to_string()); + assert!( + !binary.contains('\'') && !pids.contains('\''), + "should quote helper paths safely" + ); + format!( + "{HELPER_SELECTOR}='{mode}:{nonce}' {HELPER_NONCE}='{nonce}' {HELPER_PIDS}='{pids}' \ + exec '{binary}' --exact {HELPER_TEST} --nocapture --test-threads=1" + ) +} + +/// Returns the helper mode when the selector, nonce and argv all match a +/// fixture-launched helper run. +fn authorized_mode(selector: &str, nonce: Option<&str>, arguments: &[String]) -> Option { + let (mode, selector_nonce) = selector.rsplit_once(':')?; + let exact = arguments.iter().any(|argument| argument == "--exact"); + let filtered = arguments.iter().any(|argument| argument == HELPER_TEST); + let matched = nonce.is_some_and(|nonce| !nonce.is_empty() && nonce == selector_nonce); + (exact && filtered && matched).then(|| mode.to_owned()) +} + +/// Hidden re-exec entry point; returns immediately in normal test runs. +/// +/// A selector present without the fixture's exact filter and nonce fails the +/// run rather than silently acting or passing. +#[test] +fn helper_entry() { + let Some(selector) = std::env::var_os(HELPER_SELECTOR) else { + return; + }; + let nonce = std::env::var(HELPER_NONCE).ok(); + let arguments: Vec = std::env::args().collect(); + let mode = authorized_mode(&selector.to_string_lossy(), nonce.as_deref(), &arguments); + assert!( + mode.is_some(), + "should run helper mode only from a fixture-launched exact helper invocation" + ); + let code = match mode.as_deref() { + Some("escaped") => { + record_helper_pid("escaped.pid", fork_escaped()); + 0 + } + _ => HELPER_SETUP_FAILED, + }; + // SAFETY: `_exit` ends the helper without returning to the harness. + unsafe { libc::_exit(code) } +} + +#[test] +fn helper_selector_fails_closed_without_exact_filter_and_nonce() { + let exact = [ + "binary".to_owned(), + "--exact".to_owned(), + HELPER_TEST.to_owned(), + ]; + + assert_eq!( + authorized_mode("escaped:n1", Some("n1"), &exact), + Some("escaped".to_owned()), + "should authorize the exact fixture invocation" + ); + assert_eq!( + authorized_mode("escaped:n1", Some("n2"), &exact), + None, + "should reject a mismatched nonce" + ); + assert_eq!( + authorized_mode("escaped:n1", None, &exact), + None, + "should reject a missing nonce" + ); + assert_eq!( + authorized_mode("escaped:n1", Some("n1"), &exact[..2]), + None, + "should reject a run without the exact helper filter" + ); +} + +fn helper_setup_failed() -> ! { + // SAFETY: `_exit` is async-signal-safe and ends the helper immediately. + unsafe { libc::_exit(HELPER_SETUP_FAILED) } +} + +/// Records `pid`; a pid that could address a group or every process ends the +/// helper instead. +fn record_helper_pid(name: &str, pid: libc::pid_t) { + if pid <= 1 { + helper_setup_failed(); + } + if let Some(directory) = std::env::var_os(HELPER_PIDS) { + let _ = fs::write(Path::new(&directory).join(name), pid.to_string()); + } +} + +/// Forks a child that calls `setsid()` (leaving the process group) while +/// keeping the inherited stdout/stderr open, reports readiness and waits +/// until killed. +fn fork_escaped() -> libc::pid_t { + let mut ready = [0 as libc::c_int; 2]; + // SAFETY: `ready` provides space for the two pipe descriptors. + if unsafe { libc::pipe(ready.as_mut_ptr()) } != 0 { + helper_setup_failed(); + } + let [read_end, write_end] = ready; + // SAFETY: the forked child only calls async-signal-safe functions. + let pid = unsafe { libc::fork() }; + if pid == 0 { + // SAFETY: setsid/write/close/pause are async-signal-safe and the + // descriptors are valid. + unsafe { + libc::setsid(); + libc::write(write_end, b"r".as_ptr().cast(), 1); + libc::close(write_end); + loop { + libc::pause(); + } + } + } + if pid < 0 { + helper_setup_failed(); + } + let mut byte = [0_u8; 1]; + // SAFETY: the descriptors come from `pipe` above and `byte` is writable. + unsafe { + libc::close(write_end); + libc::read(read_end, byte.as_mut_ptr().cast(), 1); + libc::close(read_end); + } + pid +} From f407fe446fcb4daa431bae2ee1834e1ec16664fc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 14:14:27 +0530 Subject: [PATCH 046/153] Add bounded release child-process supervision Introduce the producer's bounded child-process module used by the Task 7 boundary and preparation stages. Commands run with an explicit cleared environment that rejects loader, interpreter, Git, Cargo and npm injection variables and accepts credentials only as redacted secrets. Each child runs in its own process group with wall-clock, cancellation and per-stream output limits. Exit is observed with waitid(WNOWAIT) so the zombie leader pins the group while it is killed, then reaped within a bounded grace; escaped descendants holding pipes fail closed instead of hanging, and signal targets <= 1 are refused. Tests re-execute the test binary as a host-independent helper rather than relying on shell job control. Daemonised descendants that close their stdio remain a documented residual for the OS boundary. --- crates/trusted-server-release/src/error.rs | 2 + crates/trusted-server-release/src/main.rs | 3 + crates/trusted-server-release/src/process.rs | 599 ++++++++++++++++++ .../src/process/environment.rs | 157 +++++ .../src/process/pipes.rs | 159 +++++ .../src/process/redact.rs | 63 ++ .../src/process/supervise.rs | 233 +++++++ .../src/process/tests.rs | 56 ++ .../src/process/tests/fixture.rs | 509 +++++++++++++++ .../src/process/tests/outcomes.rs | 237 +++++++ .../src/process/tests/redaction.rs | 174 +++++ .../src/process/tests/supervision.rs | 298 +++++++++ .../src/process/tests/variables.rs | 202 ++++++ 13 files changed, 2692 insertions(+) create mode 100644 crates/trusted-server-release/src/process.rs create mode 100644 crates/trusted-server-release/src/process/environment.rs create mode 100644 crates/trusted-server-release/src/process/pipes.rs create mode 100644 crates/trusted-server-release/src/process/redact.rs create mode 100644 crates/trusted-server-release/src/process/supervise.rs create mode 100644 crates/trusted-server-release/src/process/tests.rs create mode 100644 crates/trusted-server-release/src/process/tests/fixture.rs create mode 100644 crates/trusted-server-release/src/process/tests/outcomes.rs create mode 100644 crates/trusted-server-release/src/process/tests/redaction.rs create mode 100644 crates/trusted-server-release/src/process/tests/supervision.rs create mode 100644 crates/trusted-server-release/src/process/tests/variables.rs diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index f4486de2d1..cc697b4275 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -18,6 +18,8 @@ pub(crate) enum ReleaseError { Unqualified, #[display("Release inputs are incomplete")] Incomplete, + #[display("Bounded release child process failed")] + Process, } 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 bf0a633473..0f2e427684 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -4,6 +4,9 @@ mod documents; mod error; mod policy; mod preflight; +// `expect` would be unfulfilled in the test build, where the tests use it. +#[allow(dead_code, reason = "consumed by Task 7 boundary and preparation")] +mod process; #[allow(dead_code)] mod source; diff --git a/crates/trusted-server-release/src/process.rs b/crates/trusted-server-release/src/process.rs new file mode 100644 index 0000000000..231e2fe734 --- /dev/null +++ b/crates/trusted-server-release/src/process.rs @@ -0,0 +1,599 @@ +//! Bounded child-process execution. +//! +//! Every child runs from an absolute program path with a fixed argument +//! vector, an explicit working directory and an explicit environment applied +//! after clearing the inherited one. Each child leads a new process group. +//! +//! # Group kill and reaping +//! +//! The supervisor observes the leader's exit with +//! `waitid(WEXITED | WNOHANG | WNOWAIT)`, which leaves the leader as an +//! unreaped zombie. While that zombie pins the process-group ID, the group is +//! sent `SIGKILL` — after a normal exit as well as on deadline, cancellation +//! or output cap — so the signal cannot reach a recycled group. Only then is +//! the leader reaped (polling, bounded by the kill grace, never a blocking +//! wait), and `killpg` is repeated until it reports `ESRCH`. +//! +//! A residual pgid-reuse window remains after reaping: if every member is +//! already gone and the ID is recycled before the confirming `killpg`, that +//! call could reach the new group. Every original member has already been +//! sent `SIGKILL` while the ID was pinned, so this window affects only the +//! confirmation, not the kill. +//! +//! Pipes are drained concurrently under byte caps and every wait is bounded, +//! so a descendant that leaves the group (`setsid`, job control) while still +//! holding the output pipes is reported as +//! [`ProcessFailure::DescendantEscaped`] instead of hanging the producer. +//! +//! # Residuals delegated to the Task 7 OS boundary +//! +//! This module does **not** contain a descendant that calls `setsid()` and +//! closes its stdio (a daemon): it leaves no group membership or pipe for the +//! supervisor to observe, and the run reports only the leader's outcome. The +//! Task 7 boundary must provide that containment — on Linux a container +//! private PID namespace with an init process (`docker run --init`), whose +//! teardown kills every namespace member; on macOS the sandbox profile. +//! macOS has no subreaper, and this module deliberately does not change +//! process-wide state such as `PR_SET_CHILD_SUBREAPER`. +//! +//! # Platform notes +//! +//! - On Linux, signalling a zombie succeeds, so `killpg` keeps returning `0` +//! until init reaps reparented zombie descendants. Containers therefore need +//! a reaping init (`--init`); without one, runs fail with +//! [`ProcessFailure::GroupSurvived`] instead of passing silently. +//! Process-group members cannot be enumerated portably, so the supervisor +//! does not scan `/proc` to treat a zombie-only group as gone; it relies on +//! that reaping init. The Linux test fixtures read `/proc//stat` only +//! to treat an already-killed `Z`-state fixture pid as dead. +//! - XNU's `killpg` reports `EPERM` while a group holds only zombies, so +//! `EPERM` is retried within the kill grace and recorded with its real errno +//! only if it persists. +//! - A persistent genuine `EPERM` and a group that survives `SIGKILL` need +//! privileges or kernel states tests cannot arrange; those paths are +//! unit-tested through their decision functions only. +//! - Process and group IDs `<= 1` are never signalled: `0`, `-1` and `1` +//! would address the supervisor's own group, every process, or init. +//! +//! # Environment +//! +//! The environment denylist is a backstop, not the policy: the Task 7 +//! boundary and preparation callers move to per-tool environment allowlists. +//! Compiler and tool selection (`RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, +//! `CARGO_BUILD_RUSTC`, `CARGO_BUILD_RUSTDOC`, `CARGO_BUILD_RUSTFLAGS` and +//! the wrappers) is denied here; recipes may set compiler flags only through a +//! future reviewed API. +//! +//! Configuration redirectors need the same pinning. `HOME`, `CARGO_HOME` and +//! `XDG_CONFIG_HOME` are accepted here but choose where tools read +//! configuration; `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `CARGO_BUILD_RUSTC` +//! and `CARGO_BUILD_RUSTDOC` select compiler flags and tools and are denied +//! here until that reviewed API exists. Callers' per-tool allowlists (Task 7 +//! boundary and preparation) must pin every such redirector to authenticated +//! private paths. +//! +//! A timeout is the [`ProcessOutcome::TimedOut`] outcome; errnos are recorded +//! only from the operating system and never synthesized. + +mod environment; +mod pipes; +mod redact; +mod supervise; + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::process::{CommandExt as _, ExitStatusExt as _}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use self::environment::{EnvironmentValue, is_credential}; +use self::pipes::{DrainedOutput, Pipes}; +use self::redact::{REDACTED, redact, redact_cut_tail, redacted_text}; +use self::supervise::{KILL_GRACE, Watch, observe, reap_within, settle}; +use crate::error::ReleaseError; + +/// Maximum stderr bytes shown in a failure excerpt (display bound only). +const STDERR_EXCERPT_BYTES: usize = 4 * 1024; + +/// Required per-child limits; there are deliberately no defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProcessLimits { + /// Wall-clock deadline measured from spawn. + pub(crate) wall: Duration, + /// Maximum captured stdout bytes; exceeding it kills the group. + pub(crate) stdout_max: u64, + /// Maximum captured stderr bytes; exceeding it kills the group. + pub(crate) stderr_max: u64, +} + +/// A cancellation flag shared between the caller and running children. +#[derive(Clone, Debug, Default)] +pub(crate) struct Cancellation(Arc); + +impl Cancellation { + /// Requests cancellation of every child observing this flag. + pub(crate) fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } + + /// Returns whether cancellation was requested. + #[must_use] + pub(crate) fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// A captured child output stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum Stream { + #[display("stdout")] + Stdout, + #[display("stderr")] + Stderr, +} + +/// How a supervised child run ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ProcessOutcome { + /// The leader exited normally with `code`. + #[display("exited with status {code}")] + Exited { code: i32 }, + /// The leader was terminated by `signal`. + #[display("was terminated by signal {signal}")] + Signaled { signal: i32 }, + /// The wall-clock deadline expired and the group was killed. + #[display("exceeded its wall-clock deadline")] + TimedOut, + /// Cancellation was requested and the group was killed (or never started). + #[display("was cancelled")] + Cancelled, + /// A stream exceeded its byte cap and the group was killed. + #[display("exceeded the {stream} byte cap")] + OutputLimit { stream: Stream }, +} + +/// Typed classification attached to every [`ReleaseError::Process`] report. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ProcessFailure { + /// The command description was rejected before spawning. + #[display("invalid bounded command")] + InvalidCommand, + /// The operating system refused to start the child. + #[display("spawn failed (errno {errno:?})")] + Spawn { errno: Option }, + /// Observing or reaping the leader failed. + #[display("wait failed (errno {errno:?})")] + Wait { errno: Option }, + /// Signalling the process group failed with something other than `ESRCH`. + #[display("process-group signal failed (errno {errno:?})")] + Signal { errno: Option }, + /// The leader or group still existed after `SIGKILL` and the kill grace. + #[display("process group survived SIGKILL past the grace period")] + GroupSurvived, + /// A descendant left the group and still holds the child pipes. + #[display("a descendant escaped the process group while holding its pipes")] + DescendantEscaped, + /// Reading or writing a child pipe failed. + #[display("child pipe failed (errno {errno:?})")] + Pipe { errno: Option }, + /// The child ran but did not exit with status zero. + #[display("child did not succeed")] + Unsuccessful, +} + +/// A fully specified child command executed under [`ProcessLimits`]. +pub(crate) struct BoundedCommand { + program: PathBuf, + arguments: Vec, + working_directory: PathBuf, + environment: BTreeMap, + stdin: Option>, + limits: ProcessLimits, + cancellation: Option, +} + +fn process_error(failure: ProcessFailure, message: impl fmt::Display) -> Report { + Report::new(ReleaseError::Process) + .attach(failure) + .attach(message.to_string()) +} + +fn invalid(message: impl fmt::Display) -> Report { + process_error(ProcessFailure::InvalidCommand, message) +} + +impl BoundedCommand { + /// Describes a child running `program` in `working_directory`. + /// + /// The command starts with no arguments, an empty environment and a null + /// stdin. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] with [`ProcessFailure::InvalidCommand`] + /// when `program` or `working_directory` is not absolute. + pub(crate) fn new( + program: &Path, + working_directory: &Path, + limits: ProcessLimits, + ) -> Result> { + if !program.is_absolute() { + return Err(invalid("program path is not absolute")); + } + if !working_directory.is_absolute() { + return Err(invalid("working directory is not absolute")); + } + Ok(Self { + program: program.to_path_buf(), + arguments: Vec::new(), + working_directory: working_directory.to_path_buf(), + environment: BTreeMap::new(), + stdin: None, + limits, + cancellation: None, + }) + } + + /// Appends fixed arguments to the argument vector. + #[must_use] + pub(crate) fn arguments(mut self, arguments: I) -> Self + where + I: IntoIterator, + { + self.arguments.extend(arguments); + self + } + + /// Adds a non-secret environment variable. + /// + /// # Errors + /// + /// Returns [`ProcessFailure::InvalidCommand`] for an empty or malformed + /// name, a value containing NUL, a duplicate name, a code or configuration + /// injection variable (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `npm_config_*`, + /// Git configuration and interpreter search paths), or a known credential + /// name, which only [`Self::secret_environment`] may carry. + pub(crate) fn environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + if is_credential(name.as_ref()) { + return Err(invalid(format!( + "credential variable {:?} must be classified as secret", + name.as_ref() + ))); + } + self.insert_environment(name.as_ref(), value.as_ref(), false) + } + + /// Adds an environment variable whose value is redacted from all + /// diagnostics, including captured stderr excerpts. + /// + /// # Errors + /// + /// Returns [`ProcessFailure::InvalidCommand`] for the same reasons as + /// [`Self::environment`] except the credential rule, or for an empty + /// secret value. + pub(crate) fn secret_environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + if value.as_ref().is_empty() { + return Err(invalid(format!( + "secret environment variable {:?} is empty", + name.as_ref() + ))); + } + self.insert_environment(name.as_ref(), value.as_ref(), true) + } + + /// Supplies fixed in-memory bytes on stdin instead of `/dev/null`. + #[must_use] + pub(crate) fn stdin_bytes(mut self, bytes: Vec) -> Self { + self.stdin = Some(bytes); + self + } + + /// Observes `cancellation`; setting it kills and reaps the child group, + /// and a flag set before [`Self::run`] prevents spawning at all. + #[must_use] + pub(crate) fn cancellation(mut self, cancellation: Cancellation) -> Self { + self.cancellation = Some(cancellation); + self + } + + fn secrets(&self) -> Vec> { + self.environment + .values() + .filter(|entry| entry.secret) + .map(|entry| entry.value.as_bytes().to_vec()) + .collect() + } + + fn is_cancelled(&self) -> bool { + self.cancellation + .as_ref() + .is_some_and(Cancellation::is_cancelled) + } + + fn command(&self) -> Command { + let mut command = Command::new(&self.program); + command + .args(&self.arguments) + .current_dir(&self.working_directory) + .env_clear() + .envs( + self.environment + .iter() + .map(|(name, entry)| (name, &entry.value)), + ) + .stdin(if self.stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + command + } + + fn result( + &self, + outcome: ProcessOutcome, + output: DrainedOutput, + started: Instant, + ) -> ProcessResult { + ProcessResult { + program: self.program.clone(), + outcome, + stdout: output.stdout, + stderr: output.stderr, + stderr_truncated: output.stderr_truncated, + elapsed: started.elapsed(), + secrets: self.secrets(), + } + } + + /// Spawns the child and returns it with its usable process-group ID. + fn spawn_group(&self) -> Result<(Child, libc::pid_t), Report> { + let mut child = self.command().spawn().map_err(|source| { + process_error( + ProcessFailure::Spawn { + errno: source.raw_os_error(), + }, + format!("cannot spawn child: {source}"), + ) + })?; + if let Some(process_group) = libc::pid_t::try_from(child.id()) + .ok() + .filter(|pid| *pid > 1) + { + return Ok((child, process_group)); + } + // The ID is unusable for killpg, so only the leader can be stopped. + let mut report = process_error( + ProcessFailure::Spawn { errno: None }, + "child process ID is unusable as a process group", + ); + if let Err(source) = child.kill() { + report = report.attach(format!("cannot kill the child: {source}")); + } + match reap_within(&mut child, KILL_GRACE) { + Ok(Some(_)) => {} + Ok(None) => report = report.attach("child stayed unreaped after the kill grace"), + Err(source) => report = report.attach(format!("cannot reap the child: {source}")), + } + Err(report) + } + + /// Runs the child to an outcome under the configured limits. + /// + /// Deadlines, cancellation and output caps are outcomes, not errors: the + /// group is killed while the leader pins its ID, the leader is reaped and + /// the group confirmed gone before returning. After a normal leader exit + /// the group is killed as well so no same-group descendant outlives the + /// run. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] carrying a [`ProcessFailure`] when the + /// child cannot be spawned (with the real errno), the group cannot be + /// signalled, reaped or does not vanish within the kill grace, a pipe + /// fails, or a descendant escaped the group while holding the child + /// pipes. + pub(crate) fn run(&self) -> Result> { + let started = Instant::now(); + if self.is_cancelled() { + return Ok(self.result(ProcessOutcome::Cancelled, DrainedOutput::default(), started)); + } + let deadline = started + self.limits.wall; + let (mut child, process_group) = self.spawn_group()?; + let Some((pipes, overflow)) = Pipes::start(&mut child, self.stdin.clone(), &self.limits) + else { + settle(&mut child, process_group)?; + return Err(process_error( + ProcessFailure::Pipe { errno: None }, + "child pipes are unavailable", + )); + }; + let watch = Watch { + overflow: &overflow, + deadline, + cancellation: self.cancellation.as_ref(), + }; + let stop = observe(&mut child, process_group, &watch)?; + let status = settle(&mut child, process_group)?; + let drained = pipes.join()?; + let outcome = final_outcome(stop, &overflow, &drained, status)?; + Ok(self.result(outcome, drained, started)) + } +} + +/// Chooses the reported outcome once the group is gone and pipes are drained. +/// +/// A stop condition wins; otherwise a leader that died of `SIGPIPE` after a +/// capped drain closed its pipe still reports the cap, not its own exit. +fn final_outcome( + stop: Option, + overflow: &Receiver, + drained: &DrainedOutput, + status: ExitStatus, +) -> Result> { + if let Some(outcome) = stop { + return Ok(outcome); + } + match overflow.try_recv().ok().or(drained.overflowed) { + Some(stream) => Ok(ProcessOutcome::OutputLimit { stream }), + None => status_outcome(status), + } +} + +impl fmt::Debug for BoundedCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let secrets = self.secrets(); + let text = |bytes: &[u8]| redacted_text(bytes, &secrets); + let arguments: Vec = self + .arguments + .iter() + .map(|argument| text(argument.as_bytes())) + .collect(); + let environment: BTreeMap = self + .environment + .iter() + .map(|(name, entry)| { + let value = if entry.secret { + String::from_utf8_lossy(REDACTED).into_owned() + } else { + text(entry.value.as_bytes()) + }; + (text(name.as_bytes()), value) + }) + .collect(); + formatter + .debug_struct("BoundedCommand") + .field("program", &text(self.program.as_os_str().as_bytes())) + .field("arguments", &arguments) + .field( + "working_directory", + &text(self.working_directory.as_os_str().as_bytes()), + ) + .field("environment", &environment) + .field("stdin_bytes", &self.stdin.as_ref().map(Vec::len)) + .field("limits", &self.limits) + .field("cancellable", &self.cancellation.is_some()) + .finish() + } +} + +/// The bounded result of one supervised child run. +pub(crate) struct ProcessResult { + program: PathBuf, + outcome: ProcessOutcome, + stdout: Vec, + stderr: Vec, + /// Whether the stderr drain stopped at its cap, whatever the reported outcome. + stderr_truncated: bool, + elapsed: Duration, + secrets: Vec>, +} + +impl ProcessResult { + /// Returns how the run ended. + #[must_use] + pub(crate) fn outcome(&self) -> ProcessOutcome { + self.outcome + } + + /// Returns captured stdout, at most the configured cap. + #[must_use] + pub(crate) fn stdout(&self) -> &[u8] { + &self.stdout + } + + /// Returns captured stderr, at most the configured cap. + #[must_use] + pub(crate) fn stderr(&self) -> &[u8] { + &self.stderr + } + + /// Returns wall-clock time from spawn to a confirmed-gone group. + #[must_use] + pub(crate) fn elapsed(&self) -> Duration { + self.elapsed + } + + /// Returns stdout only when the child exited with status zero. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] with [`ProcessFailure::Unsuccessful`] + /// for any other outcome, carrying a bounded, lossy stderr tail with every + /// secret environment value redacted, including a secret prefix cut off by + /// the stderr cap. + pub(crate) fn require_success(self) -> Result, Report> { + if self.outcome == (ProcessOutcome::Exited { code: 0 }) { + return Ok(self.stdout); + } + let redacted = redact(&self.stderr, &self.secrets); + // Only a stderr cut by its cap can end inside a secret; the cut is + // tracked per stream because another stream may have overflowed first. + let redacted = if self.stderr_truncated { + redact_cut_tail(redacted, &self.secrets) + } else { + redacted + }; + let start = redacted.len().saturating_sub(STDERR_EXCERPT_BYTES); + let excerpt = String::from_utf8_lossy(redacted.get(start..).unwrap_or_default()); + Err(process_error( + ProcessFailure::Unsuccessful, + format!( + "{} {}; stderr tail ({} of {} bytes): {excerpt}", + redacted_text(self.program.as_os_str().as_bytes(), &self.secrets), + self.outcome, + redacted.len() - start, + redacted.len() + ), + )) + } +} + +impl fmt::Debug for ProcessResult { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessResult") + .field( + "program", + &redacted_text(self.program.as_os_str().as_bytes(), &self.secrets), + ) + .field("outcome", &self.outcome) + .field("stdout_bytes", &self.stdout.len()) + .field("stderr_bytes", &self.stderr.len()) + .field("elapsed", &self.elapsed) + .finish_non_exhaustive() + } +} + +fn status_outcome(status: ExitStatus) -> Result> { + match (status.code(), status.signal()) { + (Some(code), _) => Ok(ProcessOutcome::Exited { code }), + (None, Some(signal)) => Ok(ProcessOutcome::Signaled { signal }), + (None, None) => Err(process_error( + ProcessFailure::Wait { errno: None }, + format!("unrecognized child status {status:?}"), + )), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/process/environment.rs b/crates/trusted-server-release/src/process/environment.rs new file mode 100644 index 0000000000..89b5ca22d6 --- /dev/null +++ b/crates/trusted-server-release/src/process/environment.rs @@ -0,0 +1,157 @@ +//! Environment validation for [`BoundedCommand`]. +//! +//! The denylists are a backstop; the Task 7 boundary and preparation callers +//! supply per-tool allowlists. + +use std::ffi::{OsStr, OsString}; +use std::os::unix::ffi::OsStrExt as _; + +use error_stack::Report; + +use super::{BoundedCommand, invalid}; +use crate::error::ReleaseError; + +/// Uppercased name prefixes that inject code or configuration into children. +const INJECTION_PREFIXES: [&str; 5] = [ + "DYLD_", + "GIT_CONFIG_KEY_", + "GIT_CONFIG_VALUE_", + "LD_", + "NPM_CONFIG_", +]; +/// Uppercased `CARGO_TARGET__` suffixes that select tools. +const CARGO_TARGET_TOOL_SUFFIXES: [&str; 2] = ["_LINKER", "_RUNNER"]; +/// Uppercased exact names that inject code or configuration into children. +const INJECTION_EXACT: [&str; 42] = [ + "BASH_ENV", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_ENCODED_RUSTFLAGS", + "EDITOR", + "ENV", + "GCONV_PATH", + "GIT_ASKPASS", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_SYSTEM", + "GIT_EDITOR", + "GIT_EXEC_PATH", + "GIT_EXTERNAL_DIFF", + "GIT_PAGER", + "GIT_PROXY_COMMAND", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_TEMPLATE_DIR", + "JAVA_TOOL_OPTIONS", + "NODE_OPTIONS", + "NODE_PATH", + "PAGER", + "PERL5LIB", + "PERL5OPT", + "PYTHONPATH", + "PYTHONSTARTUP", + "RUBYLIB", + "RUBYOPT", + "RUSTC", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTC_WRAPPER", + "RUSTDOC", + "RUSTFLAGS", + "SSH_ASKPASS", + "VISUAL", + "_JAVA_OPTIONS", +]; +/// Uppercased credential name prefixes accepted only as secrets. +const CREDENTIAL_PREFIXES: [&str; 2] = ["AWS_", "FASTLY_"]; +/// Uppercased `CARGO_REGISTRIES__TOKEN` registry credential affixes. +const CARGO_REGISTRIES_TOKEN: (&str, &str) = ("CARGO_REGISTRIES_", "_TOKEN"); +/// Uppercased credential names accepted only as secrets. +const CREDENTIAL_EXACT: [&str; 8] = [ + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "CARGO_REGISTRY_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", + "NODE_AUTH_TOKEN", + "NPM_TOKEN", + "SIGSTORE_ID_TOKEN", +]; + +/// One explicit child environment entry. +#[derive(Clone)] +pub(super) struct EnvironmentValue { + pub(super) value: OsString, + pub(super) secret: bool, +} + +fn upper_name(name: &OsStr) -> String { + name.to_string_lossy().to_ascii_uppercase() +} + +fn is_injection(name: &OsStr) -> bool { + let upper = upper_name(name); + let cargo_target_tool = upper.starts_with("CARGO_TARGET_") + && CARGO_TARGET_TOOL_SUFFIXES + .iter() + .any(|suffix| upper.ends_with(suffix)); + cargo_target_tool + || INJECTION_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix)) + || INJECTION_EXACT.contains(&upper.as_str()) +} + +pub(super) fn is_credential(name: &OsStr) -> bool { + let upper = upper_name(name); + let (registries, token) = CARGO_REGISTRIES_TOKEN; + let registry_token = upper.starts_with(registries) && upper.ends_with(token); + registry_token + || CREDENTIAL_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix)) + || CREDENTIAL_EXACT.contains(&upper.as_str()) +} + +impl BoundedCommand { + pub(super) fn insert_environment( + mut self, + name: &OsStr, + value: &OsStr, + secret: bool, + ) -> Result> { + let name_bytes = name.as_bytes(); + if name_bytes.is_empty() || name_bytes.contains(&b'=') || name_bytes.contains(&0) { + return Err(invalid(format!( + "environment variable name {name:?} is malformed" + ))); + } + if value.as_bytes().contains(&0) { + return Err(invalid(format!( + "environment variable {name:?} value contains NUL" + ))); + } + if is_injection(name) { + return Err(invalid(format!( + "environment variable {name:?} injects code or configuration" + ))); + } + if self.environment.contains_key(name) { + return Err(invalid(format!( + "environment variable {name:?} is set twice" + ))); + } + self.environment.insert( + name.to_os_string(), + EnvironmentValue { + value: value.to_os_string(), + secret, + }, + ); + Ok(self) + } +} diff --git a/crates/trusted-server-release/src/process/pipes.rs b/crates/trusted-server-release/src/process/pipes.rs new file mode 100644 index 0000000000..7d2df2e256 --- /dev/null +++ b/crates/trusted-server-release/src/process/pipes.rs @@ -0,0 +1,159 @@ +//! Bounded concurrent draining of child pipes. + +use std::io::{self, Read, Write as _}; +use std::process::Child; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::Instant; + +use error_stack::Report; + +use super::supervise::{KILL_GRACE, POLL_INTERVAL}; +use super::{ProcessFailure, ProcessLimits, Stream, process_error}; +use crate::error::ReleaseError; + +/// Pipe read size for the drain threads. +const READ_CHUNK: usize = 64 * 1024; + +/// Bytes read from one stream before EOF or the cap. +struct Drained { + bytes: Vec, + overflowed: bool, +} + +/// Captured output of both streams after every pipe thread finished. +#[derive(Default)] +pub(super) struct DrainedOutput { + pub(super) stdout: Vec, + pub(super) stderr: Vec, + pub(super) overflowed: Option, + pub(super) stderr_truncated: bool, +} + +/// Drain and stdin-writer threads attached to one child. +pub(super) struct Pipes { + stdout: JoinHandle>, + stderr: JoinHandle>, + stdin: Option>>, +} + +impl Pipes { + pub(super) fn start( + child: &mut Child, + stdin_bytes: Option>, + limits: &ProcessLimits, + ) -> Option<(Self, Receiver)> { + let stdout = child.stdout.take()?; + let stderr = child.stderr.take()?; + let stdin = match stdin_bytes { + Some(bytes) => Some((child.stdin.take()?, bytes)), + None => None, + }; + let (sender, overflow) = mpsc::channel(); + let pipes = Self { + stdout: drain(stdout, Stream::Stdout, limits.stdout_max, sender.clone()), + stderr: drain(stderr, Stream::Stderr, limits.stderr_max, sender), + stdin: stdin.map(|(mut pipe, bytes)| { + thread::spawn(move || match pipe.write_all(&bytes) { + // A child that stops reading early is not a pipe failure. + Err(source) if source.kind() == io::ErrorKind::BrokenPipe => Ok(()), + other => other, + }) + }), + }; + Some((pipes, overflow)) + } + + fn finished(&self) -> bool { + self.stdout.is_finished() + && self.stderr.is_finished() + && self.stdin.as_ref().is_none_or(JoinHandle::is_finished) + } + + /// Joins every pipe thread within [`KILL_GRACE`]. + /// + /// Threads still blocked after the grace stay detached: they hold only a + /// pipe end that the escaped descendant keeps open. + pub(super) fn join(self) -> Result> { + let until = Instant::now() + KILL_GRACE; + while !self.finished() { + if Instant::now() >= until { + return Err(process_error( + ProcessFailure::DescendantEscaped, + "child pipes stayed open after the process group was gone; \ + output is rejected", + )); + } + thread::sleep(POLL_INTERVAL); + } + if let Some(stdin) = self.stdin { + joined(stdin)?; + } + let stdout = joined(self.stdout)?; + let stderr = joined(self.stderr)?; + let overflowed = if stdout.overflowed { + Some(Stream::Stdout) + } else if stderr.overflowed { + Some(Stream::Stderr) + } else { + None + }; + Ok(DrainedOutput { + stdout: stdout.bytes, + stderr: stderr.bytes, + overflowed, + stderr_truncated: stderr.overflowed, + }) + } +} + +fn joined(handle: JoinHandle>) -> Result> { + handle + .join() + .map_err(|_| process_error(ProcessFailure::Pipe { errno: None }, "pipe thread panicked"))? + .map_err(|source| { + process_error( + ProcessFailure::Pipe { + errno: source.raw_os_error(), + }, + format!("child pipe failed: {source}"), + ) + }) +} + +fn drain( + mut reader: R, + stream: Stream, + limit: u64, + overflow: Sender, +) -> JoinHandle> { + thread::spawn(move || { + let mut bytes = Vec::new(); + let mut buffer = vec![0; READ_CHUNK]; + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(source) if source.kind() == io::ErrorKind::Interrupted => continue, + Err(source) => return Err(source), + }; + if count == 0 { + return Ok(Drained { + bytes, + overflowed: false, + }); + } + let captured = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let remaining = usize::try_from(limit.saturating_sub(captured)).unwrap_or(usize::MAX); + if count > remaining { + bytes.extend_from_slice(buffer.get(..remaining).unwrap_or_default()); + // Dropping the reader closes the pipe; the supervisor kills the group. + let _ = overflow.send(stream); + return Ok(Drained { + bytes, + overflowed: true, + }); + } + bytes.extend_from_slice(buffer.get(..count).unwrap_or_default()); + } + }) +} diff --git a/crates/trusted-server-release/src/process/redact.rs b/crates/trusted-server-release/src/process/redact.rs new file mode 100644 index 0000000000..c14b3fe07c --- /dev/null +++ b/crates/trusted-server-release/src/process/redact.rs @@ -0,0 +1,63 @@ +//! Secret redaction for diagnostics. + +/// Replacement text for secret values in diagnostics. +pub(super) const REDACTED: &[u8] = b"[REDACTED]"; + +/// Replaces the union of every secret occurrence with one marker per span. +/// +/// Matches are found on the original bytes and merged before replacement, so +/// overlapping or adjacent secrets cannot leave fragments behind. +pub(super) fn redact(bytes: &[u8], secrets: &[Vec]) -> Vec { + let mut ranges: Vec<(usize, usize)> = secrets + .iter() + .filter(|secret| !secret.is_empty()) + .flat_map(|secret| { + bytes + .windows(secret.len()) + .enumerate() + .filter(move |(_, window)| window == secret) + .map(move |(start, _)| (start, start + secret.len())) + }) + .collect(); + ranges.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + let mut output = Vec::with_capacity(bytes.len()); + let mut cursor = 0; + for (start, end) in merged { + output.extend_from_slice(bytes.get(cursor..start).unwrap_or_default()); + output.extend_from_slice(REDACTED); + cursor = end; + } + output.extend_from_slice(bytes.get(cursor..).unwrap_or_default()); + output +} + +/// Replaces a trailing proper prefix of any secret, as left by a byte cap +/// that cut the stream inside the secret. +pub(super) fn redact_cut_tail(mut bytes: Vec, secrets: &[Vec]) -> Vec { + let cut = secrets + .iter() + .flat_map(|secret| { + (1..secret.len()).filter(|&length| { + secret + .get(..length) + .is_some_and(|prefix| bytes.ends_with(prefix)) + }) + }) + .max(); + if let Some(length) = cut { + bytes.truncate(bytes.len() - length); + bytes.extend_from_slice(REDACTED); + } + bytes +} + +pub(super) fn redacted_text(bytes: &[u8], secrets: &[Vec]) -> String { + String::from_utf8_lossy(&redact(bytes, secrets)).into_owned() +} diff --git a/crates/trusted-server-release/src/process/supervise.rs b/crates/trusted-server-release/src/process/supervise.rs new file mode 100644 index 0000000000..c356bfdca1 --- /dev/null +++ b/crates/trusted-server-release/src/process/supervise.rs @@ -0,0 +1,233 @@ +//! Leader observation, process-group kill and bounded reaping. + +use std::io; +use std::process::{Child, ExitStatus}; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use super::{Cancellation, ProcessFailure, ProcessOutcome, Stream, process_error}; +use crate::error::ReleaseError; + +/// Bounded wait for reaping, group disappearance and pipe closure after a kill. +/// +/// This is a supervisor liveness bound, not a release policy value: a killed +/// group closes its pipes promptly, so anything still holding them after this +/// grace has escaped the group. +pub(super) const KILL_GRACE: Duration = Duration::from_secs(2); +/// Supervisor polling interval for leader status, deadlines and cancellation. +pub(super) const POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Stop conditions observed while the leader runs. +pub(super) struct Watch<'a> { + pub(super) overflow: &'a Receiver, + pub(super) deadline: Instant, + pub(super) cancellation: Option<&'a Cancellation>, +} + +/// Returns whether the leader has exited, leaving it unreaped. +/// +/// `WNOWAIT` keeps the zombie, so the process-group ID stays pinned until +/// [`settle`] has signalled the group. +fn leader_exited(pid: libc::pid_t) -> io::Result { + let id = libc::id_t::try_from(pid) + .ok() + .filter(|_| pid > 1) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "unusable child pid"))?; + // SAFETY: `siginfo_t` is plain integer and pointer data; all-zero is a + // valid value and lets `si_pid == 0` signal "no state change". + let mut info: libc::siginfo_t = unsafe { core::mem::zeroed() }; + // SAFETY: `info` is a valid, writable `siginfo_t` for the call's duration. + let result = unsafe { + libc::waitid( + libc::P_PID, + id, + &raw mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + let source = io::Error::last_os_error(); + if source.kind() == io::ErrorKind::Interrupted { + return Ok(false); + } + return Err(source); + } + // SAFETY: `waitid` filled `info` (or left it zeroed under `WNOHANG`). + Ok(unsafe { info.si_pid() } != 0) +} + +/// Polls until the leader exits (`None`) or a stop condition fires. +fn wait_for_leader(pid: libc::pid_t, watch: &Watch<'_>) -> io::Result> { + loop { + if let Ok(stream) = watch.overflow.try_recv() { + return Ok(Some(ProcessOutcome::OutputLimit { stream })); + } + if leader_exited(pid)? { + return Ok(None); + } + if watch.cancellation.is_some_and(Cancellation::is_cancelled) { + return Ok(Some(ProcessOutcome::Cancelled)); + } + if Instant::now() >= watch.deadline { + return Ok(Some(ProcessOutcome::TimedOut)); + } + match watch.overflow.recv_timeout(POLL_INTERVAL) { + Ok(stream) => return Ok(Some(ProcessOutcome::OutputLimit { stream })), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => thread::sleep(POLL_INTERVAL), + } + } +} + +pub(super) fn signal_group(process_group: libc::pid_t) -> io::Result<()> { + // 0, -1 and 1 would address this supervisor's group, every process or + // init; refuse them without inventing an errno. + if process_group <= 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to signal a non-child process group", + )); + } + // SAFETY: `killpg` only reads its two integer arguments; it touches no + // memory owned by this process. + let result = unsafe { libc::killpg(process_group, libc::SIGKILL) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +fn signal_failure(source: &io::Error) -> Report { + process_error( + ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + format!("killpg failed: {source}"), + ) +} + +/// Returns whether `source` may be XNU's transient refusal to signal a group +/// that holds only zombies. +fn is_transient_refusal(source: &io::Error) -> bool { + source.raw_os_error() == Some(libc::EPERM) +} + +/// Polls for the leader's exit status for at most `grace`; never blocks. +/// +/// Returns `Ok(None)` when the leader is still unreaped after the grace. +pub(super) fn reap_within(child: &mut Child, grace: Duration) -> io::Result> { + let until = Instant::now() + grace; + loop { + if let Some(status) = child.try_wait()? { + return Ok(Some(status)); + } + if Instant::now() >= until { + return Ok(None); + } + thread::sleep(POLL_INTERVAL); + } +} + +/// Classifies a leader that stayed unreaped after `SIGKILL` and the grace, +/// keeping the real errno of a refused group signal. +pub(super) fn unreaped_failure(refusal: Option<&io::Error>) -> ProcessFailure { + match refusal { + Some(source) => ProcessFailure::Signal { + errno: source.raw_os_error(), + }, + None => ProcessFailure::GroupSurvived, + } +} + +/// Kills the group while the unreaped leader pins its ID, reaps the leader +/// within the grace, then confirms the group is gone. +pub(super) fn settle( + child: &mut Child, + process_group: libc::pid_t, +) -> Result> { + let refusal = signal_group(process_group) + .err() + .filter(|source| source.raw_os_error() != Some(libc::ESRCH)); + // A refused group signal may be XNU's zombie-only EPERM; signal the + // leader directly as well so a live leader still dies. A failure here + // matters only if the leader then stays unreaped, where it is reported. + let direct = refusal.as_ref().and_then(|_| child.kill().err()); + let status = reap_within(child, KILL_GRACE).map_err(|source| { + process_error( + ProcessFailure::Wait { + errno: source.raw_os_error(), + }, + format!("cannot reap child: {source}"), + ) + })?; + let Some(status) = status else { + let message = match &refusal { + Some(source) => format!("child stayed unreaped after killpg failed: {source}"), + None => "child stayed unreaped after SIGKILL".to_owned(), + }; + let report = process_error(unreaped_failure(refusal.as_ref()), message); + return Err(match direct { + Some(source) => report.attach(format!("direct leader kill also failed: {source}")), + None => report, + }); + }; + kill_group_until_gone(process_group)?; + Ok(status) +} + +/// Sends `SIGKILL` to the group until `killpg` reports `ESRCH`. +/// +/// This runs after the leader is reaped, so the group ID is no longer pinned: +/// if every member is already gone and the ID is recycled before a call +/// here, that call could reach the new group. Every original member was +/// already sent `SIGKILL` by [`settle`] while the ID was pinned, so the window +/// affects only this confirmation. +fn kill_group_until_gone(process_group: libc::pid_t) -> Result<(), Report> { + let until = Instant::now() + KILL_GRACE; + loop { + let expired = Instant::now() >= until; + match signal_group(process_group) { + Err(source) if source.raw_os_error() == Some(libc::ESRCH) => return Ok(()), + Err(source) if expired || !is_transient_refusal(&source) => { + return Err(signal_failure(&source)); + } + Ok(()) if expired => { + return Err(process_error( + ProcessFailure::GroupSurvived, + format!("process group {process_group} survived SIGKILL"), + )); + } + Ok(()) | Err(_) => thread::sleep(POLL_INTERVAL), + } + } +} + +/// Waits for the leader's exit or a stop condition. +/// +/// When observation itself fails, the group is still settled before the +/// error is returned, and a cleanup failure is attached to it. +pub(super) fn observe( + child: &mut Child, + process_group: libc::pid_t, + watch: &Watch<'_>, +) -> Result, Report> { + match wait_for_leader(process_group, watch) { + Ok(stop) => Ok(stop), + Err(source) => { + let report = process_error( + ProcessFailure::Wait { + errno: source.raw_os_error(), + }, + format!("cannot observe child: {source}"), + ); + Err(match settle(child, process_group) { + Ok(_) => report, + Err(cleanup) => report.attach(format!("cleanup also failed: {cleanup:?}")), + }) + } + } +} diff --git a/crates/trusted-server-release/src/process/tests.rs b/crates/trusted-server-release/src/process/tests.rs new file mode 100644 index 0000000000..70fd1d5a4f --- /dev/null +++ b/crates/trusted-server-release/src/process/tests.rs @@ -0,0 +1,56 @@ +//! Process supervision tests. +//! +//! Fixtures are host-independent: instead of ambient shells or coreutils, +//! each child re-executes this test binary in a hidden helper mode selected +//! by [`HELPER_SELECTOR`] (see [`helper_entry`] and [`fixture`]). The helper +//! implements every process shape with std and libc only. +//! +//! The helper fails closed: a selector without the exact helper filter and a +//! matching per-run nonce fails the test run instead of acting or skipping. +//! +//! Genuine `EPERM` group-signal refusals and a group that survives +//! `SIGKILL` need privileges or kernel states a test cannot arrange, so those +//! paths are covered only by unit tests of [`reap_within`] and +//! [`unreaped_failure`], not end to end. + +mod fixture; +mod outcomes; +mod redaction; +mod supervision; +mod variables; + +use std::io; +use std::process::Command as StdCommand; +use std::thread; + +use self::fixture::{ + CUT_KEPT, CUT_PADDING, CUT_SECRET, HELPER_NONCE, HELPER_PIDS, HELPER_SELECTOR, + HELPER_SETUP_FAILED, HELPER_TEST, LARGE_STDERR, OVERLAP_FIRST, OVERLAP_SECOND, SECRET, SLACK, + TIMEOUT_WALL, authorized_mode, failure, force_kill, harness_arguments, helper, helper_selector, + is_alive, is_dead_soon, limits, payload, read_pid, run_helper, supervision_bound, test_binary, + unauthorized_helper_run, workspace, +}; +use super::supervise::{signal_group, unreaped_failure}; +use super::*; + +/// Hidden re-exec entry point; returns immediately in normal test runs. +/// +/// A selector present without the fixture's exact filter and nonce fails the +/// run rather than silently acting or passing. +#[test] +fn helper_entry() { + let Some(selector) = std::env::var_os(HELPER_SELECTOR) else { + return; + }; + let nonce = std::env::var(HELPER_NONCE).ok(); + let arguments: Vec = std::env::args().collect(); + let mode = authorized_mode(&selector.to_string_lossy(), nonce.as_deref(), &arguments); + assert!( + mode.is_some(), + "should run helper mode only from a fixture-launched exact helper invocation" + ); + let code = mode.map_or(HELPER_SETUP_FAILED, |mode| run_helper(&mode)); + // SAFETY: `_exit` ends the helper without returning to the harness, so no + // harness summary is appended to the captured output. + unsafe { libc::_exit(code) } +} diff --git a/crates/trusted-server-release/src/process/tests/fixture.rs b/crates/trusted-server-release/src/process/tests/fixture.rs new file mode 100644 index 0000000000..55b00011d6 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/fixture.rs @@ -0,0 +1,509 @@ +//! Host-independent child fixtures and test-side utilities. +//! +//! Helper modes run inside a re-executed test binary (see +//! [`super::helper_entry`]) using std and libc only. Forked helper children +//! call only async-signal-safe functions. + +use std::io::Read as _; +use std::process::Command as StdCommand; +use std::sync::atomic::AtomicU64; +use std::thread; + +use tempfile::TempDir; + +use super::*; + +/// Environment selector `:`; absent in normal test runs. +pub(super) const HELPER_SELECTOR: &str = "TS_RELEASE_PROCESS_HELPER"; +/// Environment variable carrying the nonce the selector must match. +pub(super) const HELPER_NONCE: &str = "TS_RELEASE_PROCESS_NONCE"; +/// Exact libtest name of [`super::helper_entry`]. +pub(super) const HELPER_TEST: &str = "process::tests::helper_entry"; +/// Exit status of a helper whose fork, pipe or pid-file setup failed. +pub(super) const HELPER_SETUP_FAILED: i32 = 90; +/// Exit status of an unknown helper mode. +const HELPER_UNKNOWN_MODE: i32 = 97; +/// Environment variable naming the directory where helpers record pids. +pub(super) const HELPER_PIDS: &str = "TS_RELEASE_PROCESS_PIDS"; +/// How long sleeping helpers wait; every supervision bound must stay below it. +pub(super) const HELPER_SLEEP: Duration = Duration::from_secs(30); +/// Marks the start of helper stdout after the test harness preamble. +pub(super) const SENTINEL: &[u8] = b"\x1eTS-PROCESS-HELPER\x1e"; +pub(super) const SECRET: &str = "fixture-secret-value-0451"; +pub(super) const CUT_SECRET: &str = "Qz7-secret-Wq9"; +pub(super) const CUT_PADDING: usize = 100; +pub(super) const CUT_KEPT: usize = 6; +pub(super) const OVERLAP_FIRST: &str = "Kq1Vv8Mm"; +pub(super) const OVERLAP_SECOND: &str = "Vv8MmJx3"; +const OVERLAP_TEXT: &str = "Kq1Vv8MmJx3"; +/// Stderr written before any stdout; far above a pipe buffer. +pub(super) const LARGE_STDERR: usize = 1024 * 1024 + 17; +/// Headroom for harness startup and scheduling in wall-time assertions. +pub(super) const SLACK: Duration = Duration::from_secs(10); +/// Wall deadline for timeout tests; long enough for helpers to record pids. +pub(super) const TIMEOUT_WALL: Duration = Duration::from_secs(2); + +// --------------------------------------------------------------------------- +// Helper authorization and modes (run inside the re-executed child) +// --------------------------------------------------------------------------- + +/// Returns the helper mode when the selector, nonce and argv all match a +/// fixture-launched helper run. +pub(super) fn authorized_mode( + selector: &str, + nonce: Option<&str>, + arguments: &[String], +) -> Option { + let (mode, selector_nonce) = selector.rsplit_once(':')?; + let exact = arguments.iter().any(|argument| argument == "--exact"); + let filtered = arguments.iter().any(|argument| argument == HELPER_TEST); + let matched = nonce.is_some_and(|nonce| !nonce.is_empty() && nonce == selector_nonce); + (exact && filtered && matched).then(|| mode.to_owned()) +} + +/// Runs helper `mode` and returns its exit status. +pub(super) fn run_helper(mode: &str) -> i32 { + output_mode(mode) + .or_else(|| group_mode(mode)) + .or_else(|| secret_mode(mode)) + .unwrap_or(HELPER_UNKNOWN_MODE) +} + +/// Modes that only write fixed output and exit. +fn output_mode(mode: &str) -> Option { + match mode { + "print" => { + write_stream(1, &[SENTINEL, b"out\nline"].concat()); + write_stream(2, b"err text"); + Some(0) + } + "stderr-then-stdout" => { + write_stream(2, &[b'e'; LARGE_STDERR]); + write_stream(1, &[SENTINEL, b"small"].concat()); + Some(0) + } + "bulk-stderr" => { + write_stream(2, &b"abcdefghij".repeat(2000)); + write_stream(2, b"TAIL"); + Some(1) + } + "env" => { + let mut lines: Vec> = std::env::vars_os() + .map(|(name, value)| [name.as_bytes(), b"=", value.as_bytes(), b"\n"].concat()) + .collect(); + lines.sort(); + write_stream(1, &[SENTINEL, &lines.concat()].concat()); + Some(0) + } + "cat" => { + let mut input = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut input); + write_stream(1, &[SENTINEL, &input, b"|done"].concat()); + Some(0) + } + other => other + .strip_prefix("exit:") + .and_then(|code| code.parse().ok()) + .inspect(|_| write_stream(2, b"diagnostic detail")), + } +} + +/// Modes that shape the process group: sleepers, descendants, floods, +/// escapes and signals. Pids are recorded before anything else happens. +fn group_mode(mode: &str) -> Option { + match mode { + "raise" => { + // SAFETY: raising SIGTERM with its default disposition ends the helper. + unsafe { + libc::raise(libc::SIGTERM); + } + Some(0) + } + "sleep" => { + record_pid("leader.pid", own_pid()); + thread::sleep(HELPER_SLEEP); + Some(0) + } + "linger" => { + record_pid("grandchild.pid", fork_paused()); + Some(0) + } + "group-sleep" => { + record_pid("leader.pid", own_pid()); + record_pid("grandchild.pid", fork_paused()); + thread::sleep(HELPER_SLEEP); + Some(0) + } + "flood-both" => { + record_pid("grandchild.pid", fork_flood(2)); + Some(flood(1)) + } + "flood-stderr" => Some(flood(2)), + "escape" => { + record_pid("escaped.pid", fork_escaped(false)); + Some(0) + } + "daemon" => { + record_pid("escaped.pid", fork_escaped(true)); + Some(0) + } + _ => None, + } +} + +/// Modes that echo secret-classified environment values. +fn secret_mode(mode: &str) -> Option { + match mode { + "secret" => { + let secret = read_secret(); + write_stream(2, &[b"leak:", secret.as_slice(), b":"].concat()); + write_stream(1, &secret); + Some(3) + } + "secret-cut" => { + write_stream( + 2, + &[&b"x".repeat(CUT_PADDING), read_secret().as_slice()].concat(), + ); + Some(1) + } + "overlap" => { + write_stream(2, OVERLAP_TEXT.as_bytes()); + Some(1) + } + _ => None, + } +} + +fn read_secret() -> Vec { + std::env::var_os("TOKEN") + .map(|value| value.as_bytes().to_vec()) + .unwrap_or_default() +} + +fn write_stream(fd: libc::c_int, bytes: &[u8]) { + let mut rest = bytes; + while !rest.is_empty() { + // SAFETY: `rest` is a valid initialized buffer of `rest.len()` bytes. + let written = unsafe { libc::write(fd, rest.as_ptr().cast(), rest.len()) }; + let Ok(written) = usize::try_from(written) else { + return; + }; + rest = rest.get(written..).unwrap_or_default(); + } +} + +/// Writes to `fd` until the write fails, then returns exit status zero. +fn flood(fd: libc::c_int) -> i32 { + let chunk = [b'o'; 4096]; + loop { + // SAFETY: `chunk` is a valid initialized buffer. + if unsafe { libc::write(fd, chunk.as_ptr().cast(), chunk.len()) } < 0 { + return 0; + } + } +} + +fn setup_failed() -> ! { + // SAFETY: `_exit` is async-signal-safe and ends the helper immediately. + unsafe { libc::_exit(HELPER_SETUP_FAILED) } +} + +/// Records `pid`; a pid that could address a process group or every process +/// (`<= 1`) or an unwritable pid file ends the helper instead. +fn record_pid(name: &str, pid: libc::pid_t) { + if pid <= 1 { + setup_failed(); + } + let Some(directory) = std::env::var_os(HELPER_PIDS) else { + setup_failed(); + }; + if std::fs::write(Path::new(&directory).join(name), pid.to_string()).is_err() { + setup_failed(); + } +} + +fn own_pid() -> libc::pid_t { + // SAFETY: `getpid` has no preconditions. + unsafe { libc::getpid() } +} + +fn pause_forever() -> ! { + loop { + // SAFETY: `pause` is async-signal-safe and has no preconditions. + unsafe { + libc::pause(); + } + } +} + +/// Forks a same-group child that waits until killed. +fn fork_paused() -> libc::pid_t { + // SAFETY: the forked child only calls the async-signal-safe `pause`. + let pid = unsafe { libc::fork() }; + if pid == 0 { + pause_forever(); + } + if pid < 0 { + setup_failed(); + } + pid +} + +/// Forks a same-group child that floods `fd` until the write fails. +/// +/// Each 4 KiB write is followed by a 1 ms pause so the flood stays far below +/// a 64 MiB cap even when the leader is descheduled under parallel tests. +fn fork_flood(fd: libc::c_int) -> libc::pid_t { + // SAFETY: the forked child only calls async-signal-safe `write`, + // `nanosleep` and `_exit`. + let pid = unsafe { libc::fork() }; + if pid == 0 { + let chunk = [b'e'; 4096]; + let pause = libc::timespec { + tv_sec: 0, + tv_nsec: 1_000_000, + }; + loop { + // SAFETY: `chunk` and `pause` are valid for the calls' duration. + unsafe { + if libc::write(fd, chunk.as_ptr().cast(), chunk.len()) < 0 { + libc::_exit(0); + } + libc::nanosleep(&raw const pause, core::ptr::null_mut()); + } + } + } + if pid < 0 { + setup_failed(); + } + pid +} + +/// Waits for the one-byte readiness signal, retrying `EINTR`. +fn await_ready(read_end: libc::c_int) { + let mut byte = [0_u8; 1]; + loop { + // SAFETY: `read_end` comes from `pipe` and `byte` is writable. + let count = unsafe { libc::read(read_end, byte.as_mut_ptr().cast(), 1) }; + if count == 1 { + return; + } + if count < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + setup_failed(); + } +} + +/// Forks a child that calls `setsid()` (leaving the process group) and +/// optionally closes its stdio, then reports readiness and waits forever. +fn fork_escaped(close_stdio: bool) -> libc::pid_t { + let mut ready = [0 as libc::c_int; 2]; + // SAFETY: `ready` provides space for the two pipe descriptors. + if unsafe { libc::pipe(ready.as_mut_ptr()) } != 0 { + setup_failed(); + } + let [read_end, write_end] = ready; + // SAFETY: the forked child only calls async-signal-safe functions. + let pid = unsafe { libc::fork() }; + if pid == 0 { + // SAFETY: setsid/close/write are async-signal-safe; descriptors are valid. + unsafe { + libc::setsid(); + if close_stdio { + libc::close(0); + libc::close(1); + libc::close(2); + } + libc::write(write_end, b"r".as_ptr().cast(), 1); + libc::close(write_end); + } + pause_forever(); + } + if pid < 0 { + setup_failed(); + } + // SAFETY: `write_end` comes from `pipe` above and is no longer needed here. + unsafe { + libc::close(write_end); + } + await_ready(read_end); + // SAFETY: `read_end` comes from `pipe` above. + unsafe { + libc::close(read_end); + } + pid +} + +// --------------------------------------------------------------------------- +// Test-side utilities (run in the supervising test) +// --------------------------------------------------------------------------- + +pub(super) fn limits(wall: Duration) -> ProcessLimits { + ProcessLimits { + wall, + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +/// Upper bound for a supervised run: its wall deadline, the reap, group and +/// pipe graces, plus [`SLACK`] for harness startup. +pub(super) fn supervision_bound(wall: Duration) -> Duration { + let bound = wall + KILL_GRACE * 3 + SLACK; + assert!( + bound < HELPER_SLEEP, + "should keep supervision bounds below the helper sleep" + ); + bound +} + +pub(super) fn workspace() -> TempDir { + tempfile::tempdir().expect("should create a private test workspace") +} + +pub(super) fn test_binary() -> PathBuf { + std::env::current_exe().expect("should locate the test binary") +} + +pub(super) fn harness_arguments() -> Vec { + ["--exact", HELPER_TEST, "--nocapture", "--test-threads=1"] + .into_iter() + .map(OsString::from) + .collect() +} + +/// Returns a fresh per-launch nonce and the matching `:` selector. +pub(super) fn helper_selector(mode: &str) -> (String, String) { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let sequence = COUNTER.fetch_add(1, Ordering::SeqCst); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let nonce = format!("{}-{sequence}-{nanos}", std::process::id()); + let selector = format!("{mode}:{nonce}"); + (nonce, selector) +} + +/// Builds a bounded re-exec of this test binary in helper `mode`. +pub(super) fn helper( + workspace: &TempDir, + mode: &str, + process_limits: ProcessLimits, +) -> BoundedCommand { + let (nonce, selector) = helper_selector(mode); + BoundedCommand::new(&test_binary(), workspace.path(), process_limits) + .expect("should accept an absolute program") + .arguments(harness_arguments()) + .environment(HELPER_SELECTOR, selector) + .expect("should accept the helper selector") + .environment(HELPER_NONCE, nonce) + .expect("should accept the helper nonce") + .environment(HELPER_PIDS, workspace.path()) + .expect("should accept the helper pid directory") +} + +/// Runs this test binary with a helper selector but `arguments` and `nonce` +/// that a fixture would not use; returns whether the run succeeded. +pub(super) fn unauthorized_helper_run(arguments: &[&str], nonce: Option<&str>) -> bool { + let dir = workspace(); + let mut command = StdCommand::new(test_binary()); + command + .args(arguments) + .env_clear() + .env(HELPER_SELECTOR, "print:fixture-nonce") + .env(HELPER_PIDS, dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(nonce) = nonce { + command.env(HELPER_NONCE, nonce); + } + command + .status() + .expect("should run the test binary") + .success() +} + +/// Returns helper stdout after the harness preamble. +pub(super) fn payload(stdout: &[u8]) -> &[u8] { + stdout + .windows(SENTINEL.len()) + .position(|window| window == SENTINEL) + .and_then(|start| stdout.get(start + SENTINEL.len()..)) + .unwrap_or_default() +} + +/// Refuses pids that would address a process group or every process. +fn assert_single_process(pid: libc::pid_t) { + assert!( + pid > 1, + "should only signal a single fixture pid, got {pid}" + ); +} + +pub(super) fn read_pid(path: &Path) -> libc::pid_t { + let deadline = Instant::now() + SLACK; + loop { + let text = std::fs::read_to_string(path).unwrap_or_default(); + if let Ok(pid) = text.trim().parse::() { + assert_single_process(pid); + return pid; + } + assert!( + Instant::now() < deadline, + "should observe a pid written to {}", + path.display() + ); + thread::sleep(Duration::from_millis(10)); + } +} + +/// Returns whether Linux reports `pid` as a zombie (`Z` in `/proc//stat`). +/// +/// Elsewhere there is no `/proc`, the read fails and this returns `false`. +fn is_zombie(pid: libc::pid_t) -> bool { + std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|stat| { + stat.rfind(')') + .and_then(|end| stat.get(end + 1..)) + .map(|rest| rest.trim_start().starts_with('Z')) + }) + .unwrap_or(false) +} + +/// Returns whether `pid` is a live (non-zombie) process. +pub(super) fn is_alive(pid: libc::pid_t) -> bool { + assert_single_process(pid); + // SAFETY: `kill` with signal 0 only performs a permission and existence check. + let result = unsafe { libc::kill(pid, 0) }; + let exists = result == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH); + exists && !is_zombie(pid) +} + +/// Waits briefly for an already-killed, reparented pid to disappear. +pub(super) fn is_dead_soon(pid: libc::pid_t) -> bool { + assert_single_process(pid); + let deadline = Instant::now() + KILL_GRACE; + while is_alive(pid) { + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(10)); + } + true +} + +pub(super) fn force_kill(pid: libc::pid_t) { + assert_single_process(pid); + // SAFETY: `kill` only reads its integer arguments; `pid` is one fixture process. + unsafe { + libc::kill(pid, libc::SIGKILL); + } +} + +pub(super) fn failure(report: &Report) -> Option<&ProcessFailure> { + report.downcast_ref::() +} diff --git a/crates/trusted-server-release/src/process/tests/outcomes.rs b/crates/trusted-server-release/src/process/tests/outcomes.rs new file mode 100644 index 0000000000..5a38ccba27 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/outcomes.rs @@ -0,0 +1,237 @@ +//! Outcome classification, output capture, stdin and helper authorization. + +use super::*; + +#[test] +fn success_captures_stdout_and_stderr_exactly() { + let dir = workspace(); + + let result = helper(&dir, "print", limits(SLACK)) + .run() + .expect("should run a successful child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit zero" + ); + assert_eq!( + payload(result.stdout()), + b"out\nline", + "should capture stdout exactly" + ); + assert_eq!( + result.stderr(), + b"err text", + "should capture stderr exactly" + ); + assert!( + result.elapsed() < SLACK, + "should report a bounded elapsed time" + ); + let stdout = result.require_success().expect("should accept exit zero"); + assert_eq!(payload(&stdout), b"out\nline", "should return stdout"); +} + +#[test] +fn nonzero_exit_reports_code_and_bounded_stderr() { + let dir = workspace(); + + let result = helper(&dir, "exit:7", limits(SLACK)) + .run() + .expect("should run a failing child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 7 }, + "should report the exit code" + ); + let report = result + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + assert!( + text.contains("diagnostic detail"), + "should include the stderr excerpt: {text}" + ); + assert!( + matches!(failure(&report), Some(ProcessFailure::Unsuccessful)), + "should classify the failure as unsuccessful" + ); +} + +#[test] +fn stderr_excerpt_is_bounded() { + let dir = workspace(); + + let report = helper(&dir, "bulk-stderr", limits(SLACK)) + .run() + .expect("should run a failing child") + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + + assert!(text.contains("TAIL"), "should keep the stderr tail: {text}"); + assert!( + text.len() < 20_000 / 2, + "should bound the excerpt well below the captured stderr" + ); +} + +#[test] +fn large_stderr_before_stdout_drains_concurrently() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 4 * 1024 * 1024, + }; + + let result = helper(&dir, "stderr-then-stdout", process_limits) + .run() + .expect("should run a child that fills stderr first"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit zero without blocking on a full stderr pipe" + ); + assert_eq!( + result.stderr().len(), + LARGE_STDERR, + "should capture every stderr byte" + ); + assert_eq!(payload(result.stdout()), b"small", "should capture stdout"); +} + +#[test] +fn signaled_child_reports_signal() { + let dir = workspace(); + + let result = helper(&dir, "raise", limits(SLACK)) + .run() + .expect("should run a signaled child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Signaled { + signal: libc::SIGTERM + }, + "should report the terminating signal" + ); + assert!( + result.require_success().is_err(), + "should reject a signaled child" + ); +} + +#[test] +fn nonexistent_program_reports_real_errno() { + let dir = workspace(); + let missing = PathBuf::from("/nonexistent-trusted-server-release/fixture-tool"); + let command = BoundedCommand::new(&missing, dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + + let report = command.run().expect_err("should fail to spawn"); + + assert!( + matches!( + failure(&report), + Some(ProcessFailure::Spawn { + errno: Some(libc::ENOENT) + }) + ), + "should record ENOENT: {report:?}" + ); + assert!( + format!("{report:?}").contains(&format!("os error {}", libc::ENOENT)), + "should carry the operating system error text" + ); +} + +#[test] +fn bounded_stdin_reaches_child() { + let dir = workspace(); + let command = helper(&dir, "cat", limits(SLACK)).stdin_bytes(b"from stdin".to_vec()); + + let stdout = command + .run() + .expect("should run the cat helper") + .require_success() + .expect("should exit zero"); + + assert_eq!( + payload(&stdout), + b"from stdin|done", + "should deliver stdin bytes" + ); +} + +#[test] +fn default_stdin_is_null() { + let dir = workspace(); + + let stdout = helper(&dir, "cat", limits(SLACK)) + .run() + .expect("should run the cat helper") + .require_success() + .expect("should exit zero"); + + assert_eq!( + payload(&stdout), + b"|done", + "should read EOF from null stdin" + ); +} + +#[test] +fn helper_selector_without_fixture_invocation_fails_the_run() { + assert!( + !unauthorized_helper_run(&[HELPER_TEST, "--test-threads=1"], Some("fixture-nonce")), + "should fail a non-exact filter run that carries a selector" + ); + assert!( + !unauthorized_helper_run( + &["--exact", HELPER_TEST, "--test-threads=1"], + Some("other-nonce") + ), + "should fail an exact run whose nonce does not match" + ); + assert!( + !unauthorized_helper_run(&["--exact", HELPER_TEST, "--test-threads=1"], None), + "should fail an exact run without a nonce" + ); +} + +#[test] +fn helper_authorization_requires_exact_filter_and_nonce() { + let fixture: Vec = ["binary", "--exact", HELPER_TEST] + .into_iter() + .map(str::to_owned) + .collect(); + let loose: Vec = ["binary", HELPER_TEST] + .into_iter() + .map(str::to_owned) + .collect(); + + assert_eq!( + authorized_mode("print:n1", Some("n1"), &fixture), + Some("print".to_owned()), + "should authorize a fixture invocation" + ); + assert_eq!( + authorized_mode("print", Some("n1"), &fixture), + None, + "should reject a selector without a nonce" + ); + assert_eq!( + authorized_mode("print:n1", Some(""), &fixture), + None, + "should reject an empty nonce" + ); + assert_eq!( + authorized_mode("print:n1", Some("n1"), &loose), + None, + "should reject a non-exact filter" + ); +} diff --git a/crates/trusted-server-release/src/process/tests/redaction.rs b/crates/trusted-server-release/src/process/tests/redaction.rs new file mode 100644 index 0000000000..0e12bd016b --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/redaction.rs @@ -0,0 +1,174 @@ +//! Secret redaction in Debug output and failure excerpts. + +use super::*; + +#[test] +fn secret_values_never_appear_in_debug_or_error_text() { + let dir = workspace(); + let command = helper(&dir, "secret", limits(SLACK)) + .secret_environment("TOKEN", SECRET) + .expect("should accept a secret variable"); + + let command_text = format!("{command:?}"); + let result = command.run().expect("should run the child"); + let result_text = format!("{result:?}"); + let report = result + .require_success() + .expect_err("should reject a nonzero exit"); + let report_text = format!("{report:?}"); + + assert!( + command_text.contains("TOKEN"), + "should still name the secret variable: {command_text}" + ); + for text in [&command_text, &result_text, &report_text] { + assert!( + !text.contains(SECRET), + "should redact the secret value: {text}" + ); + } + assert!( + report_text.contains("leak:[REDACTED]:"), + "should mark the redacted stderr span: {report_text}" + ); +} + +#[test] +fn secret_cut_at_stderr_cap_is_redacted() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024, + stderr_max: u64::try_from(CUT_PADDING + CUT_KEPT).expect("should fit in u64"), + }; + let command = helper(&dir, "secret-cut", process_limits) + .secret_environment("TOKEN", CUT_SECRET) + .expect("should accept a secret variable"); + + let result = command.run().expect("should run the child"); + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stderr + }, + "should cut stderr at its cap" + ); + let report = result + .require_success() + .expect_err("should reject a capped run"); + let text = format!("{report:?}"); + + let kept = CUT_SECRET + .get(..CUT_KEPT) + .expect("should slice the fixture"); + assert!( + !text.contains(kept), + "should redact a secret prefix cut by the cap: {text}" + ); + assert!(text.contains("[REDACTED]"), "should mark the cut: {text}"); +} + +#[test] +fn cut_tail_redaction_follows_actual_stderr_truncation() { + let secrets = vec![CUT_SECRET.as_bytes().to_vec()]; + let result = |outcome, stderr_truncated| ProcessResult { + program: PathBuf::from("/fixture/tool"), + outcome, + stdout: Vec::new(), + stderr: b"exit 1: Q".to_vec(), + stderr_truncated, + elapsed: Duration::ZERO, + secrets: secrets.clone(), + }; + let text = |result: ProcessResult| { + format!( + "{:?}", + result + .require_success() + .expect_err("should reject an unsuccessful run") + ) + }; + + let complete = text(result(ProcessOutcome::Exited { code: 1 }, false)); + let stderr_capped = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stderr, + }, + true, + )); + // Stdout overflowed first, but stderr was also cut mid-secret. + let both_capped = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + true, + )); + let stdout_only = text(result( + ProcessOutcome::OutputLimit { + stream: Stream::Stdout, + }, + false, + )); + + for untouched in [&complete, &stdout_only] { + assert!( + untouched.contains("exit 1: Q") && !untouched.contains("[REDACTED]"), + "should leave untruncated stderr untouched: {untouched}" + ); + } + for redacted in [&stderr_capped, &both_capped] { + assert!( + redacted.contains("exit 1: [REDACTED]"), + "should redact a possible secret prefix at a stderr cut: {redacted}" + ); + } +} + +#[test] +fn overlapping_secrets_are_fully_redacted() { + let dir = workspace(); + let command = helper(&dir, "overlap", limits(SLACK)) + .secret_environment("FIRST", OVERLAP_FIRST) + .expect("should accept a secret variable") + .secret_environment("SECOND", OVERLAP_SECOND) + .expect("should accept a secret variable"); + + let report = command + .run() + .expect("should run the child") + .require_success() + .expect_err("should reject a nonzero exit"); + let text = format!("{report:?}"); + + for fragment in ["Kq1", "Vv8", "Jx3"] { + assert!( + !text.contains(fragment), + "should redact every byte of overlapping secrets: {text}" + ); + } +} + +#[test] +fn redaction_merges_overlapping_ranges() { + let secrets = vec![b"abcXYZ".to_vec(), b"XYZdef".to_vec()]; + + assert_eq!( + redact(b"", &secrets), + b"<[REDACTED]>".to_vec(), + "should replace the merged span once" + ); +} + +#[test] +fn debug_redacts_program_and_working_directory() { + let program = PathBuf::from(format!("/fixture/{SECRET}/tool")); + let working_directory = PathBuf::from(format!("/fixture/{SECRET}")); + let command = BoundedCommand::new(&program, &working_directory, limits(SLACK)) + .expect("should accept absolute paths") + .secret_environment("TOKEN", SECRET) + .expect("should accept a secret variable"); + + let text = format!("{command:?}"); + + assert!(!text.contains(SECRET), "should redact paths: {text}"); +} diff --git a/crates/trusted-server-release/src/process/tests/supervision.rs b/crates/trusted-server-release/src/process/tests/supervision.rs new file mode 100644 index 0000000000..a6c6657694 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/supervision.rs @@ -0,0 +1,298 @@ +//! Group kill, reaping, deadlines, cancellation, caps and escapes. + +use super::*; + +#[test] +fn timeout_kills_and_reaps_leader() { + let dir = workspace(); + let started = Instant::now(); + + let result = helper(&dir, "sleep", limits(TIMEOUT_WALL)) + .run() + .expect("should supervise a timed-out child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should report a timeout outcome rather than an errno" + ); + assert!( + started.elapsed() < supervision_bound(TIMEOUT_WALL), + "should stop within the wall deadline plus the kill graces" + ); + let leader = read_pid(&dir.path().join("leader.pid")); + assert!(!is_alive(leader), "should reap the leader"); + let report = result + .require_success() + .expect_err("should reject a timeout"); + assert_eq!( + failure(&report), + Some(&ProcessFailure::Unsuccessful), + "should classify a timeout as an errno-free unsuccessful outcome" + ); +} + +#[test] +fn normal_exit_kills_lingering_group_descendant() { + let dir = workspace(); + + let result = helper(&dir, "linger", limits(SLACK)) + .run() + .expect("should run a child with a descendant"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should report the leader exit" + ); + let grandchild = read_pid(&dir.path().join("grandchild.pid")); + assert!( + is_dead_soon(grandchild), + "should kill the same-group grandchild" + ); +} + +#[test] +fn timeout_kills_leader_and_grandchild() { + let dir = workspace(); + + let result = helper(&dir, "group-sleep", limits(TIMEOUT_WALL)) + .run() + .expect("should supervise a timed-out group"); + + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should report a timeout" + ); + assert!( + !is_alive(read_pid(&dir.path().join("leader.pid"))), + "should reap the leader" + ); + assert!( + is_dead_soon(read_pid(&dir.path().join("grandchild.pid"))), + "should kill the grandchild" + ); +} + +#[test] +fn stdout_cap_with_stderr_flood_does_not_deadlock() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK * 3, + stdout_max: 64 * 1024, + stderr_max: 64 * 1024 * 1024, + }; + let started = Instant::now(); + + let result = helper(&dir, "flood-both", process_limits) + .run() + .expect("should supervise a flooding child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stdout + }, + "should report the stdout cap" + ); + assert!( + result.stdout().len() <= 64 * 1024, + "should keep stdout within its cap" + ); + assert!( + started.elapsed() < supervision_bound(Duration::ZERO), + "should stop at the cap, well before the wall deadline" + ); + assert!( + is_dead_soon(read_pid(&dir.path().join("grandchild.pid"))), + "should kill the flooding grandchild" + ); +} + +#[test] +fn stderr_cap_reports_stderr() { + let dir = workspace(); + let process_limits = ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 4096, + }; + + let result = helper(&dir, "flood-stderr", process_limits) + .run() + .expect("should supervise a flooding child"); + + assert_eq!( + result.outcome(), + ProcessOutcome::OutputLimit { + stream: Stream::Stderr + }, + "should report the stderr cap" + ); + assert!( + result.stderr().len() <= 4096, + "should keep stderr within its cap" + ); +} + +#[test] +fn cancellation_from_another_thread_stops_group() { + let dir = workspace(); + let cancellation = Cancellation::default(); + let command = helper(&dir, "group-sleep", limits(SLACK * 3)).cancellation(cancellation.clone()); + let grandchild_file = dir.path().join("grandchild.pid"); + let canceller = thread::spawn(move || { + let grandchild = read_pid(&grandchild_file); + cancellation.cancel(); + grandchild + }); + let started = Instant::now(); + + let result = command.run().expect("should supervise a cancelled child"); + let grandchild = canceller.join().expect("should join the cancelling thread"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Cancelled, + "should report cancellation" + ); + assert!( + started.elapsed() < supervision_bound(Duration::ZERO), + "should stop promptly after cancellation" + ); + assert!( + is_dead_soon(grandchild), + "should kill the grandchild on cancellation" + ); +} + +#[test] +fn cancellation_before_spawn_starts_nothing() { + let dir = workspace(); + let cancellation = Cancellation::default(); + cancellation.cancel(); + + let result = helper(&dir, "sleep", limits(SLACK)) + .cancellation(cancellation) + .run() + .expect("should report a pre-cancelled run"); + + assert_eq!( + result.outcome(), + ProcessOutcome::Cancelled, + "should report cancellation" + ); + assert!( + !dir.path().join("leader.pid").exists(), + "should not spawn a child after cancellation" + ); +} + +#[test] +fn escaped_descendant_holding_pipes_is_reported() { + let dir = workspace(); + let started = Instant::now(); + + let report = helper(&dir, "escape", limits(SLACK)) + .run() + .expect_err("should reject output held by an escaped descendant"); + let elapsed = started.elapsed(); + let escaped = read_pid(&dir.path().join("escaped.pid")); + force_kill(escaped); + + assert!( + matches!(failure(&report), Some(ProcessFailure::DescendantEscaped)), + "should classify the escape: {report:?}" + ); + assert!( + elapsed < supervision_bound(Duration::ZERO), + "should give up within the bounded drain grace" + ); +} + +/// Documents a residual this module cannot close: a `setsid()` daemon with +/// closed stdio leaves no observable trace for the supervisor. The Task 7 OS +/// boundary (container PID namespace, macOS sandbox) must contain it. +#[test] +fn closed_stdio_daemon_escape_is_not_detected_here() { + let dir = workspace(); + + let result = helper(&dir, "daemon", limits(SLACK)) + .run() + .expect("should not observe a daemon that closed its stdio"); + let daemon = read_pid(&dir.path().join("escaped.pid")); + let survived = is_alive(daemon); + force_kill(daemon); + + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should report only the leader exit" + ); + assert!( + survived, + "should leave the escaped daemon to the OS boundary (documented residual)" + ); +} + +#[test] +fn reaping_is_bounded_and_never_blocks() { + let dir = workspace(); + let (nonce, selector) = helper_selector("sleep"); + let mut child = StdCommand::new(test_binary()) + .args(harness_arguments()) + .env_clear() + .env(HELPER_SELECTOR, selector) + .env(HELPER_NONCE, nonce) + .env(HELPER_PIDS, dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("should spawn an unsignalled helper"); + let started = Instant::now(); + + let reaped = reap_within(&mut child, Duration::from_millis(200)); + let elapsed = started.elapsed(); + let _ = child.kill(); + let _ = child.wait(); + + assert!( + matches!(reaped, Ok(None)), + "should report an unreaped leader instead of waiting" + ); + assert!( + elapsed < KILL_GRACE, + "should return after the bounded grace" + ); +} + +#[test] +fn unreaped_leader_failure_keeps_real_errno() { + let refused = io::Error::from_raw_os_error(libc::EPERM); + + assert_eq!( + unreaped_failure(Some(&refused)), + ProcessFailure::Signal { + errno: Some(libc::EPERM) + }, + "should carry the real killpg errno" + ); + assert_eq!( + unreaped_failure(None), + ProcessFailure::GroupSurvived, + "should report survival when signalling succeeded" + ); +} + +#[test] +fn group_signal_refuses_init_and_broadcast_ids() { + // Only pgid 1 is probed: 0 and -1 would address this runner or every process. + let refused = signal_group(1).expect_err("should refuse process group 1"); + + assert_eq!( + refused.raw_os_error(), + None, + "should refuse before calling killpg, without a synthesized errno" + ); +} diff --git a/crates/trusted-server-release/src/process/tests/variables.rs b/crates/trusted-server-release/src/process/tests/variables.rs new file mode 100644 index 0000000000..c5cbdc5481 --- /dev/null +++ b/crates/trusted-server-release/src/process/tests/variables.rs @@ -0,0 +1,202 @@ +//! Command and environment validation. + +use super::*; + +/// Names that inject code or configuration; rejected even as secrets. +const INJECTION_NAMES: [&str; 54] = [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_ANYTHING", + "DYLD_INSERT_LIBRARIES", + "dyld_insert_libraries", + "NODE_OPTIONS", + "BASH_ENV", + "ENV", + "GCONV_PATH", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_EXEC_PATH", + "GIT_SSH_COMMAND", + "GIT_SSH", + "npm_config_registry", + "NPM_CONFIG_REGISTRY", + "PYTHONPATH", + "PYTHONSTARTUP", + "PERL5OPT", + "PERL5LIB", + "RUBYOPT", + "RUBYLIB", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG", + "GIT_ASKPASS", + "SSH_ASKPASS", + "GIT_PROXY_COMMAND", + "GIT_EXTERNAL_DIFF", + "GIT_EDITOR", + "EDITOR", + "VISUAL", + "GIT_PAGER", + "PAGER", + "GIT_TEMPLATE_DIR", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER", + "RUSTC", + "RUSTDOC", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTFLAGS", + "NODE_PATH", + "JAVA_TOOL_OPTIONS", + "_JAVA_OPTIONS", +]; + +/// Credential names accepted only through `secret_environment`. +const CREDENTIAL_NAMES: [&str; 12] = [ + "GH_TOKEN", + "GITHUB_TOKEN", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_RUNTIME_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "FASTLY_API_TOKEN", + "github_token", + "CARGO_REGISTRY_TOKEN", + "CARGO_REGISTRIES_EXAMPLE_TOKEN", + "SIGSTORE_ID_TOKEN", +]; + +#[test] +fn relative_program_is_rejected() { + let dir = workspace(); + + let report = BoundedCommand::new(Path::new("helper"), dir.path(), limits(SLACK)) + .expect_err("should reject a relative program path"); + + assert!( + matches!(failure(&report), Some(ProcessFailure::InvalidCommand)), + "should classify an invalid command" + ); +} + +#[test] +fn relative_working_directory_is_rejected() { + let report = BoundedCommand::new(&test_binary(), Path::new("relative"), limits(SLACK)) + .expect_err("should reject a relative working directory"); + + assert!( + matches!(failure(&report), Some(ProcessFailure::InvalidCommand)), + "should classify an invalid command" + ); +} + +#[test] +fn environment_is_exactly_the_explicit_map() { + let dir = workspace(); + let command = helper(&dir, "env", limits(SLACK)) + .environment("ALPHA", "1") + .expect("should accept a plain variable") + .environment("BETA", "two words") + .expect("should accept a plain variable"); + + let stdout = command + .run() + .expect("should run the env helper") + .require_success() + .expect("should exit zero"); + + let text = String::from_utf8(payload(&stdout).to_vec()).expect("should print UTF-8"); + let names: Vec<&str> = text + .lines() + .filter_map(|line| line.split_once('=').map(|(name, _)| name)) + .collect(); + assert_eq!( + names, + vec!["ALPHA", "BETA", HELPER_SELECTOR, HELPER_NONCE, HELPER_PIDS], + "should pass exactly the explicit environment" + ); + assert!( + text.contains("ALPHA=1\nBETA=two words\n"), + "should pass explicit values: {text}" + ); +} + +#[test] +fn injection_variables_are_rejected() { + let dir = workspace(); + for name in INJECTION_NAMES { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "/tmp/fixture.so") + .expect_err("should reject an injection variable"); + assert!( + matches!(failure(&plain), Some(ProcessFailure::InvalidCommand)), + "should classify {name} as an invalid command" + ); + assert!( + !format!("{plain:?}").contains("/tmp/fixture.so"), + "should not echo the rejected value" + ); + let secret = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .secret_environment(name, "/tmp/fixture.so"); + assert!( + secret.is_err(), + "should reject {name} as a secret variable too" + ); + } +} + +#[test] +fn credentials_require_secret_classification() { + let dir = workspace(); + for name in CREDENTIAL_NAMES { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "credential"); + assert!( + plain.is_err(), + "should refuse {name} through the plain environment" + ); + let secret = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .secret_environment(name, "credential"); + assert!(secret.is_ok(), "should accept {name} as a secret variable"); + } +} + +#[test] +fn malformed_environment_names_are_rejected() { + let dir = workspace(); + for name in ["", "A=B", "NUL\0NAME"] { + let command = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + assert!( + command.environment(name, "value").is_err(), + "should reject environment name {name:?}" + ); + } +} + +#[test] +fn harmless_names_are_accepted() { + let dir = workspace(); + for name in ["CARGO_TARGET_DIR", "CARGO_HOME", "LANG", "HOME", "TZ"] { + let command = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program"); + assert!( + command.environment(name, "value").is_ok(), + "should accept {name}" + ); + } +} From 9473dde811453db0286d4cd1af26cc9c7ea4b751 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 15:32:08 +0530 Subject: [PATCH 047/153] Avoid secret-bearing redaction test diagnostics --- .../src/process/tests/redaction.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-release/src/process/tests/redaction.rs b/crates/trusted-server-release/src/process/tests/redaction.rs index 0e12bd016b..287b516b61 100644 --- a/crates/trusted-server-release/src/process/tests/redaction.rs +++ b/crates/trusted-server-release/src/process/tests/redaction.rs @@ -19,13 +19,10 @@ fn secret_values_never_appear_in_debug_or_error_text() { assert!( command_text.contains("TOKEN"), - "should still name the secret variable: {command_text}" + "should still name the secret variable" ); for text in [&command_text, &result_text, &report_text] { - assert!( - !text.contains(SECRET), - "should redact the secret value: {text}" - ); + assert!(!text.contains(SECRET), "should redact the secret value"); } assert!( report_text.contains("leak:[REDACTED]:"), @@ -170,5 +167,5 @@ fn debug_redacts_program_and_working_directory() { let text = format!("{command:?}"); - assert!(!text.contains(SECRET), "should redact paths: {text}"); + assert!(!text.contains(SECRET), "should redact paths"); } From da3c9ce6682915aef2ae48d02d8d343181e05702 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 15:42:45 +0530 Subject: [PATCH 048/153] Verify official release evidence through the ts CLI Join typed successful gh results to exact retained subjects and bundles, approved source policy, provenance, SPDX and immutable release membership. Keep offline proof mode separate from publication authority and bind reports to their verified asset and release. Expose read-only ts release verify with bounded streamed downloads, fixed GitHub origins, cancellation and Ctrl-C child cleanup. Document independent bootstrap and offline limits. Pinned signed-fixture qualification remains incomplete; no producer release gate is enabled. --- crates/trusted-server-cli/src/commands/mod.rs | 3 + .../src/commands/release.rs | 35 + crates/trusted-server-cli/src/lib.rs | 5 +- .../src/release/artifact.rs | 586 +++++++++++++++ .../trusted-server-cli/src/release/bounds.rs | 6 + .../src/release/documents.rs | 3 + .../trusted-server-cli/src/release/error.rs | 12 + .../src/release/evidence.rs | 667 ++++++++++++++++++ .../src/release/evidence/joins.rs | 300 ++++++++ crates/trusted-server-cli/src/release/gh.rs | 329 ++++++++- .../src/release/gh/input.rs | 121 ++++ .../trusted-server-cli/src/release/github.rs | 477 +++++++++++++ crates/trusted-server-cli/src/release/mod.rs | 8 + .../trusted-server-cli/src/release/offline.rs | 191 +++++ .../trusted-server-cli/src/release/online.rs | 554 +++++++++++++++ .../src/release/source_policy.rs | 9 +- .../src/release/tests/gh/argv.rs | 4 +- .../src/release/tests/gh/lifecycle.rs | 227 ++++++ .../src/release/tests/mod.rs | 7 +- .../src/release/tests/offline.rs | 187 +++++ .../src/release/tests/online.rs | 532 ++++++++++++++ .../src/release/tests/verification/fixture.rs | 561 +++++++++++++++ .../src/release/tests/verification/mod.rs | 465 ++++++++++++ .../release/tests/verification/publication.rs | 524 ++++++++++++++ crates/trusted-server-cli/src/run.rs | 8 + .../trusted-server-cli/tests/release_cli.rs | 282 ++++++++ docs/.vitepress/config.mts | 4 + docs/guide/cli.md | 17 + docs/guide/release-verification.md | 157 +++++ 29 files changed, 6261 insertions(+), 20 deletions(-) create mode 100644 crates/trusted-server-cli/src/commands/release.rs create mode 100644 crates/trusted-server-cli/src/release/artifact.rs create mode 100644 crates/trusted-server-cli/src/release/evidence.rs create mode 100644 crates/trusted-server-cli/src/release/evidence/joins.rs create mode 100644 crates/trusted-server-cli/src/release/gh/input.rs create mode 100644 crates/trusted-server-cli/src/release/github.rs create mode 100644 crates/trusted-server-cli/src/release/offline.rs create mode 100644 crates/trusted-server-cli/src/release/online.rs create mode 100644 crates/trusted-server-cli/src/release/tests/offline.rs create mode 100644 crates/trusted-server-cli/src/release/tests/online.rs create mode 100644 crates/trusted-server-cli/src/release/tests/verification/fixture.rs create mode 100644 crates/trusted-server-cli/src/release/tests/verification/mod.rs create mode 100644 crates/trusted-server-cli/src/release/tests/verification/publication.rs create mode 100644 crates/trusted-server-cli/tests/release_cli.rs create mode 100644 docs/guide/release-verification.md diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index 0f4354f007..4a5ff7b707 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -9,3 +9,6 @@ pub(crate) mod pbs; pub mod cache; pub mod dev; pub mod origin; + +#[cfg(unix)] +pub(crate) mod release; diff --git a/crates/trusted-server-cli/src/commands/release.rs b/crates/trusted-server-cli/src/commands/release.rs new file mode 100644 index 0000000000..13335ccacf --- /dev/null +++ b/crates/trusted-server-cli/src/commands/release.rs @@ -0,0 +1,35 @@ +//! Read-only verification of official release assets. + +use clap::{Args, Subcommand}; + +use crate::release; + +/// Read-only operations over the fixed official release cohort. +#[derive(Debug, Subcommand)] +pub(crate) enum ReleaseCommand { + /// Verify an official release asset against an independently approved commit. + Verify(VerifyArgs), +} + +/// An independently approved source and the exact release asset to verify. +#[derive(Debug, Args)] +pub(crate) struct VerifyArgs { + /// Exact release tag, such as v1.2.0. + tag: String, + /// Exact executable name in the embedded release cohort. + #[arg(long)] + asset: String, + /// Independently approved full, lowercase source commit. + #[arg(long)] + expected_source_commit: String, +} + +/// Runs release verification without invoking provider or build operations. +/// +/// # Errors +/// +/// Returns a diagnostic when any input, evidence or publication check fails. +pub(crate) fn run(command: ReleaseCommand) -> Result<(), String> { + let ReleaseCommand::Verify(args) = command; + release::verify(&args.tag, &args.asset, &args.expected_source_commit) +} diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 12eafb3e46..1bdfc9d931 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -7,14 +7,13 @@ mod error; #[cfg(not(target_arch = "wasm32"))] mod prebid_bundle; // Consumer-side release attestation verification. Unix-only because the gh -// runner terminates whole process groups. Dispatch wiring arrives with the -// `ts release verify` command, so the internals are not yet reachable. +// runner terminates whole process groups. The public command is read-only. #[cfg(all(unix, not(target_arch = "wasm32")))] #[cfg_attr( not(test), expect( dead_code, - reason = "release verification is reachable only from tests until `ts release verify` dispatch lands" + reason = "private offline and future action-consumption helpers have no public command surface" ) )] mod release; diff --git a/crates/trusted-server-cli/src/release/artifact.rs b/crates/trusted-server-cli/src/release/artifact.rs new file mode 100644 index 0000000000..3db444a858 --- /dev/null +++ b/crates/trusted-server-cli/src/release/artifact.rs @@ -0,0 +1,586 @@ +//! Retained release asset bytes and the read-only verification report. +//! +//! Downloads stream into a private, unlinked temporary file while the role +//! byte limit is enforced chunk by chunk, so an oversize executable is +//! rejected mid-transfer instead of after it was fully written. A +//! [`RetainedAsset`] is only a measured download; a [`VerifiedReleaseAsset`] +//! is constructed exclusively from a [`PublicationVerified`] state that names +//! the same asset, digest and length, and is re-hashed before every later use. +//! +//! Nothing here authorizes deployment: [`VerificationReport`] is a read-only +//! summary, and action-scoped authorization belongs to a later integration. + +use std::fs::{File, OpenOptions}; +use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::bounds::{self, AttemptDeadline, Cancellation}; +use super::error::{ReleaseError, report}; +use super::evidence::ComparisonOutcome; +use super::github::PublicationVerified; + +/// Read buffer used when re-hashing a retained file. +const REHASH_CHUNK: usize = 64 * 1024; + +/// The byte-limit class of a release asset. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AssetRole { + /// A cohort executable (512 MiB). + Executable, + /// A Sigstore bundle or build companion (32 MiB). + Companion, + /// An SPDX SBOM document (16 MiB). + Spdx, + /// The index, JS inventory or `SHA256SUMS` (1 MiB). + Metadata, +} + +impl AssetRole { + /// Returns the R-BOUNDS byte limit for this role. + pub(crate) const fn limit(self) -> u64 { + match self { + Self::Executable => bounds::EXECUTABLE_BYTES, + Self::Companion => bounds::COMPANION_BYTES, + Self::Spdx => bounds::SPDX_BYTES, + Self::Metadata => bounds::METADATA_BYTES, + } + } +} + +/// The exact SHA-256 and length of distributed bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AssetMeasurement { + sha256: String, + length: u64, +} + +impl AssetMeasurement { + /// Measures exact in-memory bytes without reserializing them. + pub(crate) fn of_bytes(bytes: &[u8]) -> Self { + Self { + sha256: hex::encode(Sha256::digest(bytes)), + length: bytes.len() as u64, + } + } + + /// Records a length and digest declared by already verified evidence. + /// + /// Used only for expectations; retained bytes are always re-hashed + /// before a [`VerifiedReleaseAsset`] is constructed. + pub(crate) fn declared(sha256: &str, length: u64) -> Self { + Self { + sha256: sha256.to_owned(), + length, + } + } + + /// Returns the lowercase hexadecimal SHA-256. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Returns the byte length. + pub(crate) fn length(&self) -> u64 { + self.length + } +} + +/// A download in progress into a private retained file. +#[derive(Debug)] +pub(crate) struct RetainedDownload { + name: String, + role: AssetRole, + file: File, + hasher: Sha256, + length: u64, +} + +impl RetainedDownload { + /// Starts a download into a new anonymous, owner-only temporary file. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] if the private file cannot be created. + pub(crate) fn start(name: &str, role: AssetRole) -> Result> { + let file = tempfile::tempfile().map_err(|source| report(ReleaseError::Asset, source))?; + Ok(Self { + name: name.to_owned(), + role, + file, + hasher: Sha256::new(), + length: 0, + }) + } + + /// Appends one received chunk, enforcing the role limit before writing. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] once the total would exceed the role + /// limit (the transfer must then be aborted) or the write fails. + pub(crate) fn push(&mut self, chunk: &[u8]) -> Result<(), Report> { + let remaining = self.role.limit().saturating_sub(self.length); + if chunk.len() as u64 > remaining { + return Err(report( + ReleaseError::Asset, + format!( + "asset `{}` exceeds its {} byte role limit; download aborted", + self.name, + self.role.limit() + ), + )); + } + self.file + .write_all(chunk) + .map_err(|source| report(ReleaseError::Asset, source))?; + self.hasher.update(chunk); + self.length += chunk.len() as u64; + Ok(()) + } + + /// Completes the download and returns the measured retained asset. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] if the file cannot be flushed. + pub(crate) fn finish(mut self) -> Result> { + self.file + .flush() + .map_err(|source| report(ReleaseError::Asset, source))?; + Ok(RetainedAsset { + name: self.name, + role: self.role, + file: self.file, + measurement: AssetMeasurement { + sha256: hex::encode(self.hasher.finalize()), + length: self.length, + }, + }) + } +} + +/// Streams a response body into a retained file under the role limit. +/// +/// A declared `Content-Length` above the limit is rejected before any byte +/// is read; the streamed total is enforced independently while receiving. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Asset`] for oversize or failed transfers and +/// [`ReleaseError::Timeout`] when the attempt deadline elapses. +pub(crate) async fn stream_into( + mut response: reqwest::Response, + mut download: RetainedDownload, + deadline: AttemptDeadline, +) -> Result> { + if response + .content_length() + .is_some_and(|length| length > download.role.limit()) + { + return Err(report( + ReleaseError::Asset, + format!( + "asset `{}` declares more than its role limit", + download.name + ), + )); + } + let transfer = async { + while let Some(chunk) = response + .chunk() + .await + .map_err(|source| report(ReleaseError::Asset, source.without_url()))? + { + download.push(&chunk)?; + } + download.finish() + }; + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline.instant()), transfer) + .await + .map_err(|_| { + report( + ReleaseError::Timeout, + "asset download exceeded the deadline", + ) + })? +} + +/// A fully downloaded, measured but not yet verified release asset. +#[derive(Debug)] +pub(crate) struct RetainedAsset { + name: String, + role: AssetRole, + file: File, + measurement: AssetMeasurement, +} + +impl RetainedAsset { + /// Copies retained bytes to a canonical private filename for gh snapshots. + /// + /// # Errors + /// + /// Rejects unsafe names, collisions, changed bytes, limits or cancellation. + pub(crate) fn materialize( + &mut self, + directory: &Path, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + if !super::documents::safe_name(&self.name) || !directory.is_absolute() { + return Err(report( + ReleaseError::Asset, + "unsafe retained materialization path", + )); + } + let path = directory.join(&self.name); + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .map_err(|error| report(ReleaseError::Asset, error))?; + self.file + .seek(SeekFrom::Start(0)) + .map_err(|error| report(ReleaseError::Asset, error))?; + let mut hasher = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; REHASH_CHUNK]; + loop { + if cancellation.is_cancelled() { + return Err(report(ReleaseError::Cancelled, "retained copy cancelled")); + } + if Instant::now() >= deadline.instant() { + return Err(report( + ReleaseError::Timeout, + "retained copy exceeded deadline", + )); + } + let read = self + .file + .read(&mut buffer) + .map_err(|error| report(ReleaseError::Asset, error))?; + if read == 0 { + break; + } + length += read as u64; + if length > self.role.limit() { + return Err(report( + ReleaseError::Asset, + "retained copy exceeds role limit", + )); + } + let chunk = &buffer[..read]; + output + .write_all(chunk) + .map_err(|error| report(ReleaseError::Asset, error))?; + hasher.update(chunk); + } + let measurement = AssetMeasurement::declared(&hex::encode(hasher.finalize()), length); + if measurement != self.measurement { + return Err(report( + ReleaseError::Asset, + "retained bytes changed before gh verification", + )); + } + output + .flush() + .map_err(|error| report(ReleaseError::Asset, error))?; + Ok(path) + } + + /// Returns the release asset name. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Returns the measured digest and length. + pub(crate) fn measurement(&self) -> &AssetMeasurement { + &self.measurement + } + + /// Re-reads the retained bytes of a non-executable document after + /// re-checking their digest. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] for executables (never buffered whole) + /// or if the retained bytes changed. + pub(crate) fn document_bytes(&mut self) -> Result, Report> { + if self.role == AssetRole::Executable { + return Err(report( + ReleaseError::Asset, + "executables are verified by streamed digest, not buffered", + )); + } + let bytes = read_bounded(&mut self.file, self.role.limit())?; + if AssetMeasurement::of_bytes(&bytes) != self.measurement { + return Err(report( + ReleaseError::Asset, + format!("retained asset `{}` changed after download", self.name), + )); + } + Ok(bytes) + } +} + +fn read_bounded(file: &mut File, limit: u64) -> Result, Report> { + file.seek(SeekFrom::Start(0)) + .map_err(|source| report(ReleaseError::Asset, source))?; + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|source| report(ReleaseError::Asset, source))?; + if bytes.len() as u64 > limit { + return Err(report( + ReleaseError::Asset, + "retained asset grew past its role limit", + )); + } + Ok(bytes) +} + +fn rehash( + file: &mut File, + limit: u64, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result> { + file.seek(SeekFrom::Start(0)) + .map_err(|source| report(ReleaseError::Asset, source))?; + let mut hasher = Sha256::new(); + let mut length = 0_u64; + let mut buffer = vec![0_u8; REHASH_CHUNK]; + loop { + check_attempt(deadline, cancellation)?; + let read = file + .read(&mut buffer) + .map_err(|source| report(ReleaseError::Asset, source))?; + if read == 0 { + break; + } + length += read as u64; + if length > limit { + return Err(report( + ReleaseError::Asset, + "retained asset grew past its role limit", + )); + } + hasher.update(buffer.get(..read).unwrap_or_default()); + } + Ok(AssetMeasurement { + sha256: hex::encode(hasher.finalize()), + length, + }) +} + +/// Checks the shared deadline and cancellation before file work or success. +pub(crate) fn check_attempt( + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() { + return Err(report( + ReleaseError::Cancelled, + "release verification cancelled", + )); + } + if Instant::now() >= deadline.instant() { + return Err(report( + ReleaseError::Timeout, + "release verification deadline elapsed", + )); + } + Ok(()) +} + +/// A retained release executable whose bytes match verified, published +/// evidence. +/// +/// Constructed only by [`VerifiedReleaseAsset::from_publication_bounded`]; an +/// asserted path or hash cannot build one. +#[derive(Debug)] +pub(crate) struct VerifiedReleaseAsset { + name: String, + file: File, + measurement: AssetMeasurement, + assurance_profile: String, + comparison: ComparisonOutcome, + publication_index: AssetMeasurement, +} + +impl VerifiedReleaseAsset { + /// Test compatibility wrapper; production must pass its attempt bounds. + #[cfg(test)] + pub(crate) fn from_publication( + publication: &PublicationVerified, + asset: RetainedAsset, + ) -> Result> { + Self::from_publication_bounded( + publication, + asset, + AttemptDeadline::start(), + &Cancellation::default(), + ) + } + + /// Binds a retained executable to the publication-verified evidence. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] unless the asset is a retained + /// executable consumed by `publication` whose re-hashed bytes equal the + /// verified digest and length. + pub(crate) fn from_publication_bounded( + publication: &PublicationVerified, + mut asset: RetainedAsset, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + let consumed = publication + .build() + .consumed() + .iter() + .find(|consumed| consumed.name() == asset.name) + .ok_or_else(|| { + report( + ReleaseError::Asset, + format!("asset `{}` was not consumed by verification", asset.name), + ) + })?; + if asset.role != AssetRole::Executable { + return Err(report( + ReleaseError::Asset, + "only retained executables become verified release assets", + )); + } + check_attempt(deadline, cancellation)?; + let measured = rehash( + &mut asset.file, + AssetRole::Executable.limit(), + deadline, + cancellation, + )?; + if measured != asset.measurement || &measured != consumed.measurement() { + return Err(report( + ReleaseError::Asset, + format!("asset `{}` does not match verified evidence", asset.name), + )); + } + Ok(Self { + name: asset.name, + file: asset.file, + measurement: measured, + assurance_profile: consumed.assurance_profile().to_owned(), + comparison: consumed.comparison(), + publication_index: publication.build().index_measurement().clone(), + }) + } + + /// Re-hashes the retained bytes; call before every later use. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] if the retained bytes changed. + pub(crate) fn rehash_bounded( + &mut self, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result<&AssetMeasurement, Report> { + let measured = rehash( + &mut self.file, + AssetRole::Executable.limit(), + deadline, + cancellation, + )?; + if measured != self.measurement { + return Err(report( + ReleaseError::Asset, + format!("verified asset `{}` changed after verification", self.name), + )); + } + Ok(&self.measurement) + } + + /// Returns the asset name. + pub(crate) fn name(&self) -> &str { + &self.name + } +} + +/// Read-only summary of one complete online verification. +/// +/// It is not a deployment authorization and cannot construct one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct VerificationReport { + pub(crate) consumer_policy_version: u64, + pub(crate) consumer_policy_sha256: String, + pub(crate) producer_policy_version: u64, + pub(crate) producer_policy_sha256: String, + pub(crate) repository: &'static str, + pub(crate) tag: String, + pub(crate) source_commit: String, + pub(crate) asset: String, + pub(crate) asset_sha256: String, + pub(crate) asset_length: u64, + pub(crate) assurance_profile: String, + pub(crate) run_invocation: String, + /// Informational rebuild comparison; never an authorization input. + pub(crate) comparison: ComparisonOutcome, +} + +impl VerificationReport { + /// Test compatibility wrapper; production must pass its attempt bounds. + #[cfg(test)] + pub(crate) fn new( + publication: &PublicationVerified, + asset: &mut VerifiedReleaseAsset, + ) -> Result> { + Self::new_bounded( + publication, + asset, + AttemptDeadline::start(), + &Cancellation::default(), + ) + } + /// Summarizes a publication-verified asset after re-hashing it. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Asset`] if the retained bytes changed. + pub(crate) fn new_bounded( + publication: &PublicationVerified, + asset: &mut VerifiedReleaseAsset, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + let measurement = asset.rehash_bounded(deadline, cancellation)?.clone(); + let build = publication.build(); + if &asset.publication_index != build.index_measurement() { + return Err(report( + ReleaseError::Asset, + "asset belongs to another publication", + )); + } + check_attempt(deadline, cancellation)?; + Ok(Self { + consumer_policy_version: build.consumer_policy_version(), + consumer_policy_sha256: build.consumer_policy_sha256().to_owned(), + producer_policy_version: build.index().policy_version, + producer_policy_sha256: build.index().policy_sha256.clone(), + repository: super::policy::REPOSITORY, + tag: build.release().tag().as_str().to_owned(), + source_commit: build.release().commit().as_str().to_owned(), + asset: asset.name.clone(), + asset_sha256: measurement.sha256().to_owned(), + asset_length: measurement.length(), + assurance_profile: asset.assurance_profile.clone(), + run_invocation: build.run_invocation().to_owned(), + comparison: asset.comparison, + }) + } +} diff --git a/crates/trusted-server-cli/src/release/bounds.rs b/crates/trusted-server-cli/src/release/bounds.rs index 0ba0130bd2..35c9561263 100644 --- a/crates/trusted-server-cli/src/release/bounds.rs +++ b/crates/trusted-server-cli/src/release/bounds.rs @@ -97,6 +97,12 @@ impl AttemptDeadline { pub(crate) struct Cancellation(Arc); impl Cancellation { + /// Waits for cancellation while leaving stalled HTTP futures interruptible. + pub(crate) async fn cancelled(&self) { + while !self.is_cancelled() { + tokio::time::sleep(Duration::from_millis(25)).await; + } + } /// Requests cancellation of every child observing this flag. pub(crate) fn cancel(&self) { self.0.store(true, Ordering::Release); diff --git a/crates/trusted-server-cli/src/release/documents.rs b/crates/trusted-server-cli/src/release/documents.rs index f8a496eb3d..0b80dd2492 100644 --- a/crates/trusted-server-cli/src/release/documents.rs +++ b/crates/trusted-server-cli/src/release/documents.rs @@ -51,6 +51,8 @@ pub(crate) enum Role { Bundle, Spdx, GhOutput, + /// Bounded official REST metadata responses. + Metadata, } impl Role { @@ -64,6 +66,7 @@ impl Role { Self::Bundle => bounds::BUNDLE_BYTES, Self::Spdx => bounds::SPDX_BYTES, Self::GhOutput => bounds::STDOUT_BYTES, + Self::Metadata => bounds::METADATA_BYTES, } } } diff --git a/crates/trusted-server-cli/src/release/error.rs b/crates/trusted-server-cli/src/release/error.rs index 850bb5664e..11c72052da 100644 --- a/crates/trusted-server-cli/src/release/error.rs +++ b/crates/trusted-server-cli/src/release/error.rs @@ -49,6 +49,18 @@ pub(crate) enum ReleaseError { /// Successful child output is not exactly one attestation result. #[display("GitHub CLI output is rejected")] Output, + /// A verified proof, document or digest join of the evidence graph is + /// missing, extra or mismatched. + #[display("release evidence graph is inconsistent")] + Evidence, + /// The official GitHub release, tag or release statement is not the + /// immutable publication of the verified evidence. + #[display("official release publication check failed")] + Publication, + /// A retained release asset exceeds its role bound, cannot be retained or + /// no longer matches its verified digest. + #[display("release asset retention failed")] + Asset, } impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-cli/src/release/evidence.rs b/crates/trusted-server-cli/src/release/evidence.rs new file mode 100644 index 0000000000..b56d2bc5a7 --- /dev/null +++ b/crates/trusted-server-cli/src/release/evidence.rs @@ -0,0 +1,667 @@ +//! R-EVIDENCE: the complete verified evidence graph of an official release. +//! +//! Every proof is the single result object of one successful pinned +//! `gh attestation verify --bundle ... --format json` run (see +//! [`super::gh::GhRunner::verify_attestation`]). Primitive gh success is +//! never sufficient: every certificate field, statement subject and predicate +//! edge below is checked again here, and any missing, extra or mismatched +//! edge fails closed. +//! +//! JSON shape of the pinned gh 2.97.0 output, confirmed from primary sources: +//! - Each array element is `AttestationProcessingResult` with `attestation` +//! and `verificationResult`: +//! +//! and the `--format json` help text in +//! . +//! - `verificationResult` is sigstore-go v1.2.2 (pinned in gh's `go.mod`) +//! `VerificationResult`: `mediaType`, `statement` (protojson in-toto v1 +//! Statement: `_type`, `subject[].name`, `subject[].digest`, +//! `predicateType`, `predicate`) and `signature.certificate`, a +//! `certificate.Summary` with Fulcio `Extensions` flattened into it: +//! , +//! , +//! , +//! . +//! - The GitHub workflow provenance predicate layout follows the +//! `@actions/attest` builder: +//! . +//! It was read at the toolkit's default branch, not at the pinned +//! `actions/attest` 4.2.2 dependency; real pinned fixtures must confirm it. + +mod joins; + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::LazyLock; + +use error_stack::Report; +use regex::Regex; +use serde_json::{Map, Value, json}; + +use super::artifact::AssetMeasurement; +use super::documents::{ + ApprovedRelease, FileRecord, IndexArtifact, ReleaseIndex, lower_hex, parse_release_index, +}; +use super::error::{ReleaseError, report}; +use super::gh::{PredicateKind, VerificationMode, VerifiedAttestation}; +use super::policy::{ + AcceptanceRules, AcceptedProducerPolicy, BUILD_TYPE, ConsumerPolicy, GITHUB_HOST, ISSUER, + PROVENANCE_PREDICATE, REPOSITORY, REPOSITORY_ID, REPOSITORY_OWNER_ID, SBOM_PREDICATE, + accept_producer_policy, certificate_identity, +}; +use super::source_policy::ApprovedSourcePolicy; + +pub(crate) use joins::ComparisonOutcome; + +/// sigstore-go `VerificationResultMediaType01`. +const VERIFICATION_MEDIA_TYPE: &str = + "application/vnd.dev.sigstore.verificationresult+json;version=0.1"; +/// in-toto v1 statement type. +const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1"; +/// Fixed name of the signed release index. +pub(crate) const INDEX_NAME: &str = "release-index.json"; +/// Caller workflow path inside the official repository. +const CALLER_PATH: &str = ".github/workflows/release.yml"; +const CERTIFICATE: [&str; 3] = ["verificationResult", "signature", "certificate"]; +const STATEMENT: [&str; 2] = ["verificationResult", "statement"]; + +static RUN_URI: LazyLock = LazyLock::new(|| { + Regex::new( + r"^https://github\.com/IABTechLab/trusted-server/actions/runs/[1-9][0-9]{0,19}/attempts/[1-9][0-9]{0,4}$", // allow-domain: github + ) + .expect("should compile the reviewed run invocation pattern") +}); + +/// Identity values derived only from the independently approved release. +struct Expected { + san: String, + caller: String, + source_ref: String, + commit: String, + repository_uri: String, +} + +impl Expected { + fn new(release: &ApprovedRelease) -> Self { + let repository_uri = format!("https://{GITHUB_HOST}/{REPOSITORY}"); + let source_ref = format!("refs/tags/{}", release.tag().as_str()); + Self { + san: certificate_identity(release.tag()), + caller: format!("{repository_uri}/{CALLER_PATH}@{source_ref}"), + source_ref, + commit: release.commit().as_str().to_owned(), + repository_uri, + } + } +} + +pub(super) fn at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + path.iter().try_fold(value, |current, key| current.get(key)) +} + +pub(super) fn evidence_error(message: impl std::fmt::Display) -> Report { + report(ReleaseError::Evidence, message) +} + +fn require_text<'a>(value: &'a Value, path: &[&str]) -> Result<&'a str, Report> { + at(value, path) + .and_then(Value::as_str) + .ok_or_else(|| evidence_error(format!("proof field `{}` is missing", path.join(".")))) +} + +/// Checks the verified certificate of one proof and returns its run URI. +fn check_certificate(proof: &Value, expected: &Expected) -> Result> { + let certificate = at(proof, &CERTIFICATE) + .ok_or_else(|| evidence_error("proof has no verified certificate"))?; + let fields = [ + ("subjectAlternativeName", expected.san.as_str()), + ("issuer", ISSUER), + ("buildSignerURI", expected.san.as_str()), + ("buildSignerDigest", expected.commit.as_str()), + ("sourceRepositoryURI", expected.repository_uri.as_str()), + ("sourceRepositoryDigest", expected.commit.as_str()), + ("sourceRepositoryRef", expected.source_ref.as_str()), + ("sourceRepositoryIdentifier", REPOSITORY_ID), + ("sourceRepositoryOwnerIdentifier", REPOSITORY_OWNER_ID), + ("buildConfigURI", expected.caller.as_str()), + ("buildConfigDigest", expected.commit.as_str()), + ("buildTrigger", "push"), + ("runnerEnvironment", "github-hosted"), + ]; + for (field, value) in fields { + if require_text(certificate, &[field])? != value { + return Err(evidence_error(format!( + "certificate `{field}` differs from the approved release identity" + ))); + } + } + let run = require_text(certificate, &["runInvocationURI"])?; + if !RUN_URI.is_match(run) { + return Err(evidence_error( + "certificate `runInvocationURI` is not an official repository run attempt", + )); + } + Ok(run.to_owned()) +} + +/// Returns the verified statement after checking its envelope types. +fn statement<'a>( + proof: &'a Value, + predicate_type: &str, +) -> Result<&'a Value, Report> { + if require_text(proof, &["verificationResult", "mediaType"])? != VERIFICATION_MEDIA_TYPE { + return Err(evidence_error("unsupported verification result media type")); + } + let statement = + at(proof, &STATEMENT).ok_or_else(|| evidence_error("proof has no verified statement"))?; + if require_text(statement, &["_type"])? != STATEMENT_TYPE { + return Err(evidence_error("statement is not an in-toto v1 statement")); + } + if require_text(statement, &["predicateType"])? != predicate_type { + return Err(evidence_error(format!( + "statement predicate type is not `{predicate_type}`" + ))); + } + Ok(statement) +} + +fn subject_entry(subject: &Value) -> Option<(&str, &str)> { + let object = subject.as_object()?; + let digest = object.get("digest")?.as_object()?; + let name = object.get("name")?.as_str()?; + let sha256 = digest.get("sha256")?.as_str()?; + (object.len() == 2 && digest.len() == 1 && lower_hex(sha256, 64)).then_some((name, sha256)) +} + +/// Requires the statement subjects to be exactly `expected` (name → SHA-256). +fn check_subjects( + statement: &Value, + expected: &BTreeMap<&str, &str>, +) -> Result<(), Report> { + let subjects = at(statement, &["subject"]) + .and_then(Value::as_array) + .ok_or_else(|| evidence_error("statement has no subject array"))?; + let mut signed = BTreeMap::new(); + for subject in subjects { + let (name, sha256) = subject_entry(subject) + .ok_or_else(|| evidence_error("statement subject is not exactly {name, sha256}"))?; + if signed.insert(name, sha256).is_some() { + return Err(evidence_error(format!( + "statement names subject `{name}` twice" + ))); + } + } + if &signed != expected { + return Err(evidence_error( + "signed subjects differ from the exact expected names and digests", + )); + } + Ok(()) +} + +fn expected_provenance(expected: &Expected, run: &str) -> Value { + json!({ + "buildDefinition": { + "buildType": BUILD_TYPE, + "externalParameters": { + "workflow": { + "ref": expected.source_ref, + "repository": expected.repository_uri, + "path": CALLER_PATH, + } + }, + "internalParameters": { + "github": { + "event_name": "push", + "repository_id": REPOSITORY_ID, + "repository_owner_id": REPOSITORY_OWNER_ID, + "runner_environment": "github-hosted", + } + }, + "resolvedDependencies": [{ + "uri": format!("git+{}@{}", expected.repository_uri, expected.source_ref), + "digest": {"gitCommit": expected.commit}, + }], + }, + "runDetails": { + "builder": {"id": expected.san}, + "metadata": {"invocationId": run}, + }, + }) +} + +/// Checks the SLSA predicate section by section, then exactly. +fn check_provenance_predicate( + statement: &Value, + expected: &Expected, + run: &str, +) -> Result<(), Report> { + let wanted = expected_provenance(expected, run); + let actual = at(statement, &["predicate"]) + .ok_or_else(|| evidence_error("provenance statement has no predicate"))?; + let sections: [&[&str]; 6] = [ + &["buildDefinition", "buildType"], + &["buildDefinition", "externalParameters"], + &["buildDefinition", "internalParameters"], + &["buildDefinition", "resolvedDependencies"], + &["runDetails", "builder"], + &["runDetails", "metadata"], + ]; + for section in sections { + if at(actual, section) != at(&wanted, section) { + return Err(evidence_error(format!( + "provenance `{}` differs from the approved caller, source or run", + section.join(".") + ))); + } + } + if actual != &wanted { + return Err(evidence_error( + "provenance predicate contains unexpected structures", + )); + } + Ok(()) +} + +/// Verifies one provenance proof and returns its certificate run URI. +fn check_provenance_proof( + proof: &Value, + expected: &Expected, + subjects: &BTreeMap<&str, &str>, +) -> Result> { + let run = check_certificate(proof, expected)?; + let statement = statement(proof, PROVENANCE_PREDICATE)?; + check_provenance_predicate(statement, expected, &run)?; + check_subjects(statement, subjects)?; + Ok(run) +} + +fn require_same_run(run: &str, other: &str) -> Result<(), Report> { + if run != other { + return Err(evidence_error( + "collected proofs come from different workflow runs or attempts", + )); + } + Ok(()) +} + +fn check_measurement( + record: &FileRecord, + actual: &AssetMeasurement, +) -> Result<(), Report> { + if record.size != actual.length() || record.sha256 != actual.sha256() { + return Err(evidence_error(format!( + "`{}` bytes differ from the signed index length or digest", + record.name + ))); + } + Ok(()) +} + +/// Retained bytes and verified proofs for one consumed cohort artifact. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ConsumedArtifact<'a> { + pub(crate) name: &'a str, + pub(crate) executable: &'a AssetMeasurement, + pub(crate) spdx: &'a [u8], + pub(crate) build_evidence: &'a [u8], + pub(crate) build_recipe: &'a [u8], + pub(crate) rebuild_check: &'a [u8], + pub(crate) provenance_bundle: &'a AssetMeasurement, + pub(crate) sbom_bundle: &'a AssetMeasurement, + pub(crate) provenance_proof: &'a VerifiedAttestation, + pub(crate) spdx_proof: &'a VerifiedAttestation, +} + +/// Every independently obtained trust input and downloaded evidence item. +#[derive(Clone, Copy, Debug)] +pub(crate) struct EvidenceInputs<'a> { + pub(crate) release: &'a ApprovedRelease, + pub(crate) consumer_policy: &'a ConsumerPolicy, + pub(crate) source_policy: &'a ApprovedSourcePolicy, + pub(crate) index_bytes: &'a [u8], + pub(crate) index_proof: &'a VerifiedAttestation, + pub(crate) index_bundle: &'a AssetMeasurement, + pub(crate) inventory_bytes: &'a [u8], + pub(crate) artifacts: &'a [ConsumedArtifact<'a>], +} + +/// The verified evidence of one consumed artifact. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct VerifiedArtifactEvidence { + name: String, + measurement: AssetMeasurement, + assurance_profile: String, + comparison: ComparisonOutcome, +} + +impl VerifiedArtifactEvidence { + /// Returns the asset name. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Returns the verified executable digest and length. + pub(crate) fn measurement(&self) -> &AssetMeasurement { + &self.measurement + } + + /// Returns the required assurance profile of this artifact. + pub(crate) fn assurance_profile(&self) -> &str { + &self.assurance_profile + } + + /// Returns the informational rebuild comparison outcome. + pub(crate) fn comparison(&self) -> ComparisonOutcome { + self.comparison + } +} + +/// Every R-EVIDENCE edge held for the consumed artifacts. +/// +/// Only [`verify_build_evidence`] constructs it. It proves build evidence, +/// not official publication; see [`super::github::verify_publication`]. +#[derive(Debug)] +pub(crate) struct BuildEvidenceVerified { + mode: VerificationMode, + release: ApprovedRelease, + index: ReleaseIndex, + index_measurement: AssetMeasurement, + index_bundle: AssetMeasurement, + consumer_policy: ConsumerPolicy, + producer_policy: AcceptedProducerPolicy, + run_invocation: String, + consumed: Vec, +} + +impl BuildEvidenceVerified { + /// Returns the trust context shared by every build proof. + pub(crate) fn mode(&self) -> VerificationMode { + self.mode + } + /// Returns the approved release. + pub(crate) fn release(&self) -> &ApprovedRelease { + &self.release + } + + /// Returns the provenance-verified index. + pub(crate) fn index(&self) -> &ReleaseIndex { + &self.index + } + + /// Returns the exact index bytes' digest and length. + pub(crate) fn index_measurement(&self) -> &AssetMeasurement { + &self.index_measurement + } + + /// Returns the exact provenance bundle verified for this index. + pub(crate) fn index_bundle(&self) -> &AssetMeasurement { + &self.index_bundle + } + + /// Returns the consumer policy version. + pub(crate) fn consumer_policy_version(&self) -> u64 { + self.consumer_policy.version() + } + + /// Returns the consumer policy exact-byte digest. + pub(crate) fn consumer_policy_sha256(&self) -> &str { + self.consumer_policy.sha256() + } + + /// Returns how the producer policy was accepted. + pub(crate) fn producer_policy(&self) -> &AcceptedProducerPolicy { + &self.producer_policy + } + + /// Returns the single workflow run attempt shared by every proof. + pub(crate) fn run_invocation(&self) -> &str { + &self.run_invocation + } + + /// Returns the verified consumed artifacts. + pub(crate) fn consumed(&self) -> &[VerifiedArtifactEvidence] { + &self.consumed + } +} + +/// Verifies index provenance and approved-source policy before using its fields. +/// +/// # Errors +/// +/// Returns evidence, document or policy errors for any invalid proof or binding. +pub(crate) fn verify_index( + release: &ApprovedRelease, + policy: &ConsumerPolicy, + source: &ApprovedSourcePolicy, + bytes: &[u8], + proof: &VerifiedAttestation, + bundle: &AssetMeasurement, +) -> Result> { + let measurement = AssetMeasurement::of_bytes(bytes); + let subjects = BTreeMap::from([(INDEX_NAME, measurement.sha256())]); + let claims = proof.for_inputs(release, PredicateKind::Provenance, &measurement, bundle)?; + check_provenance_proof(claims, &Expected::new(release), &subjects)?; + let index = parse_release_index(bytes, policy, release)?; + accept_producer_policy(&index, source, &AcceptanceRules::compiled(policy))?; + Ok(index) +} + +/// Verifies the complete evidence graph for the consumed artifacts. +/// +/// Index provenance is verified against the approved release before any +/// index field is trusted; the index is then parsed under the embedded +/// policy and its producer-policy identity bound to approved-source bytes. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Evidence`] for any missing, extra or mismatched +/// edge, and document or policy errors from index parsing and producer-policy +/// acceptance. +pub(crate) fn verify_build_evidence( + inputs: &EvidenceInputs<'_>, +) -> Result> { + check_consumed_names(inputs.artifacts)?; + let mode = inputs.index_proof.mode(); + if inputs.artifacts.iter().any(|artifact| { + artifact.provenance_proof.mode() != mode || artifact.spdx_proof.mode() != mode + }) { + return Err(evidence_error( + "build proofs have different online/offline trust contexts", + )); + } + + let expected = Expected::new(inputs.release); + let index_measurement = AssetMeasurement::of_bytes(inputs.index_bytes); + let index_subject = BTreeMap::from([(INDEX_NAME, index_measurement.sha256())]); + let index_proof = inputs.index_proof.for_inputs( + inputs.release, + PredicateKind::Provenance, + &index_measurement, + inputs.index_bundle, + )?; + let run = check_provenance_proof(index_proof, &expected, &index_subject)?; + let index = parse_release_index(inputs.index_bytes, inputs.consumer_policy, inputs.release)?; + let producer_policy = accept_producer_policy( + &index, + inputs.source_policy, + &AcceptanceRules::compiled(inputs.consumer_policy), + )?; + check_measurement( + &index.tsjs_inventory, + &AssetMeasurement::of_bytes(inputs.inventory_bytes), + )?; + let modules = joins::parse_inventory(inputs.inventory_bytes, &expected.commit)?; + let context = ArtifactContext { + index: &index, + expected: &expected, + run: &run, + modules: &modules, + release: inputs.release, + }; + let consumed = inputs + .artifacts + .iter() + .map(|artifact| context.verify(artifact)) + .collect::, _>>()?; + Ok(BuildEvidenceVerified { + mode, + release: inputs.release.clone(), + index, + index_measurement, + index_bundle: inputs.index_bundle.clone(), + consumer_policy: inputs.consumer_policy.clone(), + producer_policy, + run_invocation: run, + consumed, + }) +} + +fn check_consumed_names(artifacts: &[ConsumedArtifact<'_>]) -> Result<(), Report> { + let names: BTreeSet<&str> = artifacts.iter().map(|artifact| artifact.name).collect(); + if artifacts.is_empty() || names.len() != artifacts.len() { + return Err(evidence_error( + "verification needs one or more distinct consumed artifacts", + )); + } + Ok(()) +} + +struct ArtifactContext<'a> { + index: &'a ReleaseIndex, + expected: &'a Expected, + run: &'a str, + modules: &'a [joins::ModuleRecord], + release: &'a ApprovedRelease, +} + +impl ArtifactContext<'_> { + fn verify( + &self, + consumed: &ConsumedArtifact<'_>, + ) -> Result> { + let entry = self + .index + .artifacts + .iter() + .find(|entry| entry.name == consumed.name) + .ok_or_else(|| { + evidence_error(format!( + "`{}` is not a signed index artifact", + consumed.name + )) + })?; + check_companion_bytes(entry, consumed)?; + joins::parse_informational(consumed.build_recipe)?; + let provenance_subjects = provenance_subjects(entry); + let proof = consumed.provenance_proof.for_inputs( + self.release, + PredicateKind::Provenance, + consumed.executable, + consumed.provenance_bundle, + )?; + let run = check_provenance_proof(proof, self.expected, &provenance_subjects)?; + require_same_run(self.run, &run)?; + self.check_sbom_proof(entry, consumed)?; + joins::check_build_evidence(consumed.build_evidence, entry, self.index, self.modules)?; + Ok(VerifiedArtifactEvidence { + name: entry.name.clone(), + measurement: consumed.executable.clone(), + assurance_profile: entry.assurance_profile.clone(), + comparison: joins::parse_comparison(consumed.rebuild_check)?, + }) + } + + fn check_sbom_proof( + &self, + entry: &IndexArtifact, + consumed: &ConsumedArtifact<'_>, + ) -> Result<(), Report> { + let proof = consumed.spdx_proof.for_inputs( + self.release, + PredicateKind::Spdx, + consumed.executable, + consumed.sbom_bundle, + )?; + let run = check_certificate(proof, self.expected)?; + require_same_run(self.run, &run)?; + let statement = statement(proof, SBOM_PREDICATE)?; + check_subjects( + statement, + &BTreeMap::from([(entry.name.as_str(), entry.sha256.as_str())]), + )?; + let predicate = at(statement, &["predicate"]) + .ok_or_else(|| evidence_error("SPDX statement has no predicate"))?; + joins::check_spdx(predicate, consumed.spdx, &entry.sha256) + } +} + +/// Index → executable and companions: exact actual lengths and digests. +fn check_companion_bytes( + entry: &IndexArtifact, + consumed: &ConsumedArtifact<'_>, +) -> Result<(), Report> { + let executable = FileRecord { + name: entry.name.clone(), + size: entry.size, + sha256: entry.sha256.clone(), + }; + let companions = &entry.companions; + let pairs = [ + (&executable, consumed.executable.clone()), + (&companions.sbom, AssetMeasurement::of_bytes(consumed.spdx)), + ( + &companions.build_evidence, + AssetMeasurement::of_bytes(consumed.build_evidence), + ), + ( + &companions.build_recipe, + AssetMeasurement::of_bytes(consumed.build_recipe), + ), + ( + &companions.rebuild_check, + AssetMeasurement::of_bytes(consumed.rebuild_check), + ), + (&companions.provenance, consumed.provenance_bundle.clone()), + (&companions.sbom_attestation, consumed.sbom_bundle.clone()), + ]; + pairs + .iter() + .try_for_each(|(record, actual)| check_measurement(record, actual)) +} + +/// Artifact provenance subjects: executable, SBOM, build evidence, recipe and +/// rebuild check, exactly. +fn provenance_subjects(entry: &IndexArtifact) -> BTreeMap<&str, &str> { + let companions = &entry.companions; + [ + (entry.name.as_str(), entry.sha256.as_str()), + ( + companions.sbom.name.as_str(), + companions.sbom.sha256.as_str(), + ), + ( + companions.build_evidence.name.as_str(), + companions.build_evidence.sha256.as_str(), + ), + ( + companions.build_recipe.name.as_str(), + companions.build_recipe.sha256.as_str(), + ), + ( + companions.rebuild_check.name.as_str(), + companions.rebuild_check.sha256.as_str(), + ), + ] + .into_iter() + .collect() +} + +/// Returns the object at `value`, or an evidence error naming `what`. +pub(super) fn object<'a>( + value: &'a Value, + what: &str, +) -> Result<&'a Map, Report> { + value + .as_object() + .ok_or_else(|| evidence_error(format!("{what} must be a JSON object"))) +} diff --git a/crates/trusted-server-cli/src/release/evidence/joins.rs b/crates/trusted-server-cli/src/release/evidence/joins.rs new file mode 100644 index 0000000000..bd3b47db71 --- /dev/null +++ b/crates/trusted-server-cli/src/release/evidence/joins.rs @@ -0,0 +1,300 @@ +//! Document joins of the evidence graph: JS inventory, build evidence, SPDX +//! root/document equality and the informational rebuild outcome. +//! +//! Every document is parsed with the strict byte-first parser, so duplicate +//! keys, BOMs, excess depth and oversize input are rejected before any join. +//! Only the fields the joins need are read; their names follow the R-INDEX +//! field contracts because no closed build-evidence, inventory or +//! rebuild-check schema is retained yet. + +use error_stack::Report; +use serde_json::Value; + +use super::{at, evidence_error, object}; +use crate::release::documents::{IndexArtifact, ReleaseIndex, Role, lower_hex, parse_json}; +use crate::release::error::ReleaseError; + +/// SPDX document element identifier. +const DOCUMENT_ID: &str = "SPDXRef-DOCUMENT"; + +/// One JS module identity shared by the inventory and build evidence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ModuleRecord { + id: String, + filename: String, + size: u64, + sha256: String, +} + +/// Informational rebuild comparison outcome; never an authorization input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ComparisonOutcome { + #[display("matched")] + Matched, + #[display("mismatched")] + Mismatched, + #[display("failed")] + Failed, + #[display("unavailable")] + Unavailable, +} + +fn module_record(value: &Value) -> Result> { + let text = |key: &str| value.get(key).and_then(Value::as_str); + let record = ModuleRecord { + id: text("id").unwrap_or_default().to_owned(), + filename: text("filename").unwrap_or_default().to_owned(), + size: value + .get("size") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX), + sha256: text("sha256").unwrap_or_default().to_owned(), + }; + if record.id.is_empty() + || record.filename.is_empty() + || record.size == u64::MAX + || !lower_hex(&record.sha256, 64) + { + return Err(evidence_error( + "JS module record needs id, filename, integer size and SHA-256", + )); + } + Ok(record) +} + +/// Parses an array of module records and requires strictly ascending ids. +fn sorted_modules( + value: Option<&Value>, + what: &str, +) -> Result, Report> { + let records = value + .and_then(Value::as_array) + .ok_or_else(|| evidence_error(format!("{what} must be an array")))? + .iter() + .map(module_record) + .collect::, _>>()?; + if records.windows(2).any(|pair| match pair { + [first, second] => first.id >= second.id, + _ => false, + }) { + return Err(evidence_error(format!( + "{what} must be sorted by unique module id" + ))); + } + Ok(records) +} + +fn require_version_and_source( + document: &Value, + commit: &str, + what: &str, +) -> Result<(), Report> { + object(document, what)?; + if document.get("schema_version").and_then(Value::as_u64) != Some(1) { + return Err(evidence_error(format!("{what} schema version must be 1"))); + } + if document.get("source_commit").and_then(Value::as_str) != Some(commit) { + return Err(evidence_error(format!( + "{what} source commit is not the approved commit" + ))); + } + Ok(()) +} + +/// Parses the shared JS inventory and returns its module table. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Document`] for malformed JSON and +/// [`ReleaseError::Evidence`] for another source commit or a malformed, +/// unsorted or duplicated module table. +pub(crate) fn parse_inventory( + bytes: &[u8], + commit: &str, +) -> Result, Report> { + let inventory = parse_json(bytes, Role::Inventory)?; + require_version_and_source(&inventory, commit, "JS inventory")?; + let modules = sorted_modules(inventory.get("modules"), "JS inventory modules")?; + if modules.is_empty() { + return Err(evidence_error("JS inventory lists no modules")); + } + Ok(modules) +} + +/// Joins one artifact's build evidence to the index and shared inventory. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Evidence`] when the build evidence names another +/// source, target, variant, executable or inventory digest, when its prepared +/// module table differs from the inventory, or when a claimed retained module +/// is not exactly an inventory module. +pub(crate) fn check_build_evidence( + bytes: &[u8], + entry: &IndexArtifact, + index: &ReleaseIndex, + modules: &[ModuleRecord], +) -> Result<(), Report> { + let evidence = parse_json(bytes, Role::Companion)?; + require_version_and_source(&evidence, &index.source_commit, "build evidence")?; + let joins = [ + ("target", entry.target.as_str()), + ("variant", entry.variant.as_str()), + ("artifact_sha256", entry.sha256.as_str()), + ("js_inventory_sha256", index.tsjs_inventory.sha256.as_str()), + ]; + for (field, wanted) in joins { + if evidence.get(field).and_then(Value::as_str) != Some(wanted) { + return Err(evidence_error(format!( + "build evidence `{field}` differs from the signed index" + ))); + } + } + let prepared = sorted_modules(evidence.get("prepared_modules"), "prepared modules")?; + if prepared != modules { + return Err(evidence_error( + "prepared JS modules differ from the shared inventory", + )); + } + let retained = sorted_modules(evidence.get("embedded_modules"), "embedded modules")?; + if let Some(unknown) = retained.iter().find(|module| !modules.contains(module)) { + return Err(evidence_error(format!( + "retained JS module `{}` is not exactly an inventory module", + unknown.id + ))); + } + Ok(()) +} + +/// Checks the SPDX predicate against the published document and executable. +/// +/// The published document is parsed strictly (duplicate keys rejected) and +/// compared as parsed JSON: object key order is ignored, array order is +/// significant. File-byte digest equality is checked separately against the +/// signed index; this comparison never substitutes for it. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Evidence`] if the predicate differs from the +/// document or the document root checksum is not the executable digest. +pub(crate) fn check_spdx( + predicate: &Value, + document_bytes: &[u8], + executable_sha256: &str, +) -> Result<(), Report> { + let document = parse_json(document_bytes, Role::Spdx)?; + if predicate != &document { + return Err(evidence_error( + "SPDX predicate differs from the published SPDX document", + )); + } + if at(&document, &["spdxVersion"]).and_then(Value::as_str) != Some("SPDX-2.3") + || at(&document, &["SPDXID"]).and_then(Value::as_str) != Some(DOCUMENT_ID) + { + return Err(evidence_error("published SBOM is not an SPDX 2.3 document")); + } + let root = spdx_root(&document)?; + if root_sha256(&document, &root)? != executable_sha256 { + return Err(evidence_error( + "SPDX root package checksum differs from the executable digest", + )); + } + Ok(()) +} + +/// Returns the single element the document describes. +fn spdx_root(document: &Value) -> Result> { + let mut described: Vec<&str> = document + .get("documentDescribes") + .and_then(Value::as_array) + .map(|ids| ids.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let relationships = document + .get("relationships") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + described.extend(relationships.iter().filter_map(|relationship| { + let field = |key: &str| relationship.get(key).and_then(Value::as_str); + (field("spdxElementId") == Some(DOCUMENT_ID) + && field("relationshipType") == Some("DESCRIBES")) + .then(|| field("relatedSpdxElement")) + .flatten() + })); + described.sort_unstable(); + described.dedup(); + match described.as_slice() { + [root] => Ok((*root).to_owned()), + _ => Err(evidence_error( + "SPDX document must describe exactly one root", + )), + } +} + +fn root_sha256<'a>(document: &'a Value, root: &str) -> Result<&'a str, Report> { + let packages: Vec<&Value> = document + .get("packages") + .and_then(Value::as_array) + .map(|packages| { + packages + .iter() + .filter(|package| package.get("SPDXID").and_then(Value::as_str) == Some(root)) + .collect() + }) + .unwrap_or_default(); + let [package] = packages.as_slice() else { + return Err(evidence_error("SPDX root must be exactly one package")); + }; + let checksums: Vec<&str> = package + .get("checksums") + .and_then(Value::as_array) + .map(|checksums| { + checksums + .iter() + .filter(|checksum| { + checksum.get("algorithm").and_then(Value::as_str) == Some("SHA256") + }) + .filter_map(|checksum| checksum.get("checksumValue").and_then(Value::as_str)) + .collect() + }) + .unwrap_or_default(); + match checksums.as_slice() { + [sha256] => Ok(sha256), + _ => Err(evidence_error( + "SPDX root package must carry exactly one SHA256 checksum", + )), + } +} + +/// Parses the informational rebuild-check outcome. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Evidence`] for an outcome outside the closed enum. +pub(crate) fn parse_comparison(bytes: &[u8]) -> Result> { + let document = parse_informational(bytes)?; + match document.get("outcome").and_then(Value::as_str) { + Some("matched") => Ok(ComparisonOutcome::Matched), + Some("mismatched") => Ok(ComparisonOutcome::Mismatched), + Some("failed") => Ok(ComparisonOutcome::Failed), + Some("unavailable") => Ok(ComparisonOutcome::Unavailable), + _ => Err(evidence_error( + "rebuild check outcome must be matched, mismatched, failed or unavailable", + )), + } +} + +/// Strictly parses a version-1 informational companion without executing it. +/// +/// # Errors +/// +/// Returns a document error for invalid JSON or an evidence error for an +/// unsupported version. +pub(super) fn parse_informational(bytes: &[u8]) -> Result> { + let document = parse_json(bytes, Role::Companion)?; + object(&document, "informational companion")?; + if document.get("version").and_then(Value::as_u64) != Some(1) { + return Err(evidence_error("informational companion version must be 1")); + } + Ok(document) +} diff --git a/crates/trusted-server-cli/src/release/gh.rs b/crates/trusted-server-cli/src/release/gh.rs index 48ca25e928..c4640471ef 100644 --- a/crates/trusted-server-cli/src/release/gh.rs +++ b/crates/trusted-server-cli/src/release/gh.rs @@ -25,6 +25,7 @@ use error_stack::Report; use serde_json::Value; use tempfile::TempDir; +use super::artifact::AssetMeasurement; use super::bounds::{self, AttemptDeadline, Cancellation, ProcessBounds}; use super::documents::{ApprovedRelease, ReleaseTag, Role, parse_json}; use super::environment::{CallerEnvironment, GithubToken}; @@ -35,6 +36,119 @@ use super::policy::{ }; use super::process::supervise; +mod input; + +/// Trust context of a successfully verified build attestation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum VerificationMode { + /// Uses the online verifier trust configuration. + Online, + /// Uses independently transferred, pinned roots; cannot prove publication. + Offline, +} + +/// One successful pinned gh attestation invocation, bound to its exact inputs. +/// +/// Only this module constructs production proofs. Callers cannot deserialize +/// or assert a successful verifier result. +#[derive(Debug)] +pub(crate) struct VerifiedAttestation { + mode: VerificationMode, + result: Value, + release: ApprovedRelease, + predicate: PredicateKind, + subject: AssetMeasurement, + bundle: AssetMeasurement, +} + +impl VerifiedAttestation { + /// Returns the immutable trust context of the verification. + pub(crate) fn mode(&self) -> VerificationMode { + self.mode + } + /// Returns verified claims only for the same approved release and bytes. + /// + /// # Errors + /// + /// Returns an evidence error when any invocation input differs. + pub(crate) fn for_inputs( + &self, + release: &ApprovedRelease, + predicate: PredicateKind, + subject: &AssetMeasurement, + bundle: &AssetMeasurement, + ) -> Result<&Value, Report> { + if !same_release(&self.release, release) + || self.predicate != predicate + || &self.subject != subject + || &self.bundle != bundle + { + return Err(report( + ReleaseError::Evidence, + "verified gh proof belongs to other inputs", + )); + } + Ok(&self.result) + } + + /// Constructs deliberately synthetic evidence for semantic tests only. + #[cfg(test)] + pub(crate) fn synthetic_for_tests( + result: Value, + release: ApprovedRelease, + predicate: PredicateKind, + subject: AssetMeasurement, + bundle: AssetMeasurement, + ) -> Self { + Self { + mode: VerificationMode::Online, + result, + release, + predicate, + subject, + bundle, + } + } +} + +/// Successful gh release verification, bound to independent release approval. +#[derive(Debug)] +pub(crate) struct VerifiedReleaseStatement { + result: Value, + release: ApprovedRelease, +} + +impl VerifiedReleaseStatement { + /// Returns verified claims for the independently approved release. + /// + /// # Errors + /// + /// Returns a publication error for a different release. + pub(crate) fn for_release( + &self, + release: &ApprovedRelease, + ) -> Result<&Value, Report> { + if !same_release(&self.release, release) { + return Err(report( + ReleaseError::Publication, + "verified release proof belongs to another release", + )); + } + Ok(&self.result) + } + + /// Constructs deliberately synthetic publication evidence for tests only. + #[cfg(test)] + pub(crate) fn synthetic_for_tests(result: Value, release: ApprovedRelease) -> Self { + Self { result, release } + } +} + +fn same_release(first: &ApprovedRelease, second: &ApprovedRelease) -> bool { + first.tag().as_str() == second.tag().as_str() + && first.commit().as_str() == second.commit().as_str() +} + const MINIMAL_PATH: &str = "/usr/bin:/bin"; const STDERR_EXCERPT_BYTES: usize = 4096; const VERSION_EXCERPT_CHARS: usize = 128; @@ -152,6 +266,8 @@ pub(crate) fn release_verify_args(tag: &ReleaseTag) -> Vec { OsString::from(tag.as_str()), OsString::from("-R"), OsString::from(release_repository()), + OsString::from("--format"), + OsString::from("json"), ] } @@ -234,9 +350,8 @@ fn reject_loader_injection(caller: &CallerEnvironment) -> Result<(), Report Result<(Self, GithubToken), Report> { + fn isolated(caller: &CallerEnvironment) -> Result> { reject_loader_injection(caller)?; - let token = GithubToken::select(caller)?; let root = tempfile::Builder::new() .prefix("ts-release-gh-") .tempdir() @@ -254,7 +369,7 @@ impl ChildEnvironment { variables.extend( FIXED_VARIABLES.map(|(name, value)| (OsString::from(name), OsString::from(value))), ); - Ok((Self { root, variables }, token)) + Ok(Self { root, variables }) } } @@ -304,7 +419,7 @@ fn locate( Ok(canonical) } -fn check_version(stdout: &[u8], token: &GithubToken) -> Result<(), Report> { +fn check_version(stdout: &[u8], token: Option<&GithubToken>) -> Result<(), Report> { let text = String::from_utf8_lossy(stdout); let first = text.lines().next().unwrap_or_default(); let qualified = first.strip_prefix("gh version ").is_some_and(|rest| { @@ -315,7 +430,7 @@ fn check_version(stdout: &[u8], token: &GithubToken) -> Result<(), Report Result<(), Report, + offline_roots: Option<(PathBuf, AssetMeasurement)>, bounds: ProcessBounds, } @@ -353,16 +469,65 @@ impl GhRunner { deadline: AttemptDeadline, cancellation: &Cancellation, ) -> Result> { - let (environment, token) = ChildEnvironment::isolated(caller)?; + let token = Some(GithubToken::select(caller)?); + let environment = ChildEnvironment::isolated(caller)?; let executable = locate(location, caller)?; let runner = Self { executable, environment, token, + offline_roots: None, + bounds, + }; + let output = runner.run(&[OsString::from("--version")], deadline, cancellation)?; + check_version(&output.stdout, runner.token.as_ref())?; + Ok(runner) + } + + /// Resolves a token-free bundle verifier with independently pinned roots. + /// + /// # Errors + /// + /// Rejects unsafe environment, missing tools, changed roots or wrong pins. + pub(crate) fn resolve_offline( + location: GhLocation<'_>, + caller: &CallerEnvironment, + bounds: ProcessBounds, + deadline: AttemptDeadline, + cancellation: &Cancellation, + roots: &Path, + expected_sha256: &str, + ) -> Result> { + if !super::documents::lower_hex(expected_sha256, 64) { + return Err(report( + ReleaseError::Policy, + "independent roots pin must be lowercase SHA-256", + )); + } + let environment = ChildEnvironment::isolated(caller)?; + let executable = locate(location, caller)?; + let (snapshot, measurement) = input::snapshot( + roots, + environment.root.path(), + bounds::TRUSTED_ROOTS_BYTES, + deadline, + cancellation, + )?; + if measurement.sha256() != expected_sha256 { + return Err(report( + ReleaseError::Policy, + "trusted roots differ from independently approved digest", + )); + } + let runner = Self { + executable, + environment, + token: None, + offline_roots: Some((snapshot, measurement)), bounds, }; let output = runner.run(&[OsString::from("--version")], deadline, cancellation)?; - check_version(&output.stdout, &runner.token)?; + check_version(&output.stdout, None)?; Ok(runner) } @@ -398,16 +563,20 @@ impl GhRunner { .args(args) .env_clear() .envs(self.environment.variables.iter().cloned()) - .env("GH_TOKEN", self.token.expose()) .current_dir(self.environment.root.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .process_group(0); + if let Some(token) = &self.token { + command.env("GH_TOKEN", token.expose()); + } let completed = supervise(&mut command, &self.bounds, deadline, cancellation)?; + let raw_stderr = String::from_utf8_lossy(&completed.stderr); let stderr = self .token - .redact(&String::from_utf8_lossy(&completed.stderr)); + .as_ref() + .map_or_else(|| raw_stderr.to_string(), |token| token.redact(&raw_stderr)); if !completed.status.success() { let excerpt: String = stderr.chars().take(STDERR_EXCERPT_BYTES).collect(); return Err(report( @@ -437,9 +606,90 @@ impl GhRunner { predicate: PredicateKind, deadline: AttemptDeadline, cancellation: &Cancellation, - ) -> Result> { - let args = attestation_verify_args(subject, bundle, release, predicate)?; + ) -> Result> { + attestation_verify_args(subject, bundle, release, predicate)?; + let private = tempfile::Builder::new() + .prefix("proof-") + .tempdir_in(self.environment.root.path()) + .map_err(|error| report(ReleaseError::Asset, error))?; + let subject_limit = if subject + .file_name() + .is_some_and(|name| name == "release-index.json") + { + bounds::INDEX_BYTES + } else { + bounds::EXECUTABLE_BYTES + }; + let (private_subject, subject_measurement) = input::snapshot( + subject, + private.path(), + subject_limit, + deadline, + cancellation, + )?; + let (private_bundle, bundle_measurement) = input::snapshot( + bundle, + private.path(), + bounds::BUNDLE_BYTES, + deadline, + cancellation, + )?; + let bundle_bytes = + std::fs::read(&private_bundle).map_err(|error| report(ReleaseError::Asset, error))?; + parse_json(&bundle_bytes, Role::Bundle)?; + let mut args = + attestation_verify_args(&private_subject, &private_bundle, release, predicate)?; + if let Some((roots, measurement)) = &self.offline_roots { + input::unchanged( + roots, + measurement, + bounds::TRUSTED_ROOTS_BYTES, + deadline, + cancellation, + )?; + args.extend([ + OsString::from("--custom-trusted-root"), + roots.as_os_str().to_owned(), + ]); + } let output = self.run(&args, deadline, cancellation)?; + input::unchanged( + subject, + &subject_measurement, + subject_limit, + deadline, + cancellation, + )?; + input::unchanged( + bundle, + &bundle_measurement, + bounds::BUNDLE_BYTES, + deadline, + cancellation, + )?; + input::unchanged( + &private_subject, + &subject_measurement, + subject_limit, + deadline, + cancellation, + )?; + input::unchanged( + &private_bundle, + &bundle_measurement, + bounds::BUNDLE_BYTES, + deadline, + cancellation, + )?; + if let Some((roots, measurement)) = &self.offline_roots { + input::unchanged( + roots, + measurement, + bounds::TRUSTED_ROOTS_BYTES, + deadline, + cancellation, + )?; + } let value = parse_json(&output.stdout, Role::GhOutput)?; let Value::Array(results) = value else { return Err(report( @@ -456,9 +706,60 @@ impl GhRunner { ), )); } - results + let result = results .into_iter() .next() - .ok_or_else(|| report(ReleaseError::Output, "gh returned no attestation result")) + .ok_or_else(|| report(ReleaseError::Output, "gh returned no attestation result"))?; + Ok(VerifiedAttestation { + mode: if self.offline_roots.is_some() { + VerificationMode::Offline + } else { + VerificationMode::Online + }, + result, + release: release.clone(), + predicate, + subject: subject_measurement, + bundle: bundle_measurement, + }) + } + + /// Verifies the official release with the pinned tool and bounded JSON. + /// + /// gh 2.97.0 `pkg/cmd/release/verify/verify.go` exports one processing + /// result object with `--format json`, unlike attestation verify's array. + /// + /// # Errors + /// + /// Returns runner or strict JSON errors, or an output error for a shape + /// outside the pinned contract. Publication semantics are checked later. + pub(crate) fn verify_release( + &self, + release: &ApprovedRelease, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + if self.offline_roots.is_some() { + return Err(report( + ReleaseError::Publication, + "offline build evidence cannot verify publication", + )); + } + let output = self.run(&release_verify_args(release.tag()), deadline, cancellation)?; + let result = parse_json(&output.stdout, Role::GhOutput)?; + if !result.is_object() + || !result + .get("verificationResult") + .is_some_and(Value::is_object) + { + return Err(report( + ReleaseError::Output, + "gh release output must be one verification result object", + )); + } + Ok(VerifiedReleaseStatement { + result, + release: release.clone(), + }) } } diff --git a/crates/trusted-server-cli/src/release/gh/input.rs b/crates/trusted-server-cli/src/release/gh/input.rs new file mode 100644 index 0000000000..2bcf760d25 --- /dev/null +++ b/crates/trusted-server-cli/src/release/gh/input.rs @@ -0,0 +1,121 @@ +//! Bounded private snapshots supplied to the verifier child. + +use std::fs::{File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::release::artifact::AssetMeasurement; +use crate::release::bounds::{AttemptDeadline, Cancellation}; +use crate::release::error::{ReleaseError, report}; + +fn checked_transfer( + path: &Path, + mut destination: Option<&mut File>, + limit: u64, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result> { + let mut input = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|error| report(ReleaseError::Asset, error))?; + let metadata = input + .metadata() + .map_err(|error| report(ReleaseError::Asset, error))?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > limit { + return Err(report( + ReleaseError::Asset, + "gh input is not a bounded regular file", + )); + } + let mut hasher = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + if cancellation.is_cancelled() { + return Err(report( + ReleaseError::Cancelled, + "gh input snapshot cancelled", + )); + } + if Instant::now() >= deadline.instant() { + return Err(report( + ReleaseError::Timeout, + "gh input snapshot exceeded deadline", + )); + } + let read = input + .read(&mut buffer) + .map_err(|error| report(ReleaseError::Asset, error))?; + if read == 0 { + break; + } + length += read as u64; + if length > limit { + return Err(report( + ReleaseError::Asset, + "gh input exceeds its role limit", + )); + } + let chunk = &buffer[..read]; + hasher.update(chunk); + if let Some(output) = destination.as_deref_mut() { + output + .write_all(chunk) + .map_err(|error| report(ReleaseError::Asset, error))?; + } + } + Ok(AssetMeasurement::declared( + &hex::encode(hasher.finalize()), + length, + )) +} + +/// Copies a bounded file into an attempt-private directory and measures it. +/// +/// # Errors +/// +/// Returns an asset, cancellation or timeout error for failed snapshots. +pub(super) fn snapshot( + path: &Path, + directory: &Path, + limit: u64, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result<(PathBuf, AssetMeasurement), Report> { + let name = path + .file_name() + .ok_or_else(|| report(ReleaseError::Asset, "gh input has no file name"))?; + let private = directory.join(name); + let mut output = + File::create_new(&private).map_err(|error| report(ReleaseError::Asset, error))?; + let measurement = checked_transfer(path, Some(&mut output), limit, deadline, cancellation)?; + Ok((private, measurement)) +} + +/// Requires the current file to match its pre-invocation snapshot. +/// +/// # Errors +/// +/// Returns an asset error if a substitution or read failure occurs. +pub(super) fn unchanged( + path: &Path, + expected: &AssetMeasurement, + limit: u64, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result<(), Report> { + if &checked_transfer(path, None, limit, deadline, cancellation)? != expected { + return Err(report( + ReleaseError::Asset, + "gh input changed during verification", + )); + } + Ok(()) +} diff --git a/crates/trusted-server-cli/src/release/github.rs b/crates/trusted-server-cli/src/release/github.rs new file mode 100644 index 0000000000..ddd844a86c --- /dev/null +++ b/crates/trusted-server-cli/src/release/github.rs @@ -0,0 +1,477 @@ +//! R-PUBLICATION: official GitHub release, tag and release-statement checks. +//! +//! Requests go only to fixed official REST paths on the fixed API host with +//! an explicit read-only credential, no redirects and bounded bodies. The +//! checks themselves are pure functions over the fetched JSON so they are +//! exercised without network access. +//! +//! Field names were confirmed from the pinned gh 2.97.0 REST models: +//! release `tag_name`/`draft`/`prerelease`/`immutable`/`assets[].name`/ +//! `size`/`digest`/`state` in +//! , +//! the tag ref `object.sha` in the same file (`object.type` is from the +//! GitHub git refs REST documentation), and the release attestation `tag` +//! predicate field and release-ref subject in +//! +//! and . + +use std::collections::BTreeMap; + +use error_stack::Report; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderName, HeaderValue, USER_AGENT}; +use reqwest::{Client, Method, Request, StatusCode}; +use serde_json::{Map, Value}; +use url::Url; + +use super::artifact::AssetMeasurement; +use super::bounds::{self, AttemptDeadline}; +use super::documents::{ReleaseTag, Role, lower_hex, parse_json}; +use super::environment::GithubToken; +use super::error::{ReleaseError, report}; +use super::evidence::{BuildEvidenceVerified, INDEX_NAME}; +use super::gh::{VerificationMode, VerifiedReleaseStatement}; +use super::source_policy::BoundedBody; + +/// Fixed official repository REST root; only the tag varies below it. +pub(crate) const API_REPOSITORY: &str = "https://api.github.com/repos/IABTechLab/trusted-server"; // allow-domain: api.github.com +/// Certificate SAN of GitHub's release attestation signer, enforced by gh. +const RELEASE_SIGNER: &str = "https://dotcom.releases.github.com"; // allow-domain: dotcom.releases.github.com +/// Name of the external index provenance bundle. +pub(crate) const INDEX_BUNDLE_NAME: &str = "release-index.provenance.sigstore.json"; +/// Name of the convenience checksum list. +pub(crate) const CHECKSUMS_NAME: &str = "SHA256SUMS"; +const JSON_MEDIA_TYPE: &str = "application/vnd.github+json"; +const API_VERSION: &str = "2022-11-28"; + +fn publication_error(message: impl std::fmt::Display) -> Report { + report(ReleaseError::Publication, message) +} + +fn rest_request(path: &str, token: &GithubToken) -> Result> { + let url = Url::parse(&format!("{API_REPOSITORY}/{path}")) + .map_err(|source| report(ReleaseError::Publication, source))?; + let mut request = Request::new(Method::GET, url); + let mut authorization = HeaderValue::from_str(&format!("Bearer {}", token.expose())) + .map_err(|_| publication_error("GitHub token is not a valid header value"))?; + authorization.set_sensitive(true); + let headers = request.headers_mut(); + headers.insert(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE)); + headers.insert( + HeaderName::from_static("x-github-api-version"), + HeaderValue::from_static(API_VERSION), + ); + headers.insert(USER_AGENT, HeaderValue::from_static("trusted-server-cli")); + headers.insert(AUTHORIZATION, authorization); + Ok(request) +} + +/// Builds the fixed release-by-tag request. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] if the request cannot be encoded. +pub(crate) fn release_request( + tag: &ReleaseTag, + token: &GithubToken, +) -> Result> { + rest_request(&format!("releases/tags/{}", tag.as_str()), token) +} + +/// Builds the fixed lightweight tag ref request. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] if the request cannot be encoded. +pub(crate) fn tag_ref_request( + tag: &ReleaseTag, + token: &GithubToken, +) -> Result> { + rest_request(&format!("git/ref/tags/{}", tag.as_str()), token) +} + +/// Executes a fixed REST request and strictly parses its bounded JSON body. +/// +/// The client must not follow redirects (see +/// [`super::source_policy::policy_client`]); any 3xx, non-200 status, final +/// URL change or body over 1 MiB fails. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] for transport, status, origin or +/// size failures and [`ReleaseError::Timeout`] after the attempt deadline. +pub(crate) async fn fetch_json( + client: &Client, + request: Request, + token: &GithubToken, + deadline: AttemptDeadline, +) -> Result> { + let request_url = request.url().clone(); + let fetch = async { + let mut response = client + .execute(request) + .await + .map_err(|error| publication_error(token.redact(&error.to_string())))?; + if response.status() != StatusCode::OK || response.url() != &request_url { + return Err(publication_error(format!( + "official REST endpoint returned {} (redirects are rejected)", + response.status() + ))); + } + let mut body = BoundedBody::new(bounds::METADATA_BYTES); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| publication_error(token.redact(&error.to_string())))? + { + body.push(&chunk).map_err(|error| { + error + .change_context(ReleaseError::Publication) + .attach("REST response exceeds 1 MiB") + })?; + } + parse_json(&body.into_bytes(), Role::Metadata) + }; + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline.instant()), fetch) + .await + .map_err(|_| report(ReleaseError::Timeout, "REST request exceeded the deadline"))? +} + +/// Every asset the official release must contain, with exact length and +/// SHA-256. +fn expected_assets( + build: &BuildEvidenceVerified, + index_bundle: &AssetMeasurement, + checksums: &AssetMeasurement, +) -> BTreeMap { + let index = build.index(); + let mut assets = BTreeMap::new(); + let mut add = |name: &str, size: u64, sha256: &str| { + assets.insert(name.to_owned(), AssetMeasurement::declared(sha256, size)); + }; + add( + &index.tsjs_inventory.name, + index.tsjs_inventory.size, + &index.tsjs_inventory.sha256, + ); + for artifact in &index.artifacts { + add(&artifact.name, artifact.size, &artifact.sha256); + let companions = &artifact.companions; + for record in [ + &companions.sbom, + &companions.provenance, + &companions.sbom_attestation, + &companions.build_evidence, + &companions.build_recipe, + &companions.rebuild_check, + ] { + add(&record.name, record.size, &record.sha256); + } + } + let measured = [ + (INDEX_NAME, build.index_measurement()), + (INDEX_BUNDLE_NAME, index_bundle), + (CHECKSUMS_NAME, checksums), + ]; + for (name, measurement) in measured { + add(name, measurement.length(), measurement.sha256()); + } + assets +} + +fn is_prerelease(tag: &ReleaseTag) -> bool { + let version = tag.as_str().split('+').next().unwrap_or_default(); + version.contains('-') +} + +/// Checks the release-by-tag REST object against the expected asset set. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] unless the release is the published, +/// immutable release of `tag` with a prerelease flag matching the tag and +/// exactly the expected assets, each uploaded with the expected REST size +/// (and digest, when GitHub reports one). +pub(crate) fn check_release( + release: &Value, + tag: &ReleaseTag, + expected: &BTreeMap, +) -> Result<(), Report> { + if release.get("tag_name").and_then(Value::as_str) != Some(tag.as_str()) { + return Err(publication_error("REST release is for another tag")); + } + if release.get("draft") != Some(&Value::Bool(false)) { + return Err(publication_error("release is a draft, not published")); + } + if release.get("prerelease") != Some(&Value::Bool(is_prerelease(tag))) { + return Err(publication_error( + "release prerelease flag does not match the tag", + )); + } + if release.get("immutable") != Some(&Value::Bool(true)) { + return Err(publication_error("release is not immutable")); + } + let listed = release_assets(release)?; + if listed.len() != expected.len() || listed.keys().ne(expected.keys()) { + return Err(publication_error( + "release assets are not exactly the signed release asset set", + )); + } + for (name, (size, digest)) in &listed { + let wanted = expected + .get(*name) + .ok_or_else(|| publication_error(format!("unexpected release asset `{name}`")))?; + if *size != wanted.length() { + return Err(publication_error(format!( + "REST size of `{name}` differs from the signed and actual length" + ))); + } + if digest.is_some_and(|digest| digest != format!("sha256:{}", wanted.sha256())) { + return Err(publication_error(format!( + "REST digest of `{name}` differs from the signed digest" + ))); + } + } + Ok(()) +} + +type ListedAssets<'a> = BTreeMap<&'a str, (u64, Option<&'a str>)>; + +fn release_assets(release: &Value) -> Result, Report> { + let assets = release + .get("assets") + .and_then(Value::as_array) + .ok_or_else(|| publication_error("REST release has no asset list"))?; + let mut listed = BTreeMap::new(); + for asset in assets { + let name = asset.get("name").and_then(Value::as_str); + let size = asset.get("size").and_then(Value::as_u64); + let (Some(name), Some(size)) = (name, size) else { + return Err(publication_error("REST asset lacks a name or size")); + }; + if asset.get("state").and_then(Value::as_str) != Some("uploaded") { + return Err(publication_error(format!( + "release asset `{name}` is not fully uploaded" + ))); + } + let digest = asset.get("digest").and_then(Value::as_str); + if listed.insert(name, (size, digest)).is_some() { + return Err(publication_error(format!( + "release lists asset `{name}` twice" + ))); + } + } + Ok(listed) +} + +/// Checks that the tag is lightweight and targets the approved commit. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] for another ref, an annotated tag +/// object or another commit. +pub(crate) fn check_tag_ref( + tag_ref: &Value, + tag: &ReleaseTag, + commit: &str, +) -> Result<(), Report> { + if tag_ref.get("ref").and_then(Value::as_str) != Some(&format!("refs/tags/{}", tag.as_str())) { + return Err(publication_error("REST ref is not the release tag")); + } + let object = tag_ref.get("object"); + if object + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + != Some("commit") + { + return Err(publication_error( + "release tag must be lightweight; annotated tag objects are rejected", + )); + } + if object + .and_then(|object| object.get("sha")) + .and_then(Value::as_str) + != Some(commit) + { + return Err(publication_error( + "release tag does not target the approved commit", + )); + } + Ok(()) +} + +/// Checks the verified GitHub release attestation (`gh release verify +/// --format json`) binds the tag, approved commit and every asset digest. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] for another signer, tag or commit, +/// or signed asset names/digests that differ from the expected set. +pub(crate) fn check_release_statement( + verified: &Value, + tag: &ReleaseTag, + commit: &str, + expected: &BTreeMap, +) -> Result<(), Report> { + let result = verified.get("verificationResult"); + let field = |path: &[&str]| { + path.iter() + .try_fold(result?, |value, key| value.get(key)) + .and_then(Value::as_str) + }; + if field(&["mediaType"]) + != Some("application/vnd.dev.sigstore.verificationresult+json;version=0.1") + || field(&["statement", "_type"]) != Some("https://in-toto.io/Statement/v1") + || field(&["statement", "predicateType"]) + != Some("https://in-toto.io/attestation/release/v0.1") + { + return Err(publication_error( + "unsupported verified release statement type", + )); + } + let purl = format!("pkg:github/{}@{}", super::policy::REPOSITORY, tag.as_str()); + for (key, wanted) in [ + ("repository", super::policy::REPOSITORY), + ("repositoryId", super::policy::REPOSITORY_ID), + ("ownerId", super::policy::REPOSITORY_OWNER_ID), + ("purl", purl.as_str()), + ] { + if field(&["statement", "predicate", key]) != Some(wanted) { + return Err(publication_error(format!( + "signed release `{key}` differs from the official repository" + ))); + } + } + if field(&["signature", "certificate", "subjectAlternativeName"]) != Some(RELEASE_SIGNER) { + return Err(publication_error("release statement has another signer")); + } + if field(&["statement", "predicate", "tag"]) != Some(tag.as_str()) { + return Err(publication_error("release statement names another tag")); + } + let subjects = result + .and_then(|result| result.get("statement")) + .and_then(|statement| statement.get("subject")) + .and_then(Value::as_array) + .ok_or_else(|| publication_error("release statement has no subjects"))?; + let (named, commits) = release_subjects(subjects, &purl)?; + if commits != [commit] { + return Err(publication_error( + "release statement does not bind exactly the approved commit", + )); + } + let wanted: BTreeMap<&str, &str> = expected + .iter() + .map(|(name, measurement)| (name.as_str(), measurement.sha256())) + .collect(); + if named != wanted { + return Err(publication_error( + "signed release asset names or digests differ from the verified assets", + )); + } + Ok(()) +} + +type ReleaseSubjects<'a> = (BTreeMap<&'a str, &'a str>, Vec<&'a str>); + +fn release_subjects<'a>( + subjects: &'a [Value], + purl: &str, +) -> Result, Report> { + let mut named = BTreeMap::new(); + let mut commits = Vec::new(); + for subject in subjects { + let digest = subject.get("digest").and_then(Value::as_object); + let name = subject + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + if name.is_empty() { + if subject.as_object().map(Map::len) != Some(2) + || subject.get("uri").and_then(Value::as_str) != Some(purl) + || digest.map(Map::len) != Some(1) + { + return Err(publication_error( + "release commit subject must have exactly the official URI and SHA-1", + )); + } + let sha1 = digest + .and_then(|digest| digest.get("sha1")) + .and_then(Value::as_str); + commits.push(sha1.unwrap_or_default()); + continue; + } + if subject.as_object().map(Map::len) != Some(2) { + return Err(publication_error( + "release asset subject must have exactly name and digest", + )); + } + let sha256 = digest + .filter(|digest| digest.len() == 1) + .and_then(|digest| digest.get("sha256")) + .and_then(Value::as_str) + .filter(|sha256| lower_hex(sha256, 64)) + .ok_or_else(|| publication_error("signed release asset lacks one SHA-256"))?; + if named.insert(name, sha256).is_some() { + return Err(publication_error(format!( + "release statement names `{name}` twice" + ))); + } + } + Ok((named, commits)) +} + +/// Fetched official publication data and release-wide asset measurements. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PublicationInputs<'a> { + pub(crate) release: &'a Value, + pub(crate) tag_ref: &'a Value, + pub(crate) release_statement: &'a VerifiedReleaseStatement, + pub(crate) index_bundle: &'a AssetMeasurement, + pub(crate) checksums: &'a AssetMeasurement, +} + +/// Build evidence that is also the official immutable publication. +/// +/// Only [`verify_publication`] constructs it. +#[derive(Debug)] +pub(crate) struct PublicationVerified { + build: BuildEvidenceVerified, +} + +impl PublicationVerified { + /// Returns the verified build evidence. + pub(crate) fn build(&self) -> &BuildEvidenceVerified { + &self.build + } +} + +/// Verifies the official immutable publication of verified build evidence. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Publication`] if the tag is annotated or targets +/// another commit, the release is not the immutable published release with +/// exactly the expected assets and REST lengths, or the release statement +/// does not bind every asset name and digest. +pub(crate) fn verify_publication( + build: BuildEvidenceVerified, + inputs: &PublicationInputs<'_>, +) -> Result> { + if build.mode() != VerificationMode::Online { + return Err(publication_error( + "offline build evidence cannot become official publication evidence", + )); + } + let tag = build.release().tag().clone(); + let commit = build.release().commit().as_str().to_owned(); + check_tag_ref(inputs.tag_ref, &tag, &commit)?; + let expected = expected_assets(&build, inputs.index_bundle, inputs.checksums); + check_release(inputs.release, &tag, &expected)?; + if inputs.index_bundle != build.index_bundle() { + return Err(publication_error( + "published index bundle differs from the verified bundle", + )); + } + let statement = inputs.release_statement.for_release(build.release())?; + check_release_statement(statement, &tag, &commit, &expected)?; + Ok(PublicationVerified { build }) +} diff --git a/crates/trusted-server-cli/src/release/mod.rs b/crates/trusted-server-cli/src/release/mod.rs index 1fe65ce502..8e66f491f5 100644 --- a/crates/trusted-server-cli/src/release/mod.rs +++ b/crates/trusted-server-cli/src/release/mod.rs @@ -5,14 +5,22 @@ //! release metadata, operator configuration and forwarded gh flags never //! redefine them. +mod artifact; mod bounds; mod documents; mod environment; mod error; +mod evidence; mod gh; +mod github; mod policy; mod process; mod source_policy; #[cfg(test)] mod tests; + +mod offline; +mod online; + +pub(crate) use online::verify; diff --git a/crates/trusted-server-cli/src/release/offline.rs b/crates/trusted-server-cli/src/release/offline.rs new file mode 100644 index 0000000000..826fa1db56 --- /dev/null +++ b/crates/trusted-server-cli/src/release/offline.rs @@ -0,0 +1,191 @@ +//! Private build-evidence-only verification of securely transferred inputs. +//! +//! The caller supplies independently approved source, artifact and root pins. +//! No publication proof, report or deployment authorization is produced. + +use std::fs::{File, OpenOptions}; +use std::io::Read as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::Path; +use std::time::Instant; + +use error_stack::Report; +use serde_json::Value; + +use super::artifact::{AssetRole, RetainedAsset, RetainedDownload}; +use super::bounds::{AttemptDeadline, Cancellation, ProcessBounds}; +use super::documents::{ApprovedRelease, lower_hex, safe_name}; +use super::environment::CallerEnvironment; +use super::error::{ReleaseError, report}; +use super::evidence::BuildEvidenceVerified; +use super::gh::{GhLocation, GhRunner}; +use super::online::{Transport, build_with}; +use super::source_policy::{ApprovedSourcePolicy, ApprovedSourceRoot, read_approved_snapshot}; + +/// Independent trust inputs, transferred separately from untrusted evidence. +pub(super) struct OfflineApproval<'a> { + pub(super) release: &'a ApprovedRelease, + pub(super) source: &'a ApprovedSourceRoot, + pub(super) asset: &'a str, + pub(super) artifact_sha256: &'a str, + pub(super) roots: &'a Path, + pub(super) roots_sha256: &'a str, + pub(super) gh: &'a Path, +} + +/// Offline evidence deliberately hides its build proof from publication APIs. +pub(super) struct OfflineBuildEvidence { + _build: BuildEvidenceVerified, + _asset: RetainedAsset, +} + +struct LocalTransport<'a> { + directory: &'a Path, + source: &'a ApprovedSourceRoot, + cancellation: &'a Cancellation, +} + +fn regular_file(path: &Path) -> Result> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|error| report(ReleaseError::Asset, error))?; + if !file + .metadata() + .map_err(|error| report(ReleaseError::Asset, error))? + .is_file() + { + return Err(report( + ReleaseError::Asset, + "offline input must be a regular file", + )); + } + Ok(file) +} + +impl Transport for LocalTransport<'_> { + async fn policy( + &self, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + super::artifact::check_attempt(deadline, cancellation)?; + read_approved_snapshot(self.source) + } + + async fn asset( + &self, + name: &str, + role: AssetRole, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + super::artifact::check_attempt(deadline, cancellation)?; + if !safe_name(name) { + return Err(report(ReleaseError::Asset, "unsafe offline input name")); + } + let mut file = regular_file(&self.directory.join(name))?; + let mut retained = RetainedDownload::start(name, role)?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + if self.cancellation.is_cancelled() { + return Err(report(ReleaseError::Cancelled, "offline input cancelled")); + } + if Instant::now() >= deadline.instant() { + return Err(report( + ReleaseError::Timeout, + "offline input deadline elapsed", + )); + } + let read = file + .read(&mut buffer) + .map_err(|error| report(ReleaseError::Asset, error))?; + if read == 0 { + return retained.finish(); + } + retained.push(&buffer[..read])?; + } + } + + async fn publication( + &self, + _tag_ref: bool, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + super::artifact::check_attempt(deadline, cancellation)?; + Err(report( + ReleaseError::Publication, + "offline verification cannot acquire publication evidence", + )) + } +} + +/// Verifies local build evidence with independently pinned source, roots and bytes. +/// +/// The caller must enforce OS network denial during this operation. The gh +/// invocation uses local bundles and custom roots, with no token or cache. +/// +/// # Errors +/// +/// Rejects any changed trust input, artifact pin or evidence graph edge. +pub(super) async fn verify_build( + approval: &OfflineApproval<'_>, + evidence_directory: &Path, + caller: &CallerEnvironment, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result> { + if !lower_hex(approval.artifact_sha256, 64) + || approval.source.commit() != approval.release.commit() + { + return Err(report( + ReleaseError::Policy, + "offline source and artifact pins must be independently approved", + )); + } + if super::policy::embedded_policy()? + .artifact(approval.asset) + .is_none() + { + return Err(report( + ReleaseError::Policy, + "offline asset is outside the embedded cohort", + )); + } + let runner = GhRunner::resolve_offline( + GhLocation::Explicit(approval.gh), + caller, + ProcessBounds::production(), + deadline, + cancellation, + approval.roots, + approval.roots_sha256, + )?; + let transport = LocalTransport { + directory: evidence_directory, + source: approval.source, + cancellation, + }; + let built = build_with( + &transport, + &runner, + approval.release, + approval.asset, + deadline, + cancellation, + ) + .await?; + if built.executable.measurement().sha256() != approval.artifact_sha256 { + return Err(report( + ReleaseError::Evidence, + "offline executable differs from independently approved digest", + )); + } + super::artifact::check_attempt(deadline, cancellation)?; + Ok(OfflineBuildEvidence { + _build: built.build, + _asset: built.executable, + }) +} diff --git a/crates/trusted-server-cli/src/release/online.rs b/crates/trusted-server-cli/src/release/online.rs new file mode 100644 index 0000000000..d34b4e1e4f --- /dev/null +++ b/crates/trusted-server-cli/src/release/online.rs @@ -0,0 +1,554 @@ +//! Fixed-origin online orchestration of the private consumer verifier. + +use std::future::Future; + +use error_stack::Report; +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use url::Url; + +use super::artifact::{ + AssetRole, RetainedAsset, RetainedDownload, VerificationReport, VerifiedReleaseAsset, + stream_into, +}; +use super::bounds::{AttemptDeadline, Cancellation, ProcessBounds}; +use super::documents::{ApprovedCommit, ApprovedRelease, ReleaseTag, safe_name}; +use super::environment::{CallerEnvironment, GithubToken}; +use super::error::{ReleaseError, report}; +use super::evidence::{ + BuildEvidenceVerified, ConsumedArtifact, EvidenceInputs, INDEX_NAME, verify_build_evidence, + verify_index, +}; +use super::gh::{GhLocation, GhRunner, PredicateKind}; +use super::github::{self, PublicationInputs}; +use super::policy::embedded_policy; +use super::source_policy::{self, ApprovedSourcePolicy}; + +/// Validates all public inputs before any environment or network access. +pub(super) fn validate_request( + tag: &str, + asset: &str, + commit: &str, +) -> Result> { + let tag = ReleaseTag::parse(tag)?; + let commit = ApprovedCommit::parse(commit)?; + if embedded_policy()?.artifact(asset).is_none() { + return Err(report( + ReleaseError::Policy, + "asset is outside the embedded release cohort", + )); + } + Ok(ApprovedRelease::new(tag, commit)) +} + +/// Constructs only canonical public release URLs; no credential is attached. +pub(super) fn approved_asset_url( + release: &ApprovedRelease, + name: &str, +) -> Result> { + if !safe_name(name) { + return Err(report(ReleaseError::Asset, "release asset name is unsafe")); + } + Url::parse(&format!( + "https://github.com/{}/releases/download/{}/{name}", + super::policy::REPOSITORY, + release.tag().as_str() + )) + .map_err(|_| report(ReleaseError::Asset, "cannot encode official asset URL")) +} + +/// Exact CDN listed by GitHub's runner networking reference under release assets. +/// +/// +pub(super) fn allowed_redirect(url: &Url) -> bool { + url.scheme() == "https" + && url.host_str() == Some("release-assets.githubusercontent.com") + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() +} + +/// Internal transport seam; public dispatch always constructs [`OfficialTransport`]. +pub(super) trait Transport { + async fn policy( + &self, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result>; + async fn asset( + &self, + name: &str, + role: AssetRole, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result>; + async fn publication( + &self, + tag_ref: bool, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result>; +} + +/// Interrupts a pending HTTP send or body read when its attempt is cancelled. +pub(super) async fn cancellable( + future: impl Future>>, + cancellation: &Cancellation, +) -> Result> { + tokio::select! { + biased; + () = cancellation.cancelled() => Err(report(ReleaseError::Cancelled, "release HTTP operation cancelled")), + result = future => result, + } +} + +struct OfficialTransport<'a> { + client: Client, + release: &'a ApprovedRelease, + token: GithubToken, +} + +impl Transport for OfficialTransport<'_> { + async fn policy( + &self, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + super::artifact::check_attempt(deadline, cancellation)?; + cancellable( + source_policy::fetch_approved_policy( + &self.client, + self.release.commit(), + &self.token, + deadline, + ), + cancellation, + ) + .await + } + + async fn asset( + &self, + name: &str, + role: AssetRole, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + super::artifact::check_attempt(deadline, cancellation)?; + let transfer = async { + let mut url = approved_asset_url(self.release, name)?; + for hop in 0..=3 { + // No bearer, cookies, referrer or caller-controlled headers on public downloads. + let response = self + .client + .get(url.clone()) + .header(reqwest::header::USER_AGENT, "trusted-server-cli") + .send() + .await + .map_err(|_| report(ReleaseError::Asset, "official asset request failed"))?; + if response.url() != &url { + return Err(report( + ReleaseError::Asset, + "asset client followed an unexpected redirect", + )); + } + if response.status().is_redirection() && hop < 3 { + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + report(ReleaseError::Asset, "asset redirect has no valid location") + })?; + let next = url + .join(location) + .map_err(|_| report(ReleaseError::Asset, "invalid asset redirect"))?; + if !allowed_redirect(&next) { + return Err(report( + ReleaseError::Asset, + "asset redirect is outside the approved HTTPS host", + )); + } + url = next; + continue; + } + if response.status() != StatusCode::OK { + return Err(report( + ReleaseError::Asset, + "official asset request did not return 200", + )); + } + return stream_into(response, RetainedDownload::start(name, role)?, deadline).await; + } + Err(report(ReleaseError::Asset, "asset redirect limit exceeded")) + }; + cancellable( + async { + tokio::time::timeout_at( + tokio::time::Instant::from_std(deadline.instant()), + transfer, + ) + .await + .map_err(|_| { + report( + ReleaseError::Timeout, + "asset request exceeded attempt deadline", + ) + })? + }, + cancellation, + ) + .await + } + + async fn publication( + &self, + tag_ref: bool, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + super::artifact::check_attempt(deadline, cancellation)?; + let request = if tag_ref { + github::tag_ref_request(self.release.tag(), &self.token)? + } else { + github::release_request(self.release.tag(), &self.token)? + }; + cancellable( + github::fetch_json(&self.client, request, &self.token, deadline), + cancellation, + ) + .await + } +} + +/// Runs actual downloads, proofs and joins; returns only a read-only report. +pub(super) async fn build_with( + transport: &impl Transport, + runner: &GhRunner, + request: &ApprovedRelease, + asset_name: &str, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result> { + let policy = embedded_policy()?; + let source_policy = transport.policy(deadline, cancellation).await?; + let mut index = transport + .asset(INDEX_NAME, AssetRole::Metadata, deadline, cancellation) + .await?; + let mut index_bundle = transport + .asset( + github::INDEX_BUNDLE_NAME, + AssetRole::Companion, + deadline, + cancellation, + ) + .await?; + let private = tempfile::Builder::new() + .prefix("ts-release-inputs-") + .tempdir() + .map_err(|error| report(ReleaseError::Asset, error))?; + let index_path = index.materialize(private.path(), deadline, cancellation)?; + let index_bundle_path = index_bundle.materialize(private.path(), deadline, cancellation)?; + let index_proof = runner.verify_attestation( + &index_path, + &index_bundle_path, + request, + PredicateKind::Provenance, + deadline, + cancellation, + )?; + let index_bytes = index.document_bytes()?; + let parsed = verify_index( + request, + &policy, + &source_policy, + &index_bytes, + &index_proof, + index_bundle.measurement(), + )?; + let selected = parsed + .artifacts + .iter() + .find(|artifact| artifact.name == asset_name) + .ok_or_else(|| { + report( + ReleaseError::Policy, + "selected asset is absent from signed index", + ) + })?; + let mut executable = transport + .asset(asset_name, AssetRole::Executable, deadline, cancellation) + .await?; + let companions = &selected.companions; + let mut provenance = transport + .asset( + &companions.provenance.name, + AssetRole::Companion, + deadline, + cancellation, + ) + .await?; + let mut sbom_bundle = transport + .asset( + &companions.sbom_attestation.name, + AssetRole::Companion, + deadline, + cancellation, + ) + .await?; + let mut spdx = transport + .asset( + &companions.sbom.name, + AssetRole::Spdx, + deadline, + cancellation, + ) + .await?; + let mut build_evidence = transport + .asset( + &companions.build_evidence.name, + AssetRole::Companion, + deadline, + cancellation, + ) + .await?; + let mut build_recipe = transport + .asset( + &companions.build_recipe.name, + AssetRole::Companion, + deadline, + cancellation, + ) + .await?; + let mut rebuild_check = transport + .asset( + &companions.rebuild_check.name, + AssetRole::Companion, + deadline, + cancellation, + ) + .await?; + let mut inventory = transport + .asset( + &parsed.tsjs_inventory.name, + AssetRole::Metadata, + deadline, + cancellation, + ) + .await?; + for (record, retained) in [ + (&companions.provenance, &provenance), + (&companions.sbom_attestation, &sbom_bundle), + (&companions.sbom, &spdx), + (&companions.build_evidence, &build_evidence), + (&companions.build_recipe, &build_recipe), + (&companions.rebuild_check, &rebuild_check), + (&parsed.tsjs_inventory, &inventory), + ] { + if retained.name() != record.name + || retained.measurement().sha256() != record.sha256 + || retained.measurement().length() != record.size + { + return Err(report( + ReleaseError::Evidence, + "download does not match signed companion membership", + )); + } + } + if executable.name() != selected.name + || executable.measurement().sha256() != selected.sha256 + || executable.measurement().length() != selected.size + { + return Err(report( + ReleaseError::Evidence, + "download does not match signed executable membership", + )); + } + let executable_path = executable.materialize(private.path(), deadline, cancellation)?; + let provenance_path = provenance.materialize(private.path(), deadline, cancellation)?; + let sbom_path = sbom_bundle.materialize(private.path(), deadline, cancellation)?; + let provenance_proof = runner.verify_attestation( + &executable_path, + &provenance_path, + request, + PredicateKind::Provenance, + deadline, + cancellation, + )?; + let spdx_proof = runner.verify_attestation( + &executable_path, + &sbom_path, + request, + PredicateKind::Spdx, + deadline, + cancellation, + )?; + let spdx_bytes = spdx.document_bytes()?; + let build_evidence_bytes = build_evidence.document_bytes()?; + let build_recipe_bytes = build_recipe.document_bytes()?; + let rebuild_check_bytes = rebuild_check.document_bytes()?; + let inventory_bytes = inventory.document_bytes()?; + let consumed = [ConsumedArtifact { + name: asset_name, + executable: executable.measurement(), + spdx: &spdx_bytes, + build_evidence: &build_evidence_bytes, + build_recipe: &build_recipe_bytes, + rebuild_check: &rebuild_check_bytes, + provenance_bundle: provenance.measurement(), + sbom_bundle: sbom_bundle.measurement(), + provenance_proof: &provenance_proof, + spdx_proof: &spdx_proof, + }]; + let build = verify_build_evidence(&EvidenceInputs { + release: request, + consumer_policy: &policy, + source_policy: &source_policy, + index_bytes: &index_bytes, + index_proof: &index_proof, + index_bundle: index_bundle.measurement(), + inventory_bytes: &inventory_bytes, + artifacts: &consumed, + })?; + Ok(BuildResult { + build, + executable, + index_bundle: index_bundle.measurement().clone(), + }) +} + +/// Verified build and retained executable, before online publication checks. +pub(super) struct BuildResult { + pub(super) build: BuildEvidenceVerified, + pub(super) executable: RetainedAsset, + pub(super) index_bundle: super::artifact::AssetMeasurement, +} + +/// Completes online publication checks and emits a read-only report. +pub(super) async fn verify_with( + transport: &impl Transport, + runner: &GhRunner, + request: &ApprovedRelease, + asset_name: &str, + deadline: AttemptDeadline, + cancellation: &Cancellation, +) -> Result> { + let built = build_with( + transport, + runner, + request, + asset_name, + deadline, + cancellation, + ) + .await?; + let checksums = transport + .asset( + github::CHECKSUMS_NAME, + AssetRole::Metadata, + deadline, + cancellation, + ) + .await?; + let release = transport.publication(false, deadline, cancellation).await?; + let tag_ref = transport.publication(true, deadline, cancellation).await?; + let statement = runner.verify_release(request, deadline, cancellation)?; + let publication = github::verify_publication( + built.build, + &PublicationInputs { + release: &release, + tag_ref: &tag_ref, + release_statement: &statement, + index_bundle: &built.index_bundle, + checksums: checksums.measurement(), + }, + )?; + let mut asset = VerifiedReleaseAsset::from_publication_bounded( + &publication, + built.executable, + deadline, + cancellation, + )?; + VerificationReport::new_bounded(&publication, &mut asset, deadline, cancellation) +} + +/// Entry point used only by the public command adapter. +pub(crate) fn verify(tag: &str, asset: &str, commit: &str) -> Result<(), String> { + let request = validate_request(tag, asset, commit).map_err(|error| format!("{error:?}"))?; + let caller = CallerEnvironment::from_process(); + let token = GithubToken::select(&caller).map_err(|error| format!("{error:?}"))?; + let deadline = AttemptDeadline::start(); + let cancellation = Cancellation::default(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .map_err(|_| "cannot create release verification runtime".to_owned())?; + // Register the listener synchronously before any gh child can start. + #[cfg(any(target_os = "macos", target_os = "linux"))] + let signal_task = { + let mut interrupt = { + let _entered = runtime.enter(); + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .map_err(|_| "cannot register release cancellation signal".to_owned())? + }; + let requested = cancellation.clone(); + runtime.spawn(async move { + if interrupt.recv().await.is_some() { + requested.cancel(); + } + }) + }; + let result = runtime + .block_on(async { + let runner = GhRunner::resolve( + GhLocation::SearchPath, + &caller, + ProcessBounds::production(), + deadline, + &cancellation, + )?; + let client = source_policy::policy_client()?; + let transport = OfficialTransport { + client, + release: &request, + token, + }; + verify_with( + &transport, + &runner, + &request, + asset, + deadline, + &cancellation, + ) + .await + }) + .map_err(|error| format!("{error:?}"))?; + super::artifact::check_attempt(deadline, &cancellation) + .map_err(|error| format!("{error:?}"))?; + let written = crate::output::write_stdout_line(format_report(&result)) + .map_err(|error| format!("{error:?}")); + #[cfg(any(target_os = "macos", target_os = "linux"))] + signal_task.abort(); + written +} + +fn format_report(report: &VerificationReport) -> String { + format!( + "Verified {}/{}\nSource: {}\nAsset: {}\nSHA-256: {}\nBytes: {}\nConsumer policy: {} {}\nProducer policy: {} {}\nAssurance: {}\nBuild evidence: verified\nOfficial immutable publication: verified\nRebuild comparison: {}", + report.repository, + report.tag, + report.source_commit, + report.asset, + report.asset_sha256, + report.asset_length, + report.consumer_policy_version, + report.consumer_policy_sha256, + report.producer_policy_version, + report.producer_policy_sha256, + report.assurance_profile, + report.comparison + ) +} diff --git a/crates/trusted-server-cli/src/release/source_policy.rs b/crates/trusted-server-cli/src/release/source_policy.rs index 36f347c46a..cf6572d965 100644 --- a/crates/trusted-server-cli/src/release/source_policy.rs +++ b/crates/trusted-server-cli/src/release/source_policy.rs @@ -83,6 +83,11 @@ pub(crate) struct ApprovedSourceRoot { } impl ApprovedSourceRoot { + /// Returns the independently approved source commit. + pub(crate) fn commit(&self) -> &ApprovedCommit { + &self.commit + } + /// Opens an absolute snapshot root directory bound to its approved commit. /// /// # Errors @@ -237,7 +242,9 @@ pub(crate) fn policy_client() -> Result> { /// The shared policy-client configuration: no redirect is ever followed. fn no_redirect_builder() -> ClientBuilder { crate::tls::install_crypto_provider(); - Client::builder().redirect(reqwest::redirect::Policy::none()) + Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) } /// Builds the production client configuration without `https_only`, so a diff --git a/crates/trusted-server-cli/src/release/tests/gh/argv.rs b/crates/trusted-server-cli/src/release/tests/gh/argv.rs index 9490b07a73..e5053f3767 100644 --- a/crates/trusted-server-cli/src/release/tests/gh/argv.rs +++ b/crates/trusted-server-cli/src/release/tests/gh/argv.rs @@ -106,7 +106,9 @@ fn release_argv_is_fixed() { "verify", "v1.2.0", "-R", - "github.com/IABTechLab/trusted-server" + "github.com/IABTechLab/trusted-server", + "--format", + "json" ]), "should match reviewed release verify" ); diff --git a/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs b/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs index 7674a59f59..2421b82736 100644 --- a/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs +++ b/crates/trusted-server-cli/src/release/tests/gh/lifecycle.rs @@ -6,6 +6,218 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::*; +#[test] +fn successful_attestation_proof_is_bound_to_exact_measured_inputs() { + let fake = Fake::script("echo '[{\"verificationResult\":{}}]'"); + let subject = fake.file("subject.wasm"); + let bundle = fake.file("subject.wasm.provenance.sigstore.json"); + fs::write(&subject, b"synthetic executable").expect("should create subject"); + fs::write(&bundle, b"{}").expect("should create bundle"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let proof = runner + .verify_attestation( + &subject, + &bundle, + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default(), + ) + .expect("should obtain typed successful fake-child result"); + let measured_subject = + crate::release::artifact::AssetMeasurement::of_bytes(b"synthetic executable"); + let measured_bundle = crate::release::artifact::AssetMeasurement::of_bytes(b"{}"); + let changed = crate::release::artifact::AssetMeasurement::of_bytes(b"changed"); + assert!( + proof + .for_inputs( + &release(), + PredicateKind::Provenance, + &measured_subject, + &measured_bundle + ) + .is_ok(), + "should accept matching invocation inputs" + ); + for (predicate, subject, bundle) in [ + (PredicateKind::Spdx, &measured_subject, &measured_bundle), + (PredicateKind::Provenance, &changed, &measured_bundle), + (PredicateKind::Provenance, &measured_subject, &changed), + ] { + assert!( + proof + .for_inputs(&release(), predicate, subject, bundle) + .is_err(), + "should reject changed invocation input" + ); + } + let other_release = ApprovedRelease::new( + ReleaseTag::parse("v1.2.1").expect("should parse another tag"), + ApprovedCommit::parse(COMMIT).expect("should parse commit"), + ); + assert!( + proof + .for_inputs( + &other_release, + PredicateKind::Provenance, + &measured_subject, + &measured_bundle + ) + .is_err(), + "should reject another approved release" + ); +} + +#[test] +fn attestation_child_reads_private_snapshot_during_transient_substitution() { + let fake = Fake::empty(); + let subject = fake.file("subject.wasm"); + let original = fake.file("original.wasm"); + let bundle = fake.file("subject.wasm.provenance.sigstore.json"); + fs::write(&subject, b"synthetic executable").expect("should create subject"); + fs::write(&original, b"synthetic executable").expect("should retain original fixture bytes"); + fs::write(&bundle, b"{}").expect("should create bundle"); + fake.install(&format!( + "printf 'substituted' > '{subject}'\ncmp -s \"$3\" '{original}' || exit 1\ncp '{original}' '{subject}'\necho '[{{\"verificationResult\":{{}}}}]'", + subject = subject.display(), original = original.display(), + )); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + assert!( + runner + .verify_attestation( + &subject, + &bundle, + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default() + ) + .is_ok(), + "should verify only private original bytes despite transient caller-path substitution" + ); +} + +#[test] +fn release_verification_requires_successful_single_json_object() { + for (body, accepted) in [ + ("echo '{\"verificationResult\":{}}'", true), + ("echo '[{\"verificationResult\":{}}]'", false), + ("echo '{\"verificationResult\":'", false), + ("echo '{\"verificationResult\":{}}'; exit 1", false), + ] { + let fake = Fake::script(body); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let result = runner.verify_release( + &release(), + AttemptDeadline::start(), + &Cancellation::default(), + ); + assert_eq!( + result.is_ok(), + accepted, + "should enforce release output contract" + ); + if let Ok(proof) = result { + assert!( + proof.for_release(&release()).is_ok(), + "should bind approved release" + ); + let other = ApprovedRelease::new( + ReleaseTag::parse("v1.2.1").expect("should parse another tag"), + ApprovedCommit::parse(COMMIT).expect("should parse commit"), + ); + assert!( + proof.for_release(&other).is_err(), + "should reject another release" + ); + } + } +} + +#[test] +fn rejects_attestation_input_substitution_during_successful_child() { + for substituted in ["subject", "bundle"] { + let fake = Fake::empty(); + let subject = fake.file("subject.wasm"); + let bundle = fake.file("subject.wasm.provenance.sigstore.json"); + fs::write(&subject, b"original executable").expect("should create subject"); + fs::write(&bundle, b"{}").expect("should create bundle"); + let changed = if substituted == "subject" { + &subject + } else { + &bundle + }; + fake.install(&format!( + "printf 'substituted' > '{}'\necho '[{{\"verificationResult\":{{}}}}]'", + changed.display() + )); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + let result = runner.verify_attestation( + &subject, + &bundle, + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default(), + ); + assert!( + result.is_err(), + "should reject substituted {substituted} despite child success" + ); + } +} + +#[test] +fn rejects_oversize_subject_before_invoking_attestation_child() { + let fake = Fake::script("echo '[{\"verificationResult\":{}}]'"); + let subject = fake.file("subject.wasm"); + File::create(&subject) + .expect("should create sparse subject") + .set_len(crate::release::bounds::EXECUTABLE_BYTES + 1) + .expect("should set oversize length"); + let bundle = fake.file("subject.wasm.provenance.sigstore.json"); + fs::write(&bundle, b"{}").expect("should create bundle"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + assert!( + runner + .verify_attestation( + &subject, + &bundle, + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default() + ) + .is_err(), + "should reject oversize subject" + ); +} + +#[test] +fn rejects_symlinked_attestation_input_before_child() { + let fake = Fake::script("echo '[{\"verificationResult\":{}}]'"); + let subject = fake.file("subject.wasm"); + let actual = fake.file("actual.wasm"); + fs::write(&actual, b"synthetic executable").expect("should create actual file"); + symlink(&actual, &subject).expect("should create substituted symlink"); + let bundle = fake.file("subject.wasm.provenance.sigstore.json"); + fs::write(&bundle, b"{}").expect("should create bundle"); + let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); + assert!( + runner + .verify_attestation( + &subject, + &bundle, + &release(), + PredicateKind::Provenance, + AttemptDeadline::start(), + &Cancellation::default() + ) + .is_err(), + "should reject symlink input" + ); +} + // Bounds, termination and reaping. #[test] @@ -149,6 +361,11 @@ fn truncated_json_is_rejected() { let fake = Fake::script("printf '[{\"verificationResult\":'"); let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); let subject = fake.file("subject.wasm"); + fs::write(&subject, b"original executable").expect("should create subject"); + fs::write(fake.file("subject.wasm.provenance.sigstore.json"), b"{}") + .expect("should create provenance bundle"); + fs::write(fake.file("subject.wasm.sbom.sigstore.json"), b"{}") + .expect("should create SPDX bundle"); let (error, _) = context(runner.verify_attestation( &subject, @@ -173,6 +390,11 @@ fn failed_exit_yields_no_claims_and_redacts_token() { ); let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); let subject = fake.file("subject.wasm"); + fs::write(&subject, b"original executable").expect("should create subject"); + fs::write(fake.file("subject.wasm.provenance.sigstore.json"), b"{}") + .expect("should create provenance bundle"); + fs::write(fake.file("subject.wasm.sbom.sigstore.json"), b"{}") + .expect("should create SPDX bundle"); let (error, text) = context(runner.verify_attestation( &subject, @@ -212,6 +434,11 @@ fn requires_exactly_one_attestation_result() { let fake = Fake::script(&format!("echo '{output}'")); let runner = fake.runner(test_bounds()).expect("should resolve fake gh"); let subject = fake.file("subject.wasm"); + fs::write(&subject, b"original executable").expect("should create subject"); + fs::write(fake.file("subject.wasm.provenance.sigstore.json"), b"{}") + .expect("should create provenance bundle"); + fs::write(fake.file("subject.wasm.sbom.sigstore.json"), b"{}") + .expect("should create SPDX bundle"); let result = runner.verify_attestation( &subject, diff --git a/crates/trusted-server-cli/src/release/tests/mod.rs b/crates/trusted-server-cli/src/release/tests/mod.rs index b862122c54..bf17d974d9 100644 --- a/crates/trusted-server-cli/src/release/tests/mod.rs +++ b/crates/trusted-server-cli/src/release/tests/mod.rs @@ -1,5 +1,10 @@ //! Private release verification tests, registered explicitly so focused runs -//! (`release::tests::documents`, `release::tests::gh`) select real cases. +//! (`release::tests::documents`, `release::tests::gh`, +//! `release::tests::verification`) select real cases. mod documents; mod gh; +mod verification; + +mod offline; +mod online; diff --git a/crates/trusted-server-cli/src/release/tests/offline.rs b/crates/trusted-server-cli/src/release/tests/offline.rs new file mode 100644 index 0000000000..c4363658da --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/offline.rs @@ -0,0 +1,187 @@ +//! Offline build evidence uses independent roots and pins; never publication. + +use crate::release::bounds::{AttemptDeadline, Cancellation, ProcessBounds}; +use crate::release::documents::sha256_hex; +use crate::release::environment::CallerEnvironment; +use crate::release::gh::{GhLocation, GhRunner}; +use crate::release::offline::{OfflineApproval, verify_build}; +use crate::release::policy::EMBEDDED_POLICY; +use crate::release::source_policy::ApprovedSourceRoot; +use crate::release::tests::online::Case; +use crate::release::tests::verification::fixture::{self, ASSET, COMMIT}; +use std::fs; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt as _; +use std::process::Command; +use std::time::Duration; + +#[test] +fn offline_runner_requires_independent_roots_digest_without_token() { + let directory = tempfile::tempdir().expect("should create private directory"); + let gh = directory.path().join("gh"); + fs::write(&gh, "#!/bin/sh\n[ -z \"$GH_TOKEN$GITHUB_TOKEN\" ] || exit 9\necho 'gh version 2.97.0 (2026-01-01)'\n").expect("should write fake gh"); + fs::set_permissions(&gh, fs::Permissions::from_mode(0o700)).expect("should mark executable"); + let roots = directory.path().join("trusted_root.jsonl"); + fs::write(&roots, b"fictional independent root snapshot\n").expect("should write roots"); + let caller = CallerEnvironment::default(); + let resolve = |pin: &str| { + GhRunner::resolve_offline( + GhLocation::Explicit(&gh), + &caller, + ProcessBounds::production(), + AttemptDeadline::start(), + &Cancellation::default(), + &roots, + pin, + ) + }; + assert!( + resolve(&"0".repeat(64)).is_err(), + "should reject transferred roots that do not match independent pin" + ); + assert!( + resolve(&sha256_hex(b"fictional independent root snapshot\n")).is_ok(), + "should resolve offline with token absent" + ); +} + +async fn verify_case(case: &Case, pin: &str) -> bool { + let source_directory = case.directory.path().join("approved-source"); + fs::create_dir_all(source_directory.join("crates/trusted-server-cli")) + .expect("should create approved source layout"); + fs::write( + source_directory.join("crates/trusted-server-cli/release-policy.json"), + EMBEDDED_POLICY, + ) + .expect("should write independently approved policy"); + let release = fixture::approved(COMMIT); + let source = ApprovedSourceRoot::new(release.commit().clone(), &source_directory) + .expect("should open approved source root"); + let roots = case.directory.path().join("trusted_root.jsonl"); + fs::write(&roots, b"fictional independently transported roots\n") + .expect("should write root snapshot"); + let roots_sha256 = sha256_hex(b"fictional independently transported roots\n"); + let approval = OfflineApproval { + release: &release, + source: &source, + asset: ASSET, + artifact_sha256: pin, + roots: &roots, + roots_sha256: &roots_sha256, + gh: &case.gh, + }; + verify_build( + &approval, + case.directory.path(), + &CallerEnvironment::default(), + AttemptDeadline::start(), + &Cancellation::default(), + ) + .await + .is_ok() +} + +#[tokio::test] +async fn offline_complete_synthetic_graph_checks_independent_artifact_pin() { + let case = Case::new(); + case.write_assets(); + assert!( + verify_case(&case, &sha256_hex(&case.signed.documents.executable)).await, + "should verify synthetic build graph without token" + ); + assert!( + !verify_case(&case, &"0".repeat(64)).await, + "should reject a substituted independently pinned artifact" + ); +} + +#[test] +#[cfg(target_os = "macos")] +fn offline_build_verification_runs_under_proven_os_network_denial() { + const CHILD: &str = "TS_INTERNAL_OFFLINE_DENIAL_TEST_CHILD"; + if let Ok(address) = std::env::var(CHILD) { + let address: SocketAddr = address.parse().expect("should parse control address"); + assert!( + TcpStream::connect_timeout(&address, Duration::from_secs(1)).is_err(), + "should deny socket inside offline verification boundary" + ); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build offline runtime"); + let case = Case::new(); + case.write_assets(); + assert!( + runtime.block_on(verify_case( + &case, + &sha256_hex(&case.signed.documents.executable) + )), + "should verify local build evidence with networking denied and tokens absent" + ); + return; + } + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind unconfined socket control"); + let address = listener.local_addr().expect("should read control address"); + let _control = TcpStream::connect_timeout(&address, Duration::from_secs(1)) + .expect("should prove unconfined socket succeeds"); + let output = Command::new("/usr/bin/sandbox-exec") + .args(["-p", "(version 1)(allow default)(deny network*)"]) + .arg(std::env::current_exe().expect("should locate test binary")) + .args(["--exact", "release::tests::offline::offline_build_verification_runs_under_proven_os_network_denial", "--nocapture"]) + .env_clear().env("PATH", "/usr/bin:/bin").env(CHILD, address.to_string()) + .output().expect("should execute offline test under network denial"); + assert!( + output.status.success(), + "should pass denied-network offline build test: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[tokio::test] +async fn actual_offline_proofs_cannot_upgrade_through_online_publication_composition() { + let case = Case::new(); + let roots = case.directory.path().join("trusted_root.jsonl"); + fs::write(&roots, b"fictional independently approved roots") + .expect("should write pinned roots"); + let runner = GhRunner::resolve_offline( + GhLocation::Explicit(&case.gh), + &CallerEnvironment::default(), + ProcessBounds::production(), + AttemptDeadline::start(), + &Cancellation::default(), + &roots, + &sha256_hex(b"fictional independently approved roots"), + ) + .expect("should resolve token-free offline runner"); + let release = fixture::approved(COMMIT); + let built = crate::release::online::build_with( + &case, + &runner, + &release, + ASSET, + AttemptDeadline::start(), + &Cancellation::default(), + ) + .await + .expect("should build actual offline-mode proofs through fake child"); + let online_statement = case + .runner() + .verify_release(&release, AttemptDeadline::start(), &Cancellation::default()) + .expect("should obtain separately successful online-mode release proof"); + let result = crate::release::github::verify_publication( + built.build, + &crate::release::github::PublicationInputs { + release: &case.signed.publication.release, + tag_ref: &case.signed.publication.tag_ref, + release_statement: &online_statement, + index_bundle: &built.index_bundle, + checksums: &crate::release::artifact::AssetMeasurement::of_bytes( + &case.signed.publication.checksums, + ), + }, + ); + assert!( + result.is_err(), + "should prevent offline proofs upgrading through otherwise valid online publication data" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/online.rs b/crates/trusted-server-cli/src/release/tests/online.rs new file mode 100644 index 0000000000..a599f275f6 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/online.rs @@ -0,0 +1,532 @@ +//! Deterministic transport and retained-byte orchestration tests. + +use crate::release::artifact::{AssetRole, RetainedAsset, RetainedDownload}; +use crate::release::bounds::{AttemptDeadline, Cancellation, ProcessBounds}; +use crate::release::documents::ApprovedCommit; +use crate::release::environment::CallerEnvironment; +use crate::release::error::{ReleaseError, report}; +use crate::release::gh::{GhLocation, GhRunner}; +use crate::release::online::{Transport, verify_with}; +use crate::release::online::{allowed_redirect, approved_asset_url, validate_request}; +use crate::release::policy::EMBEDDED_POLICY; +use crate::release::source_policy::ApprovedSourcePolicy; +use crate::release::tests::verification::fixture::{self, ASSET, Signed}; +use error_stack::Report; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::PathBuf; +use tempfile::TempDir; +use url::Url; + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +#[test] +fn inputs_are_validated_without_environment() { + assert!( + validate_request("v1.2.0", "ts-aarch64-apple-darwin", COMMIT).is_ok(), + "should accept required public path" + ); + for (tag, asset, commit) in [ + ("latest", "ts-aarch64-apple-darwin", COMMIT), + ("v1.2.0", "../../ts", COMMIT), + ("v1.2.0", "ts-aarch64-apple-darwin", "main"), + ] { + assert!( + validate_request(tag, asset, commit).is_err(), + "should reject invalid request before any I/O" + ); + } +} + +#[test] +fn download_url_and_redirects_are_fixed_and_credential_free() { + let release = validate_request("v1.2.0", "ts-aarch64-apple-darwin", COMMIT) + .expect("should validate request"); + let url = + approved_asset_url(&release, "release-index.json").expect("should construct canonical URL"); + assert_eq!( + url.as_str(), + "https://github.com/IABTechLab/trusted-server/releases/download/v1.2.0/release-index.json", + "should use fixed official repository" + ); + for value in [ + "https://example.com/file", + "http://release-assets.githubusercontent.com/file", // allow-domain: release-assets.githubusercontent.com + "https://user:secret@release-assets.githubusercontent.com/file", // allow-domain: release-assets.githubusercontent.com + "https://release-assets.githubusercontent.com:444/file", // allow-domain: release-assets.githubusercontent.com + "https://release-assets.githubusercontent.com/file#fragment", // allow-domain: release-assets.githubusercontent.com + "https://github.com/other/file", + ] { + assert!( + !allowed_redirect(&Url::parse(value).expect("should parse URL")), + "should reject unapproved redirect {value}" + ); + } + assert!( + allowed_redirect( + &Url::parse("https://release-assets.githubusercontent.com/file?signature=fictional") // allow-domain: release-assets.githubusercontent.com + .expect("should parse CDN URL") + ), + "should allow documented release CDN" + ); + assert!( + approved_asset_url(&release, "../bad").is_err(), + "should reject traversal" + ); +} + +#[test] +fn retained_materialization_preserves_canonical_bytes_without_overwrite() { + let directory = tempfile::tempdir().expect("should create private directory"); + let mut download = RetainedDownload::start("ts-aarch64-apple-darwin", AssetRole::Executable) + .expect("should start retention"); + download + .push(&vec![17; 128 * 1024 + 1]) + .expect("should retain multiple chunks"); + let mut asset = download.finish().expect("should finish retention"); + let path = asset + .materialize( + directory.path(), + AttemptDeadline::start(), + &Cancellation::default(), + ) + .expect("should materialize retained executable"); + assert_eq!( + std::fs::metadata(&path).expect("should stat copy").len(), + asset.measurement().length(), + "should preserve measured length" + ); + assert_eq!( + crate::release::artifact::AssetMeasurement::of_bytes( + &std::fs::read(path).expect("should read copy") + ), + *asset.measurement(), + "should preserve retained digest" + ); + assert!( + asset + .materialize( + directory.path(), + AttemptDeadline::start(), + &Cancellation::default() + ) + .is_err(), + "should never overwrite existing canonical path" + ); +} + +/// Synthetic proofs are emitted by a real bounded OS child, not a crypto fixture. +pub(super) struct Case { + pub(super) directory: TempDir, + pub(super) gh: PathBuf, + pub(super) assets: BTreeMap>, + pub(super) signed: Signed, + pub(super) requests: RefCell>, +} + +impl Case { + pub(super) fn new() -> Self { + let signed = fixture::consistent(); + let directory = tempfile::tempdir().expect("should create fake transport directory"); + let documents = &signed.documents; + let mut assets = BTreeMap::from([ + ("release-index.json".to_owned(), signed.index_bytes.clone()), + ( + "release-index.provenance.sigstore.json".to_owned(), + signed.publication.index_bundle.clone(), + ), + ( + "tsjs-inventory.json".to_owned(), + documents.inventory.clone(), + ), + ( + "SHA256SUMS".to_owned(), + signed.publication.checksums.clone(), + ), + (ASSET.to_owned(), documents.executable.clone()), + ]); + for (suffix, bytes) in [ + (".provenance.sigstore.json", &documents.provenance_bundle), + (".sbom.sigstore.json", &documents.sbom_bundle), + (".spdx.json", &documents.spdx), + (".build-evidence.json", &documents.build_evidence), + (".build-recipe.json", &documents.build_recipe), + (".rebuild-check.json", &documents.rebuild_check), + ] { + assets.insert(format!("{ASSET}{suffix}"), bytes.clone()); + } + let gh = directory.path().join("gh"); + let case = Self { + directory, + gh, + assets, + signed, + requests: RefCell::new(Vec::new()), + }; + case.install_proofs(); + case + } + + pub(super) fn install_proofs(&self) { + for (name, proof) in [ + ("index-proof", json!([self.signed.proofs.index])), + ("artifact-proof", json!([self.signed.proofs.provenance])), + ("sbom-proof", json!([self.signed.proofs.spdx])), + ("release-proof", self.signed.publication.statement.clone()), + ] { + fs::write( + self.directory.path().join(name), + serde_json::to_vec(&proof).expect("should encode synthetic proof"), + ) + .expect("should write proof response"); + } + let root = self.directory.path().display(); + let script = format!( + "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'gh version 2.97.0 (2026-01-01)'; exit 0; fi\nif [ \"$1\" = release ]; then /bin/cat '{root}/release-proof'; exit 0; fi\ncase \"$*\" in *--custom-trusted-root*) [ -z \"$GH_TOKEN$GITHUB_TOKEN\" ] || exit 9;; esac\ncase \"$3\" in */release-index.json) /bin/cat '{root}/index-proof';; *) case \"$*\" in *spdx.dev*) /bin/cat '{root}/sbom-proof';; *) /bin/cat '{root}/artifact-proof';; esac;; esac\n" + ); + fs::write(&self.gh, script).expect("should install fake gh"); + fs::set_permissions(&self.gh, fs::Permissions::from_mode(0o700)) + .expect("should mark fake gh executable"); + } + + pub(super) fn runner(&self) -> GhRunner { + GhRunner::resolve( + GhLocation::Explicit(&self.gh), + &CallerEnvironment::from_pairs([("GH_TOKEN", "fictional-read-only-token")]), + ProcessBounds::production(), + AttemptDeadline::start(), + &Cancellation::default(), + ) + .expect("should resolve fake trusted gh") + } + + pub(super) fn write_assets(&self) { + for (name, bytes) in &self.assets { + fs::write(self.directory.path().join(name), bytes) + .expect("should write transferred evidence"); + } + } +} + +impl Transport for Case { + async fn policy( + &self, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + crate::release::artifact::check_attempt(deadline, cancellation)?; + self.requests.borrow_mut().push("policy".to_owned()); + Ok(ApprovedSourcePolicy::for_tests( + ApprovedCommit::parse(COMMIT)?, + EMBEDDED_POLICY.to_vec(), + )) + } + + async fn asset( + &self, + name: &str, + role: AssetRole, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + crate::release::artifact::check_attempt(deadline, cancellation)?; + self.requests.borrow_mut().push(name.to_owned()); + if std::time::Instant::now() >= deadline.instant() { + return Err(report( + ReleaseError::Timeout, + "fake transport deadline elapsed", + )); + } + let bytes = self + .assets + .get(name) + .ok_or_else(|| report(ReleaseError::Asset, "fake transport asset absent"))?; + let mut retained = RetainedDownload::start(name, role)?; + for chunk in bytes.chunks(17) { + retained.push(chunk)?; + } + retained.finish() + } + + async fn publication( + &self, + tag_ref: bool, + deadline: AttemptDeadline, + cancellation: &Cancellation, + ) -> Result> { + crate::release::artifact::check_attempt(deadline, cancellation)?; + self.requests.borrow_mut().push("publication".to_owned()); + Ok(if tag_ref { + self.signed.publication.tag_ref.clone() + } else { + self.signed.publication.release.clone() + }) + } +} + +#[tokio::test] +async fn full_online_orchestration_joins_retained_bytes_and_typed_child_proofs() { + let case = Case::new(); + let report = verify_with( + &case, + &case.runner(), + &fixture::approved(COMMIT), + ASSET, + AttemptDeadline::start(), + &Cancellation::default(), + ) + .await + .expect("should verify complete synthetic graph through bounded child"); + assert_eq!(report.asset, ASSET, "should report selected verified asset"); + assert_eq!( + report.source_commit, COMMIT, + "should retain independently approved commit" + ); +} + +#[tokio::test] +async fn untrusted_index_proof_fails_before_any_index_selected_download() { + let mut case = Case::new(); + case.signed.proofs.index["verificationResult"]["signature"]["certificate"]["sourceRepositoryDigest"] = + json!("0".repeat(40)); + case.install_proofs(); + assert!( + verify_with( + &case, + &case.runner(), + &fixture::approved(COMMIT), + ASSET, + AttemptDeadline::start(), + &Cancellation::default() + ) + .await + .is_err(), + "should reject certificate mismatch" + ); + assert_eq!( + *case.requests.borrow(), + [ + "policy", + "release-index.json", + "release-index.provenance.sigstore.json" + ], + "should verify index provenance before using its fields" + ); +} + +#[tokio::test] +async fn malformed_truncated_and_failed_proof_children_never_produce_reports() { + for output in [ + "printf '[{'", + "printf '[{}]'; exit 7", + "printf '[{\"a\":1,\"a\":2}]'", + ] { + let case = Case::new(); + fs::write(&case.gh, format!("#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'gh version 2.97.0 (2026-01-01)'; exit 0; fi\n{output}\n")).expect("should install malformed proof child"); + assert!( + verify_with( + &case, + &case.runner(), + &fixture::approved(COMMIT), + ASSET, + AttemptDeadline::start(), + &Cancellation::default() + ) + .await + .is_err(), + "should reject incomplete, ambiguous or failed proof" + ); + } +} + +#[tokio::test] +async fn changed_companion_and_publication_fail_closed() { + for mutation in ["companion", "publication"] { + let mut case = Case::new(); + if mutation == "companion" { + case.assets + .insert(format!("{ASSET}.build-evidence.json"), b"{}".to_vec()); + } else { + case.signed.publication.release["immutable"] = json!(false); + } + assert!( + verify_with( + &case, + &case.runner(), + &fixture::approved(COMMIT), + ASSET, + AttemptDeadline::start(), + &Cancellation::default() + ) + .await + .is_err(), + "should reject {mutation} mismatch" + ); + } +} + +#[tokio::test] +async fn elapsed_attempt_deadline_produces_no_verification_report() { + let case = Case::new(); + assert!( + verify_with( + &case, + &case.runner(), + &fixture::approved(COMMIT), + ASSET, + AttemptDeadline::at(std::time::Instant::now()), + &Cancellation::default() + ) + .await + .is_err(), + "should reject elapsed attempt" + ); +} + +#[tokio::test] +async fn bounded_child_timeout_fails_full_orchestration_without_publication() { + let case = Case::new(); + fs::write(&case.gh, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'gh version 2.97.0 (2026-01-01)'; exit 0; fi\nexec /bin/sleep 10\n").expect("should install stalled proof child"); + let mut bounds = ProcessBounds::production(); + bounds.child_timeout = std::time::Duration::from_millis(50); + let runner = case.runner().with_bounds(bounds); + assert!( + verify_with( + &case, + &runner, + &fixture::approved(COMMIT), + ASSET, + AttemptDeadline::start(), + &Cancellation::default() + ) + .await + .is_err(), + "should fail complete orchestration when proof child times out" + ); + assert!( + !case.requests.borrow().contains(&"publication".to_owned()), + "should not acquire publication after failed build proof" + ); +} + +#[tokio::test] +async fn stalled_http_headers_and_bodies_cancel_promptly() { + for headers in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind stalled HTTP fixture"); + let address = listener + .local_addr() + .expect("should obtain fixture address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("should accept HTTP request"); + let mut request = [0_u8; 1024]; + tokio::io::AsyncReadExt::read(&mut socket, &mut request) + .await + .expect("should read request"); + if headers { + tokio::io::AsyncWriteExt::write_all( + &mut socket, + b"HTTP/1.1 200 OK\r\nContent-Length: 4096\r\n\r\n", + ) + .await + .expect("should send stalled body headers"); + } + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + }); + let cancellation = Cancellation::default(); + let requested = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + requested.cancel(); + }); + let client = crate::release::source_policy::plain_http_policy_client_for_tests() + .expect("should create no-redirect fixture client"); + let transfer = async { + let response = client + .get(format!("http://{address}/fixture")) + .send() + .await + .map_err(|_| report(ReleaseError::Asset, "fixture request failed"))?; + crate::release::artifact::stream_into( + response, + RetainedDownload::start("release-index.json", AssetRole::Metadata)?, + AttemptDeadline::start(), + ) + .await + }; + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + crate::release::online::cancellable(transfer, &cancellation), + ) + .await + .expect("should cancel stalled HTTP before normal attempt deadline"); + assert_eq!( + result + .expect_err("should reject cancelled transfer") + .current_context(), + &ReleaseError::Cancelled, + "should cancel both header and chunk waits" + ); + server.abort(); + } +} + +#[test] +fn final_asset_and_report_hashes_honor_deadline_and_cancellation() { + let signed = fixture::consistent(); + let publication = signed + .publish() + .expect("should verify synthetic publication"); + let retain = || { + let mut download = RetainedDownload::start(ASSET, AssetRole::Executable) + .expect("should start retained executable"); + download + .push(&signed.documents.executable) + .expect("should retain fixture bytes"); + download.finish().expect("should finish retained bytes") + }; + let expired = AttemptDeadline::at(std::time::Instant::now()); + assert!( + crate::release::artifact::VerifiedReleaseAsset::from_publication_bounded( + &publication, + retain(), + expired, + &Cancellation::default() + ) + .is_err(), + "should not construct final asset after deadline" + ); + let mut asset = crate::release::artifact::VerifiedReleaseAsset::from_publication_bounded( + &publication, + retain(), + AttemptDeadline::start(), + &Cancellation::default(), + ) + .expect("should construct bounded final asset"); + assert!( + crate::release::artifact::VerificationReport::new_bounded( + &publication, + &mut asset, + expired, + &Cancellation::default() + ) + .is_err(), + "should produce no report after final-phase deadline" + ); + let cancelled = Cancellation::default(); + cancelled.cancel(); + assert!( + crate::release::artifact::VerificationReport::new_bounded( + &publication, + &mut asset, + AttemptDeadline::start(), + &cancelled + ) + .is_err(), + "should produce no report after final-phase cancellation" + ); +} diff --git a/crates/trusted-server-cli/src/release/tests/verification/fixture.rs b/crates/trusted-server-cli/src/release/tests/verification/fixture.rs new file mode 100644 index 0000000000..741e622a76 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/verification/fixture.rs @@ -0,0 +1,561 @@ +//! A fully synthetic fictional release evidence graph, built in three +//! mutable stages so each test alters exactly one edge: +//! +//! 1. [`Graph`]: companion documents and executable bytes; +//! 2. [`Built`]: the release index derived from those exact bytes; +//! 3. [`Signed`]: gh-shaped proofs and publication JSON derived from the +//! exact index and companion bytes. + +use error_stack::Report; +use serde_json::{Value, json}; + +use crate::release::artifact::AssetMeasurement; +use crate::release::documents::{ApprovedCommit, ApprovedRelease, ReleaseTag, sha256_hex}; +use crate::release::error::ReleaseError; +use crate::release::evidence::{ + BuildEvidenceVerified, ConsumedArtifact, EvidenceInputs, verify_build_evidence, +}; +use crate::release::gh::{PredicateKind, VerifiedAttestation, VerifiedReleaseStatement}; +use crate::release::github::{PublicationInputs, PublicationVerified, verify_publication}; +use crate::release::policy::{EMBEDDED_POLICY, embedded_policy}; +use crate::release::source_policy::ApprovedSourcePolicy; + +const INDEX_FIXTURE: &[u8] = + include_bytes!("../../../../tests/fixtures/release/release-index.json"); +pub(crate) const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; +pub(crate) const OTHER_COMMIT: &str = "89abcdef0123456789abcdef0123456789abcdef"; +pub(crate) const TAG: &str = "v1.2.0"; +pub(crate) const ASSET: &str = "trusted-server-fastly.wasm"; +pub(crate) const RUN: &str = + "https://github.com/IABTechLab/trusted-server/actions/runs/1234567890/attempts/1"; +pub(crate) const OTHER_RUN: &str = + "https://github.com/IABTechLab/trusted-server/actions/runs/1234567890/attempts/2"; +pub(crate) const SAN: &str = "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/v1.2.0"; +pub(crate) const CALLER: &str = + "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.2.0"; +const REPOSITORY_URI: &str = "https://github.com/IABTechLab/trusted-server"; +const RELEASE_SIGNER: &str = "https://dotcom.releases.github.com"; // allow-domain: dotcom.releases.github.com + +/// Suffixes of the five artifact provenance subjects besides the executable. +pub(crate) const PROVENANCE_SUFFIXES: [&str; 4] = [ + ".spdx.json", + ".build-evidence.json", + ".build-recipe.json", + ".rebuild-check.json", +]; + +pub(crate) fn approved(commit: &str) -> ApprovedRelease { + ApprovedRelease::new( + ReleaseTag::parse(TAG).expect("should accept fictional tag"), + ApprovedCommit::parse(commit).expect("should accept fictional commit"), + ) +} + +fn module(id: &str, body: &str) -> Value { + json!({ + "id": id, + "filename": format!("tsjs-{id}.js"), + "size": body.len(), + "sha256": sha256_hex(body.as_bytes()), + }) +} + +/// Stage 1: companion documents and executable bytes. +pub(crate) struct Graph { + pub(crate) executable: Vec, + pub(crate) inventory: Value, + pub(crate) spdx: Value, + pub(crate) build_evidence: Value, + pub(crate) build_recipe: Value, + pub(crate) rebuild_check: Value, +} + +impl Graph { + pub(crate) fn new() -> Self { + let executable = b"fictional trusted-server-fastly wasm bytes".to_vec(); + let executable_sha256 = sha256_hex(&executable); + let modules = json!([module("core", "core();"), module("prebid", "prebid();")]); + let mut inventory_modules = modules.clone(); + inventory_modules[0]["contributions"] = json!([{"package": "fictional-js"}]); + let inventory = json!({ + "schema_version": 1, + "source_commit": COMMIT, + "modules": inventory_modules, + "tools": [], + }); + Self { + spdx: spdx_document(&executable_sha256), + build_evidence: json!({ + "schema_version": 1, + "source_commit": COMMIT, + "target": "wasm32-wasip1", + "variant": "default", + "artifact_sha256": executable_sha256, + "js_inventory_sha256": sha256_hex(&bytes_of(&inventory)), + "prepared_modules": modules, + "embedded_modules": [modules[0].clone()], + "compatibility_tests": [], + "inputs": [], + }), + build_recipe: json!({"version": 1, "source_commit": COMMIT}), + rebuild_check: json!({"version": 1, "outcome": "matched"}), + executable, + inventory, + } + } + + /// Serializes documents and derives the index from their exact bytes. + pub(crate) fn build(self) -> Built { + let documents = Documents { + inventory: bytes_of(&self.inventory), + spdx: bytes_of(&self.spdx), + build_evidence: bytes_of(&self.build_evidence), + build_recipe: bytes_of(&self.build_recipe), + rebuild_check: bytes_of(&self.rebuild_check), + provenance_bundle: br#"{"fictional":"artifact provenance bundle"}"#.to_vec(), + sbom_bundle: br#"{"fictional":"artifact sbom bundle"}"#.to_vec(), + executable: self.executable, + }; + let index = index_for(&documents); + Built { documents, index } + } +} + +fn spdx_document(executable_sha256: &str) -> Value { + json!({ + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "name": ASSET, + "dataLicense": "CC0-1.0", + "documentNamespace": "https://example.com/spdx/trusted-server-fastly", + "creationInfo": {"creators": ["Tool: fictional"], "created": "2026-01-01T00:00:00Z"}, + "packages": [ + { + "SPDXID": "SPDXRef-Package-root", + "name": ASSET, + "checksums": [{"algorithm": "SHA256", "checksumValue": executable_sha256}], + }, + {"SPDXID": "SPDXRef-Package-dependency", "name": "fictional-crate"}, + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-root", + }, + { + "spdxElementId": "SPDXRef-Package-root", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-dependency", + }, + ], + }) +} + +pub(crate) fn bytes_of(value: &Value) -> Vec { + serde_json::to_vec_pretty(value).expect("should serialize fixture JSON") +} + +/// Exact serialized companion and executable bytes. +pub(crate) struct Documents { + pub(crate) executable: Vec, + pub(crate) inventory: Vec, + pub(crate) spdx: Vec, + pub(crate) build_evidence: Vec, + pub(crate) build_recipe: Vec, + pub(crate) rebuild_check: Vec, + pub(crate) provenance_bundle: Vec, + pub(crate) sbom_bundle: Vec, +} + +fn record(name: &str, bytes: &[u8]) -> Value { + json!({"name": name, "size": bytes.len(), "sha256": sha256_hex(bytes)}) +} + +fn index_for(documents: &Documents) -> Value { + let mut index: Value = + serde_json::from_slice(INDEX_FIXTURE).expect("should parse index fixture"); + index["policy_sha256"] = json!(sha256_hex(EMBEDDED_POLICY)); + index["tsjs_inventory"] = record("tsjs-inventory.json", &documents.inventory); + let inventory_sha256 = sha256_hex(&documents.inventory); + for artifact in index["artifacts"] + .as_array_mut() + .expect("should hold artifacts") + { + artifact["js_inventory_sha256"] = json!(inventory_sha256); + if artifact["name"] != ASSET { + continue; + } + artifact["size"] = json!(documents.executable.len()); + artifact["sha256"] = json!(sha256_hex(&documents.executable)); + let companions = &mut artifact["companions"]; + companions["sbom"] = record(&format!("{ASSET}.spdx.json"), &documents.spdx); + companions["build_evidence"] = record( + &format!("{ASSET}.build-evidence.json"), + &documents.build_evidence, + ); + companions["build_recipe"] = record( + &format!("{ASSET}.build-recipe.json"), + &documents.build_recipe, + ); + companions["rebuild_check"] = record( + &format!("{ASSET}.rebuild-check.json"), + &documents.rebuild_check, + ); + companions["provenance"] = record( + &format!("{ASSET}.provenance.sigstore.json"), + &documents.provenance_bundle, + ); + companions["sbom_attestation"] = record( + &format!("{ASSET}.sbom.sigstore.json"), + &documents.sbom_bundle, + ); + } + index +} + +/// Stage 2: documents plus the release index value. +pub(crate) struct Built { + pub(crate) documents: Documents, + pub(crate) index: Value, +} + +/// gh-shaped verified proofs for every collected bundle. +pub(crate) struct Proofs { + pub(crate) index: Value, + pub(crate) provenance: Value, + pub(crate) spdx: Value, +} + +/// Official publication JSON for the release. +pub(crate) struct Publication { + pub(crate) release: Value, + pub(crate) tag_ref: Value, + pub(crate) statement: Value, + pub(crate) index_bundle: Vec, + pub(crate) checksums: Vec, +} + +impl Built { + /// Re-derives the index after directly altering serialized documents. + pub(crate) fn reindex(&mut self) { + self.index = index_for(&self.documents); + } + + /// Serializes the index and signs proofs/publication over exact bytes. + pub(crate) fn sign(self) -> Signed { + let index_bytes = bytes_of(&self.index); + let documents = &self.documents; + let mut provenance_subjects = vec![(ASSET.to_owned(), sha256_hex(&documents.executable))]; + let companions = [ + &documents.spdx, + &documents.build_evidence, + &documents.build_recipe, + &documents.rebuild_check, + ]; + for (suffix, bytes) in PROVENANCE_SUFFIXES.iter().zip(companions) { + provenance_subjects.push((format!("{ASSET}{suffix}"), sha256_hex(bytes))); + } + let spdx_value: Value = + serde_json::from_slice(&documents.spdx).expect("should parse fixture SPDX"); + let proofs = Proofs { + index: proof( + PROVENANCE, + &[("release-index.json".to_owned(), sha256_hex(&index_bytes))], + &provenance_predicate(RUN), + RUN, + ), + provenance: proof( + PROVENANCE, + &provenance_subjects, + &provenance_predicate(RUN), + RUN, + ), + spdx: proof( + SPDX, + &[(ASSET.to_owned(), sha256_hex(&documents.executable))], + &spdx_value, + RUN, + ), + }; + let publication = publication_for(&self.index, &index_bytes); + Signed { + commit: COMMIT.to_owned(), + documents: self.documents, + index_bytes, + proofs, + publication, + } + } +} + +const PROVENANCE: &str = "https://slsa.dev/provenance/v1"; +const SPDX: &str = "https://spdx.dev/Document/v2.3"; + +pub(crate) fn certificate(run: &str) -> Value { + json!({ + "certificateIssuer": "CN=fictional-intermediate,O=example.com", + "subjectAlternativeName": SAN, + "issuer": "https://token.actions.githubusercontent.com", + "githubWorkflowTrigger": "push", + "githubWorkflowSHA": COMMIT, + "githubWorkflowName": "Release", + "githubWorkflowRepository": "IABTechLab/trusted-server", + "githubWorkflowRef": "refs/tags/v1.2.0", + "buildSignerURI": SAN, + "buildSignerDigest": COMMIT, + "runnerEnvironment": "github-hosted", + "sourceRepositoryURI": REPOSITORY_URI, + "sourceRepositoryDigest": COMMIT, + "sourceRepositoryRef": "refs/tags/v1.2.0", + "sourceRepositoryIdentifier": "950860906", + "sourceRepositoryOwnerURI": "https://github.com/IABTechLab", + "sourceRepositoryOwnerIdentifier": "82903538", + "buildConfigURI": CALLER, + "buildConfigDigest": COMMIT, + "buildTrigger": "push", + "runInvocationURI": run, + "sourceRepositoryVisibilityAtSigning": "public", + }) +} + +pub(crate) fn provenance_predicate(run: &str) -> Value { + json!({ + "buildDefinition": { + "buildType": "https://actions.github.io/buildtypes/workflow/v1", + "externalParameters": { + "workflow": { + "ref": "refs/tags/v1.2.0", + "repository": REPOSITORY_URI, + "path": ".github/workflows/release.yml", + } + }, + "internalParameters": { + "github": { + "event_name": "push", + "repository_id": "950860906", + "repository_owner_id": "82903538", + "runner_environment": "github-hosted", + } + }, + "resolvedDependencies": [{ + "uri": "git+https://github.com/IABTechLab/trusted-server@refs/tags/v1.2.0", + "digest": {"gitCommit": COMMIT}, + }], + }, + "runDetails": { + "builder": {"id": SAN}, + "metadata": {"invocationId": run}, + }, + }) +} + +fn proof( + predicate_type: &str, + subjects: &[(String, String)], + predicate: &Value, + run: &str, +) -> Value { + let subjects: Vec = subjects + .iter() + .map(|(name, sha256)| json!({"name": name, "digest": {"sha256": sha256}})) + .collect(); + json!({ + "attestation": { + "bundle": {"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json"}, + "bundle_url": "", + }, + "verificationResult": { + "mediaType": "application/vnd.dev.sigstore.verificationresult+json;version=0.1", + "signature": {"certificate": certificate(run)}, + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": subjects, + "predicateType": predicate_type, + "predicate": predicate, + }, + "verifiedTimestamps": [], + }, + }) +} + +/// Every release asset (name, size, SHA-256) for a signed index. +fn release_assets(index: &Value, index_bytes: &[u8], extra: &[(&str, &[u8])]) -> Vec { + let mut assets = vec![index["tsjs_inventory"].clone()]; + for artifact in index["artifacts"] + .as_array() + .expect("should hold artifacts") + { + assets.push(json!({ + "name": artifact["name"], + "size": artifact["size"], + "sha256": artifact["sha256"], + })); + for companion in artifact["companions"] + .as_object() + .expect("should hold companions") + .values() + { + assets.push(companion.clone()); + } + } + assets.push(record("release-index.json", index_bytes)); + for (name, bytes) in extra { + assets.push(record(name, bytes)); + } + assets +} + +fn publication_for(index: &Value, index_bytes: &[u8]) -> Publication { + let index_bundle = br#"{"fictional":"index provenance bundle"}"#.to_vec(); + let checksums = b"fictional checksum list\n".to_vec(); + let assets = release_assets( + index, + index_bytes, + &[ + ("release-index.provenance.sigstore.json", &index_bundle), + ("SHA256SUMS", &checksums), + ], + ); + let rest_assets: Vec = assets + .iter() + .map(|asset| { + json!({ + "name": asset["name"], + "size": asset["size"], + "state": "uploaded", + "digest": format!("sha256:{}", asset["sha256"].as_str().unwrap_or_default()), + }) + }) + .collect(); + let mut subjects = vec![ + json!({"uri": format!("pkg:github/IABTechLab/trusted-server@{TAG}"), "digest": {"sha1": COMMIT}}), + ]; + subjects.extend( + assets + .iter() + .map(|asset| json!({"name": asset["name"], "digest": {"sha256": asset["sha256"]}})), + ); + Publication { + release: json!({ + "tag_name": TAG, + "draft": false, + "prerelease": false, + "immutable": true, + "assets": rest_assets, + }), + tag_ref: json!({ + "ref": "refs/tags/v1.2.0", + "object": {"sha": COMMIT, "type": "commit"}, + }), + statement: json!({ + "attestation": {"bundle": {}, "bundle_url": ""}, + "verificationResult": { + "mediaType": "application/vnd.dev.sigstore.verificationresult+json;version=0.1", + "signature": {"certificate": {"subjectAlternativeName": RELEASE_SIGNER}}, + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://in-toto.io/attestation/release/v0.1", + "subject": subjects, + "predicate": {"tag": TAG, "repository": "IABTechLab/trusted-server", "repositoryId": "950860906", "ownerId": "82903538", "purl": format!("pkg:github/IABTechLab/trusted-server@{TAG}")}, + }, + }, + }), + index_bundle, + checksums, + } +} + +/// Stage 3: exact bytes, proofs and publication data. +pub(crate) struct Signed { + pub(crate) commit: String, + pub(crate) documents: Documents, + pub(crate) index_bytes: Vec, + pub(crate) proofs: Proofs, + pub(crate) publication: Publication, +} + +impl Signed { + /// Runs the evidence-graph verifier over this graph. + pub(crate) fn verify(&self) -> Result> { + let policy = embedded_policy().expect("should accept embedded policy"); + let release = approved(&self.commit); + let source_policy = ApprovedSourcePolicy::for_tests( + ApprovedCommit::parse(COMMIT).expect("should accept fictional commit"), + EMBEDDED_POLICY.to_vec(), + ); + let documents = &self.documents; + let executable = AssetMeasurement::of_bytes(&documents.executable); + let provenance_bundle = AssetMeasurement::of_bytes(&documents.provenance_bundle); + let sbom_bundle = AssetMeasurement::of_bytes(&documents.sbom_bundle); + let index_bundle = AssetMeasurement::of_bytes(&self.publication.index_bundle); + let index_proof = VerifiedAttestation::synthetic_for_tests( + self.proofs.index.clone(), + release.clone(), + PredicateKind::Provenance, + AssetMeasurement::of_bytes(&self.index_bytes), + index_bundle.clone(), + ); + let provenance_proof = VerifiedAttestation::synthetic_for_tests( + self.proofs.provenance.clone(), + release.clone(), + PredicateKind::Provenance, + executable.clone(), + provenance_bundle.clone(), + ); + let spdx_proof = VerifiedAttestation::synthetic_for_tests( + self.proofs.spdx.clone(), + release.clone(), + PredicateKind::Spdx, + executable.clone(), + sbom_bundle.clone(), + ); + let artifacts = [ConsumedArtifact { + name: ASSET, + executable: &executable, + spdx: &documents.spdx, + build_evidence: &documents.build_evidence, + build_recipe: &documents.build_recipe, + rebuild_check: &documents.rebuild_check, + provenance_bundle: &provenance_bundle, + sbom_bundle: &sbom_bundle, + provenance_proof: &provenance_proof, + spdx_proof: &spdx_proof, + }]; + verify_build_evidence(&EvidenceInputs { + release: &release, + consumer_policy: &policy, + source_policy: &source_policy, + index_bytes: &self.index_bytes, + index_proof: &index_proof, + index_bundle: &index_bundle, + inventory_bytes: &documents.inventory, + artifacts: &artifacts, + }) + } + + /// Runs evidence then publication verification. + pub(crate) fn publish(&self) -> Result> { + let build = self.verify()?; + let publication = &self.publication; + let release_proof = VerifiedReleaseStatement::synthetic_for_tests( + publication.statement.clone(), + approved(&self.commit), + ); + verify_publication( + build, + &PublicationInputs { + release: &publication.release, + tag_ref: &publication.tag_ref, + release_statement: &release_proof, + index_bundle: &AssetMeasurement::of_bytes(&publication.index_bundle), + checksums: &AssetMeasurement::of_bytes(&publication.checksums), + }, + ) + } +} + +/// Builds and signs an unmodified, fully consistent graph. +pub(crate) fn consistent() -> Signed { + Graph::new().build().sign() +} diff --git a/crates/trusted-server-cli/src/release/tests/verification/mod.rs b/crates/trusted-server-cli/src/release/tests/verification/mod.rs new file mode 100644 index 0000000000..13ed0a1936 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/verification/mod.rs @@ -0,0 +1,465 @@ +//! Evidence-graph verification: a fully synthetic fictional graph passes and +//! each independently altered edge fails closed. +//! +//! These semantic tests do not qualify a cryptographic verifier or action. +//! The gh runner is exercised separately with real OS fixture children. + +use error_stack::Report; +use serde_json::{Value, json}; + +use crate::release::error::ReleaseError; +use crate::release::evidence::ComparisonOutcome; + +use self::fixture::{ + ASSET, Graph, OTHER_COMMIT, OTHER_RUN, PROVENANCE_SUFFIXES, RUN, Signed, consistent, +}; + +pub(super) mod fixture; +mod publication; + +#[test] +fn synthetic_graph_rejects_malformed_or_duplicate_key_recipe() { + for recipe in [b"not JSON".as_slice(), br#"{"version":1,"version":1}"#] { + let mut built = Graph::new().build(); + built.documents.build_recipe = recipe.to_vec(); + built.reindex(); + assert_eq!( + context(built.sign().verify()), + ReleaseError::Document, + "should strictly parse authenticated recipe bytes" + ); + } +} + +#[test] +fn synthetic_graph_rejects_unsupported_informational_versions() { + for field in ["recipe", "comparison"] { + let mut graph = Graph::new(); + if field == "recipe" { + graph.build_recipe["version"] = json!(2); + } else { + graph.rebuild_check["version"] = json!(2); + } + assert!( + graph.build().sign().verify().is_err(), + "should reject unsupported {field} version" + ); + } +} + +const ZERO_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +/// A named in-place edit of one proof section. +type Mutation = (&'static str, fn(&mut Value)); + +fn context(result: Result>) -> ReleaseError { + *result + .expect_err("should reject the altered edge") + .current_context() +} + +fn rejects_evidence(signed: &Signed, why: &str) { + assert_eq!( + context(signed.verify()), + ReleaseError::Evidence, + "should reject: {why}" + ); +} + +fn certificate_mut(proof: &mut Value) -> &mut Value { + &mut proof["verificationResult"]["signature"]["certificate"] +} + +fn statement_mut(proof: &mut Value) -> &mut Value { + &mut proof["verificationResult"]["statement"] +} + +#[test] +fn consistent_graph_verifies() { + let build = consistent() + .verify() + .expect("should verify the consistent fictional graph"); + + assert_eq!(build.run_invocation(), RUN, "should keep the shared run"); + assert_eq!(build.consumed().len(), 1, "should verify one artifact"); + let consumed = &build.consumed()[0]; + assert_eq!(consumed.name(), ASSET, "should name the consumed asset"); + assert_eq!( + consumed.assurance_profile(), + "provenance-sbom-repeatable", + "should carry the index profile" + ); + assert_eq!( + consumed.comparison(), + ComparisonOutcome::Matched, + "should report the rebuild outcome" + ); +} + +#[test] +fn rejects_index_subject_digest() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.index)["subject"][0]["digest"]["sha256"] = json!(ZERO_DIGEST); + + rejects_evidence(&signed, "index provenance subject digest differs"); +} + +#[test] +fn rejects_index_bytes_altered_after_signing() { + let mut signed = consistent(); + signed.index_bytes.push(b'\n'); + + rejects_evidence(&signed, "index bytes no longer match the signed subject"); +} + +#[test] +fn rejects_each_altered_provenance_subject_digest() { + for position in 0..=PROVENANCE_SUFFIXES.len() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.provenance)["subject"][position]["digest"]["sha256"] = + json!(ZERO_DIGEST); + + rejects_evidence(&signed, &format!("provenance subject {position} digest")); + } +} + +#[test] +fn rejects_missing_and_extra_provenance_subjects() { + let mut missing = consistent(); + statement_mut(&mut missing.proofs.provenance)["subject"] + .as_array_mut() + .expect("should hold subjects") + .pop(); + rejects_evidence(&missing, "provenance lacks the rebuild-check subject"); + + let mut extra = consistent(); + statement_mut(&mut extra.proofs.provenance)["subject"] + .as_array_mut() + .expect("should hold subjects") + .push(json!({"name": "unapproved.bin", "digest": {"sha256": ZERO_DIGEST}})); + rejects_evidence(&extra, "provenance carries an unapproved subject"); +} + +#[test] +fn rejects_renamed_signed_subject() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.provenance)["subject"][3]["name"] = + json!("ts-x86_64-unknown-linux-gnu.build-recipe.json"); + + rejects_evidence(&signed, "signed subject names another asset"); +} + +#[test] +fn rejects_spdx_subject() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.spdx)["subject"][0]["digest"]["sha256"] = json!(ZERO_DIGEST); + + rejects_evidence(&signed, "SPDX attestation subject is not the executable"); +} + +#[test] +fn rejects_spdx_root_checksum() { + let mut graph = Graph::new(); + graph.spdx["packages"][0]["checksums"][0]["checksumValue"] = json!(ZERO_DIGEST); + + rejects_evidence( + &graph.build().sign(), + "SPDX root checksum is not the executable digest", + ); +} + +#[test] +fn rejects_ambiguous_spdx_root() { + let mut graph = Graph::new(); + graph.spdx["documentDescribes"] = json!(["SPDXRef-Package-dependency"]); + + rejects_evidence(&graph.build().sign(), "SPDX describes two roots"); +} + +#[test] +fn rejects_spdx_predicate_differing_from_document() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.spdx)["predicate"]["packages"][1]["name"] = + json!("substituted-crate"); + + rejects_evidence( + &signed, + "SPDX predicate differs from the published document", + ); +} + +#[test] +fn spdx_comparison_ignores_key_order_but_not_array_order() { + let mut reordered = consistent(); + let predicate = &mut statement_mut(&mut reordered.proofs.spdx)["predicate"]; + let packages = predicate["packages"].clone(); + predicate + .as_object_mut() + .expect("should hold predicate object") + .remove("packages"); + predicate["packages"] = packages; + reordered + .verify() + .expect("should ignore object key order in the SPDX comparison"); + + let mut swapped = consistent(); + statement_mut(&mut swapped.proofs.spdx)["predicate"]["packages"] + .as_array_mut() + .expect("should hold packages") + .reverse(); + rejects_evidence(&swapped, "SPDX array order is significant"); +} + +#[test] +fn rejects_duplicate_keys_in_published_spdx() { + let mut built = Graph::new().build(); + let text = String::from_utf8(built.documents.spdx.clone()).expect("should be UTF-8"); + built.documents.spdx = text + .replacen("\"name\":", "\"name\": \"shadow\",\n \"name\":", 1) + .into_bytes(); + built.reindex(); + + assert_eq!( + context(built.sign().verify()), + ReleaseError::Document, + "should reject a signed but duplicate-key SPDX document" + ); +} + +#[test] +fn rejects_js_inventory_digest() { + let mut graph = Graph::new(); + graph.build_evidence["js_inventory_sha256"] = json!(ZERO_DIGEST); + + rejects_evidence( + &graph.build().sign(), + "build evidence names another inventory", + ); +} + +#[test] +fn rejects_index_inventory_record() { + let mut built = Graph::new().build(); + built.index["tsjs_inventory"]["sha256"] = json!(ZERO_DIGEST); + for artifact in built.index["artifacts"] + .as_array_mut() + .expect("should hold artifacts") + { + artifact["js_inventory_sha256"] = json!(ZERO_DIGEST); + } + + rejects_evidence( + &built.sign(), + "index inventory digest differs from its bytes", + ); +} + +#[test] +fn rejects_prepared_module_table() { + let mut graph = Graph::new(); + graph.build_evidence["prepared_modules"][1]["size"] = json!(1); + + rejects_evidence( + &graph.build().sign(), + "prepared modules differ from inventory", + ); +} + +#[test] +fn rejects_retained_module_outside_inventory() { + let mut graph = Graph::new(); + graph.build_evidence["embedded_modules"][0]["sha256"] = json!(ZERO_DIGEST); + + rejects_evidence( + &graph.build().sign(), + "retained module is not an inventory module", + ); +} + +#[test] +fn rejects_build_evidence_for_another_executable() { + let mut graph = Graph::new(); + graph.build_evidence["artifact_sha256"] = json!(ZERO_DIGEST); + + rejects_evidence( + &graph.build().sign(), + "build evidence names another executable", + ); +} + +#[test] +fn rejects_unknown_rebuild_outcome() { + let mut graph = Graph::new(); + graph.rebuild_check["outcome"] = json!("assumed"); + + rejects_evidence( + &graph.build().sign(), + "rebuild outcome outside the closed enum", + ); +} + +#[test] +fn rejects_altered_companion_bytes() { + let mut signed = consistent(); + signed.documents.build_recipe.push(b' '); + + rejects_evidence(&signed, "companion bytes differ from the signed index"); +} + +#[test] +fn rejects_policy_version_and_digest() { + let mut digest = Graph::new().build(); + digest.index["policy_sha256"] = json!(ZERO_DIGEST); + assert_eq!( + context(digest.sign().verify()), + ReleaseError::ProducerPolicy, + "should reject a producer policy digest not matching approved-source bytes" + ); + + let mut version = Graph::new().build(); + version.index["policy_version"] = json!(2); + assert_eq!( + context(version.sign().verify()), + ReleaseError::ProducerPolicy, + "should reject a producer policy version not matching approved-source bytes" + ); +} + +#[test] +fn rejects_another_approved_source_commit() { + let mut signed = consistent(); + signed.commit = OTHER_COMMIT.to_owned(); + + rejects_evidence(&signed, "proofs are not for the approved commit"); +} + +#[test] +fn rejects_certificate_source_digest_and_resolved_commit() { + let mut certificate = consistent(); + certificate_mut(&mut certificate.proofs.provenance)["sourceRepositoryDigest"] = + json!(OTHER_COMMIT); + rejects_evidence(&certificate, "certificate source digest differs"); + + let mut resolved = consistent(); + statement_mut(&mut resolved.proofs.index)["predicate"]["buildDefinition"]["resolvedDependencies"] + [0]["digest"]["gitCommit"] = json!(OTHER_COMMIT); + rejects_evidence(&resolved, "resolved source commit differs"); +} + +#[test] +fn rejects_run_uri_mismatch_between_proofs() { + let mut spdx = consistent(); + certificate_mut(&mut spdx.proofs.spdx)["runInvocationURI"] = json!(OTHER_RUN); + rejects_evidence(&spdx, "SPDX proof comes from another run attempt"); + + let mut provenance = consistent(); + certificate_mut(&mut provenance.proofs.provenance)["runInvocationURI"] = json!(OTHER_RUN); + statement_mut(&mut provenance.proofs.provenance)["predicate"]["runDetails"]["metadata"]["invocationId"] = + json!(OTHER_RUN); + rejects_evidence(&provenance, "artifact provenance comes from another run"); +} + +#[test] +fn rejects_invocation_id_differing_from_certificate_run() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.index)["predicate"]["runDetails"]["metadata"]["invocationId"] = + json!(OTHER_RUN); + + rejects_evidence(&signed, "invocationId differs from the certificate run URI"); +} + +#[test] +fn rejects_foreign_run_uri() { + let mut signed = consistent(); + let foreign = "https://github.com/IABTechLab/trusted-server-fork/actions/runs/1/attempts/1"; + for proof in [ + &mut signed.proofs.index, + &mut signed.proofs.provenance, + &mut signed.proofs.spdx, + ] { + certificate_mut(proof)["runInvocationURI"] = json!(foreign); + } + + rejects_evidence(&signed, "run URI is outside the official repository"); +} + +#[test] +fn rejects_caller_build_config_uri() { + let mut certificate = consistent(); + certificate_mut(&mut certificate.proofs.spdx)["buildConfigURI"] = json!( + "https://github.com/IABTechLab/trusted-server/.github/workflows/other.yml@refs/tags/v1.2.0" + ); + rejects_evidence(&certificate, "certificate caller is another workflow"); + + let mut external = consistent(); + statement_mut(&mut external.proofs.provenance)["predicate"]["buildDefinition"]["externalParameters"] + ["workflow"]["path"] = json!(".github/workflows/other.yml"); + rejects_evidence(&external, "provenance caller path differs"); + + let mut inputs = consistent(); + statement_mut(&mut inputs.proofs.provenance)["predicate"]["buildDefinition"]["externalParameters"] + ["inputs"] = json!({"command": "fictional"}); + rejects_evidence(&inputs, "external parameters carry arbitrary inputs"); +} + +#[test] +fn rejects_signer_san_workflow() { + let mut signed = consistent(); + certificate_mut(&mut signed.proofs.index)["subjectAlternativeName"] = json!( + "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.2.0" + ); + + rejects_evidence(&signed, "SAN names the caller instead of release-build.yml"); +} + +#[test] +fn rejects_certificate_identity_fields() { + let fields = [ + ("issuer", json!("https://issuer.example.com")), + ("sourceRepositoryIdentifier", json!("1")), + ("sourceRepositoryOwnerIdentifier", json!("2")), + ("buildTrigger", json!("workflow_dispatch")), + ("runnerEnvironment", json!("self-hosted")), + ("buildConfigDigest", json!(OTHER_COMMIT)), + ]; + for (field, value) in fields { + let mut signed = consistent(); + certificate_mut(&mut signed.proofs.provenance)[field] = value; + + rejects_evidence(&signed, &format!("certificate `{field}` differs")); + } +} + +#[test] +fn rejects_provenance_predicate_structure() { + let mutations: [Mutation; 4] = [ + ("build type", |predicate| { + predicate["buildDefinition"]["buildType"] = json!("https://example.com/build/v1"); + }), + ("runner", |predicate| { + predicate["buildDefinition"]["internalParameters"]["github"]["runner_environment"] = + json!("self-hosted"); + }), + ("builder id", |predicate| { + predicate["runDetails"]["builder"]["id"] = json!("https://example.com/builder"); + }), + ("unexpected structure", |predicate| { + predicate["extra"] = json!(true); + }), + ]; + for (why, mutate) in mutations { + let mut signed = consistent(); + mutate(&mut statement_mut(&mut signed.proofs.provenance)["predicate"]); + + rejects_evidence(&signed, why); + } +} + +#[test] +fn rejects_wrong_predicate_type() { + let mut signed = consistent(); + statement_mut(&mut signed.proofs.spdx)["predicateType"] = + json!("https://slsa.dev/provenance/v1"); + + rejects_evidence(&signed, "SPDX proof carries a provenance predicate type"); +} diff --git a/crates/trusted-server-cli/src/release/tests/verification/publication.rs b/crates/trusted-server-cli/src/release/tests/verification/publication.rs new file mode 100644 index 0000000000..c4641743a4 --- /dev/null +++ b/crates/trusted-server-cli/src/release/tests/verification/publication.rs @@ -0,0 +1,524 @@ +//! Official publication checks, retained asset streaming and the read-only +//! verification report. +//! +//! Publication and signature claims here are synthetic semantic fixtures; +//! they do not qualify the pinned verifier or GitHub signing service. + +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::time::{Duration, Instant}; + +use reqwest::header::{ACCEPT, AUTHORIZATION}; +use serde_json::json; + +use super::fixture::{ASSET, COMMIT, RUN, Signed, TAG, consistent}; +use super::{ZERO_DIGEST, context}; +use crate::release::artifact::{ + AssetRole, RetainedDownload, VerificationReport, VerifiedReleaseAsset, stream_into, +}; +use crate::release::bounds::{self, AttemptDeadline}; +use crate::release::documents::ReleaseTag; +use crate::release::environment::{CallerEnvironment, GithubToken}; +use crate::release::error::ReleaseError; +use crate::release::evidence::ComparisonOutcome; +use crate::release::github::{API_REPOSITORY, fetch_json, release_request, tag_ref_request}; +use crate::release::policy::AcceptedProducerPolicy; +use crate::release::source_policy::plain_http_policy_client_for_tests; + +const MIB: usize = 1024 * 1024; + +#[test] +fn synthetic_publication_rejects_wrong_repository_and_statement_types() { + for (path, replacement) in [ + (vec!["predicate", "repository"], json!("fictional/other")), + (vec!["_type"], json!("https://example.com/Statement/v1")), + (vec!["predicateType"], json!("https://example.com/release")), + ] { + let mut signed = consistent(); + let mut value = &mut signed.publication.statement["verificationResult"]["statement"]; + for key in path { + value = &mut value[key]; + } + *value = replacement; + rejects_publication(&signed, "wrong repository or statement type"); + } +} + +#[test] +fn synthetic_publication_rejects_ambiguous_commit_subjects() { + for replacement in [ + json!({"digest": {"sha1": COMMIT}}), + json!({"uri": "pkg:github/fictional/other@v1.2.0", "digest": {"sha1": COMMIT}}), + json!({"uri": format!("pkg:github/IABTechLab/trusted-server@{TAG}"), "digest": {"sha1": COMMIT, "sha256": ZERO_DIGEST}}), + json!({"uri": format!("pkg:github/IABTechLab/trusted-server@{TAG}"), "name": "", "digest": {"sha1": COMMIT}}), + ] { + let mut signed = consistent(); + signed.publication.statement["verificationResult"]["statement"]["subject"][0] = replacement; + rejects_publication(&signed, "ambiguous commit subject"); + } +} + +#[test] +fn synthetic_report_rejects_asset_from_another_publication() { + let first = consistent(); + let publication = first + .publish() + .expect("should verify synthetic publication"); + let executable = retained(ASSET, AssetRole::Executable, &first.documents.executable); + let mut asset = VerifiedReleaseAsset::from_publication(&publication, executable) + .expect("should bind synthetic executable"); + let mut other = super::fixture::Graph::new(); + other.executable = b"different synthetic executable".to_vec(); + other.spdx["packages"][0]["checksums"][0]["checksumValue"] = + json!(crate::release::documents::sha256_hex(&other.executable)); + other.build_evidence["artifact_sha256"] = + json!(crate::release::documents::sha256_hex(&other.executable)); + let other_publication = other + .build() + .sign() + .publish() + .expect("should verify second synthetic publication"); + assert!( + VerificationReport::new(&other_publication, &mut asset).is_err(), + "should reject cross-publication report" + ); +} + +fn rejects_publication(signed: &Signed, why: &str) { + assert_eq!( + context(signed.publish()), + ReleaseError::Publication, + "should reject: {why}" + ); +} + +fn retained(name: &str, role: AssetRole, bytes: &[u8]) -> crate::release::artifact::RetainedAsset { + let mut download = RetainedDownload::start(name, role).expect("should create retained file"); + download + .push(bytes) + .expect("should accept bytes within the limit"); + download.finish().expect("should finish the download") +} + +#[test] +fn consistent_publication_verifies_and_reports() { + let signed = consistent(); + let publication = signed + .publish() + .expect("should verify the consistent official publication"); + let executable = retained(ASSET, AssetRole::Executable, &signed.documents.executable); + assert_eq!(executable.name(), ASSET, "should keep the retained name"); + let mut asset = VerifiedReleaseAsset::from_publication(&publication, executable) + .expect("should bind the retained executable to verified evidence"); + assert_eq!(asset.name(), ASSET, "should keep the verified asset name"); + assert_eq!( + publication.build().producer_policy(), + &AcceptedProducerPolicy::Current, + "should accept the current producer policy" + ); + + let report = VerificationReport::new(&publication, &mut asset) + .expect("should summarize the verified asset"); + + assert_eq!(report.tag, TAG, "should report the approved tag"); + assert_eq!(report.source_commit, COMMIT, "should report the source"); + assert_eq!(report.asset, ASSET, "should report the asset"); + assert_eq!( + report.asset_length, + signed.documents.executable.len() as u64, + "should report the actual length" + ); + assert_eq!(report.consumer_policy_version, 1, "should report policy v1"); + assert_eq!( + report.consumer_policy_sha256, report.producer_policy_sha256, + "should report the current producer policy" + ); + assert_eq!(report.run_invocation, RUN, "should report the shared run"); + assert_eq!( + report.comparison, + ComparisonOutcome::Matched, + "should report the informational comparison" + ); +} + +#[test] +fn rejects_substituted_retained_executable() { + let signed = consistent(); + let publication = signed.publish().expect("should verify publication"); + let substituted = retained( + ASSET, + AssetRole::Executable, + b"substituted executable bytes", + ); + + let result = VerifiedReleaseAsset::from_publication(&publication, substituted); + + assert_eq!( + context(result), + ReleaseError::Asset, + "should reject retained bytes that differ from verified evidence" + ); +} + +#[test] +fn rejects_unconsumed_or_companion_asset() { + let signed = consistent(); + let publication = signed.publish().expect("should verify publication"); + let other = retained( + "ts-x86_64-unknown-linux-gnu", + AssetRole::Executable, + &signed.documents.executable, + ); + assert_eq!( + context(VerifiedReleaseAsset::from_publication(&publication, other)), + ReleaseError::Asset, + "should reject an asset that verification did not consume" + ); + + let companion = retained(ASSET, AssetRole::Companion, &signed.documents.executable); + assert_eq!( + context(VerifiedReleaseAsset::from_publication( + &publication, + companion + )), + ReleaseError::Asset, + "should reject a non-executable retained role" + ); +} + +#[test] +fn rejects_rest_length() { + let mut signed = consistent(); + signed.publication.release["assets"][0]["size"] = json!(1); + + rejects_publication(&signed, "REST size differs from the signed length"); +} + +#[test] +fn rejects_rest_digest() { + let mut signed = consistent(); + signed.publication.release["assets"][3]["digest"] = json!(format!("sha256:{ZERO_DIGEST}")); + + rejects_publication(&signed, "REST digest differs from the signed digest"); +} + +#[test] +fn rejects_mutable_draft_or_prerelease_release() { + let edits = [ + ("immutable", json!(false)), + ("immutable", json!(null)), + ("draft", json!(true)), + ("prerelease", json!(true)), + ("tag_name", json!("v1.2.1")), + ]; + for (field, value) in edits { + let mut signed = consistent(); + signed.publication.release[field] = value; + + rejects_publication(&signed, &format!("release `{field}` is not acceptable")); + } +} + +#[test] +fn rejects_annotated_or_retargeted_tag() { + let mut annotated = consistent(); + annotated.publication.tag_ref["object"]["type"] = json!("tag"); + rejects_publication(&annotated, "annotated tag object"); + + let mut retargeted = consistent(); + retargeted.publication.tag_ref["object"]["sha"] = + json!("89abcdef0123456789abcdef0123456789abcdef"); + rejects_publication(&retargeted, "tag targets another commit"); +} + +#[test] +fn rejects_extra_missing_and_partial_release_assets() { + let mut extra = consistent(); + extra.publication.release["assets"] + .as_array_mut() + .expect("should hold assets") + .push(json!({"name": "unlisted.bin", "size": 1, "state": "uploaded"})); + rejects_publication(&extra, "release carries an unlisted asset"); + + let mut missing = consistent(); + missing.publication.release["assets"] + .as_array_mut() + .expect("should hold assets") + .pop(); + rejects_publication(&missing, "release lacks SHA256SUMS"); + + let mut partial = consistent(); + partial.publication.release["assets"][1]["state"] = json!("starter"); + rejects_publication(&partial, "release asset is not fully uploaded"); +} + +#[test] +fn rejects_release_statement_edges() { + let mut digest = consistent(); + digest.publication.statement["verificationResult"]["statement"]["subject"][2]["digest"]["sha256"] = + json!(ZERO_DIGEST); + rejects_publication(&digest, "signed release asset digest differs"); + + let mut name = consistent(); + name.publication.statement["verificationResult"]["statement"]["subject"][2]["name"] = + json!("renamed.bin"); + rejects_publication(&name, "signed release asset name differs"); + + let mut tag = consistent(); + tag.publication.statement["verificationResult"]["statement"]["predicate"]["tag"] = + json!("v1.2.1"); + rejects_publication(&tag, "release statement names another tag"); + + let mut commit = consistent(); + commit.publication.statement["verificationResult"]["statement"]["subject"][0]["digest"]["sha1"] = + json!("89abcdef0123456789abcdef0123456789abcdef"); + rejects_publication(&commit, "release statement binds another commit"); + + let mut signer = consistent(); + signer.publication.statement["verificationResult"]["signature"]["certificate"]["subjectAlternativeName"] = + json!("https://releases.example.com"); + rejects_publication(&signer, "release statement has another signer"); +} + +#[test] +fn rejects_altered_index_bundle_or_checksums() { + let mut bundle = consistent(); + bundle.publication.index_bundle.push(b' '); + rejects_publication( + &bundle, + "index bundle bytes differ from REST and signed membership", + ); + + let mut checksums = consistent(); + checksums.publication.checksums.push(b' '); + rejects_publication(&checksums, "checksum list differs from signed membership"); +} + +#[test] +fn oversized_stream_is_aborted_mid_download() { + let mut download = + RetainedDownload::start(ASSET, AssetRole::Spdx).expect("should create retained file"); + let chunk = vec![b'x'; MIB]; + for _ in 0..16 { + download + .push(&chunk) + .expect("should accept bytes up to 16 MiB"); + } + + let result = download.push(b"y"); + + assert_eq!( + context(result), + ReleaseError::Asset, + "should abort the transfer at the first byte past the SPDX limit" + ); +} + +#[test] +fn role_limits_match_r_bounds() { + assert_eq!( + AssetRole::Executable.limit(), + bounds::EXECUTABLE_BYTES, + "512 MiB" + ); + assert_eq!( + AssetRole::Companion.limit(), + bounds::COMPANION_BYTES, + "32 MiB" + ); + assert_eq!(AssetRole::Spdx.limit(), bounds::SPDX_BYTES, "16 MiB"); + assert_eq!(AssetRole::Metadata.limit(), bounds::METADATA_BYTES, "1 MiB"); +} + +/// Serves one chunked response of `total` bytes, ignoring client aborts. +fn serve_chunked(total: usize) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind loopback"); + let address = listener.local_addr().expect("should have address"); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); + let chunk = vec![b'z'; 64 * 1024]; + let mut sent = 0; + while sent < total { + let header = format!("{:x}\r\n", chunk.len()); + let written = stream + .write_all(header.as_bytes()) + .and_then(|()| stream.write_all(&chunk)) + .and_then(|()| stream.write_all(b"\r\n")); + if written.is_err() { + return; + } + sent += chunk.len(); + } + let _ = stream.write_all(b"0\r\n\r\n"); + }); + format!("http://{address}/asset") +} + +#[tokio::test] +async fn streamed_download_aborts_past_role_limit() { + let url = serve_chunked(4 * MIB); + let client = plain_http_policy_client_for_tests().expect("should build client"); + let response = client.get(url).send().await.expect("should connect"); + let download = + RetainedDownload::start("SHA256SUMS", AssetRole::Metadata).expect("should create file"); + let deadline = AttemptDeadline::at(Instant::now() + Duration::from_secs(30)); + + let result = stream_into(response, download, deadline).await; + + assert_eq!( + context(result), + ReleaseError::Asset, + "should abort a chunked body once it passes the 1 MiB metadata limit" + ); +} + +#[tokio::test] +async fn streamed_download_within_limit_is_measured() { + let url = serve_chunked(256 * 1024); + let client = plain_http_policy_client_for_tests().expect("should build client"); + let response = client.get(url).send().await.expect("should connect"); + let download = + RetainedDownload::start("SHA256SUMS", AssetRole::Metadata).expect("should create file"); + let deadline = AttemptDeadline::at(Instant::now() + Duration::from_secs(30)); + + let mut asset = stream_into(response, download, deadline) + .await + .expect("should retain a body within the limit"); + + assert_eq!( + asset.measurement().length(), + 256 * 1024, + "should count bytes" + ); + let bytes = asset + .document_bytes() + .expect("should reread retained bytes"); + assert_eq!(bytes, vec![b'z'; 256 * 1024], "should retain exact bytes"); +} + +#[test] +fn rest_requests_use_fixed_official_paths_and_explicit_token() { + let tag = ReleaseTag::parse(TAG).expect("should accept fictional tag"); + let environment = CallerEnvironment::from_pairs([("GH_TOKEN", "fictional-read-only-token")]); + let token = GithubToken::select(&environment).expect("should select explicit token"); + + let release = release_request(&tag, &token).expect("should build release request"); + let tag_ref = tag_ref_request(&tag, &token).expect("should build ref request"); + + assert_eq!( + release.url().as_str(), + format!("{API_REPOSITORY}/releases/tags/v1.2.0"), + "should target the fixed release-by-tag path" + ); + assert_eq!( + tag_ref.url().as_str(), + format!("{API_REPOSITORY}/git/ref/tags/v1.2.0"), + "should target the fixed tag ref path" + ); + for request in [&release, &tag_ref] { + assert_eq!( + request.url().host_str(), + Some("api.github.com"), + "should use the fixed API host" + ); + assert_eq!( + request + .headers() + .get(ACCEPT) + .map(reqwest::header::HeaderValue::as_bytes), + Some(&b"application/vnd.github+json"[..]), + "should request JSON" + ); + let authorization = request + .headers() + .get(AUTHORIZATION) + .expect("should carry the explicit token"); + assert!( + authorization.is_sensitive(), + "should mark the token sensitive" + ); + } +} + +/// Serves one fixed raw HTTP response. +fn serve_once(response: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind loopback"); + let address = listener.local_addr().expect("should have address"); + std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut request = [0_u8; 4096]; + let _ = stream.read(&mut request); + let _ = stream.write_all(&response); + }); + format!("http://{address}/repos/release") +} + +async fn fetch_loopback(response: Vec) -> Result { + let url = serve_once(response); + let client = plain_http_policy_client_for_tests().expect("should build client"); + let environment = CallerEnvironment::from_pairs([("GH_TOKEN", "fictional-read-only-token")]); + let token = GithubToken::select(&environment).expect("should select explicit token"); + let request = client.get(url).build().expect("should build request"); + let deadline = AttemptDeadline::at(Instant::now() + Duration::from_secs(30)); + fetch_json(&client, request, &token, deadline) + .await + .map_err(|report| *report.current_context()) +} + +fn http(status: &str, headers: &str, body: &[u8]) -> Vec { + let mut response = format!( + "HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(body); + response +} + +#[tokio::test] +async fn fetch_json_parses_bounded_strict_json() { + let value = fetch_loopback(http("200 OK", "", br#"{"immutable":true}"#)) + .await + .expect("should parse a bounded JSON body"); + + assert_eq!( + value, + json!({"immutable": true}), + "should return parsed JSON" + ); +} + +#[tokio::test] +async fn fetch_json_rejects_redirect_oversize_and_duplicates() { + let redirect = http( + "302 Found", + "Location: https://example.com/elsewhere\r\n", + b"", + ); + assert_eq!( + fetch_loopback(redirect).await.expect_err("should reject"), + ReleaseError::Publication, + "should reject a redirect instead of following it" + ); + + let oversize = http("200 OK", "", &vec![b' '; MIB + 1]); + assert_eq!( + fetch_loopback(oversize).await.expect_err("should reject"), + ReleaseError::Publication, + "should reject a body over 1 MiB" + ); + + let duplicate = http("200 OK", "", br#"{"immutable":false,"immutable":true}"#); + assert_eq!( + fetch_loopback(duplicate).await.expect_err("should reject"), + ReleaseError::Document, + "should reject duplicate keys in REST JSON" + ); +} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index d6339ea765..c1e77a1b5e 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -41,6 +41,10 @@ struct Args { #[derive(Debug, Subcommand)] enum Command { + /// Verify official release assets without deployment. + #[cfg(unix)] + #[command(subcommand)] + Release(crate::commands::release::ReleaseCommand), /// Print the currently active deployment version for a target adapter. ActiveVersion(ActiveVersionArgs), /// Browser-backed page and ad-template audits. @@ -138,6 +142,10 @@ pub fn run_from_env() -> Result { fn dispatch(args: Args) -> Result { match args.command { + #[cfg(unix)] + Command::Release(command) => { + crate::commands::release::run(command).map(|()| RunOutcome::Success) + } Command::ActiveVersion(args) => { edgezero_cli::run_active_version(&args).map(|()| RunOutcome::Success) } diff --git a/crates/trusted-server-cli/tests/release_cli.rs b/crates/trusted-server-cli/tests/release_cli.rs new file mode 100644 index 0000000000..af50f2af61 --- /dev/null +++ b/crates/trusted-server-cli/tests/release_cli.rs @@ -0,0 +1,282 @@ +//! Public release verification dispatch, without provider configuration. +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt as _; + +use assert_cmd::Command; +use predicates::str::contains; + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn command() -> Command { + let mut command = Command::new(assert_cmd::cargo::cargo_bin!("ts")); + command.env_clear().env("PATH", "/usr/bin:/bin"); + command +} + +#[test] +fn required_verify_path_reaches_explicit_token_requirement() { + command() + .args([ + "release", + "verify", + "v1.2.0", + "--asset", + "ts-aarch64-apple-darwin", + "--expected-source-commit", + COMMIT, + ]) + .assert() + .failure() + .stderr(contains("explicit")); +} + +#[test] +fn invalid_inputs_fail_before_environment_or_provider_configuration() { + for (tag, asset, commit, diagnostic) in [ + ("latest", "ts-aarch64-apple-darwin", COMMIT, "tag"), + ("v1.2.0", "../../ts", COMMIT, "asset"), + ("v1.2.0", "ts-aarch64-apple-darwin", "main", "commit"), + ] { + let output = command() + .args([ + "release", + "verify", + tag, + "--asset", + asset, + "--expected-source-commit", + commit, + ]) + .output() + .expect("should run verifier"); + assert!(!output.status.success(), "should reject invalid input"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(diagnostic), + "should identify invalid {diagnostic}: {stderr}" + ); + assert!( + !stderr.contains("token"), + "should validate before token lookup: {stderr}" + ); + assert!( + !stderr.contains("configuration"), + "should not load provider configuration" + ); + } +} + +#[test] +fn unsupported_public_surfaces_are_rejected() { + for flag in [ + "--offline", + "--offline-deploy", + "--deploy", + "--cache", + "--output", + "--export", + ] { + command() + .args([ + "release", + "verify", + "v1.2.0", + "--asset", + "ts-aarch64-apple-darwin", + "--expected-source-commit", + COMMIT, + flag, + ]) + .assert() + .failure() + .stderr(contains("unexpected argument")); + } +} + +#[test] +fn invalid_inputs_cannot_invoke_build_provider_or_gh_hooks() { + let directory = tempfile::tempdir().expect("should create isolated caller directory"); + let marker = directory.path().join("hook-invoked"); + let script = format!("#!/bin/sh\necho invoked > '{}'\nexit 9\n", marker.display()); + for name in ["gh", "cargo", "fastly", "edgezero"] { + let path = directory.path().join(name); + std::fs::write(&path, &script).expect("should write forbidden hook"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("should mark hook executable"); + } + for name in ["edgezero.toml", "trusted-server.toml"] { + std::fs::write( + directory.path().join(name), + "invalid configuration sentinel {{{", + ) + .expect("should write unreadable configuration sentinel"); + } + for args in [ + vec![ + "release", + "verify", + "v1.2.0", + "--asset", + "../../ts", + "--expected-source-commit", + COMMIT, + ], + vec![ + "release", + "verify", + "v1.2.0", + "--asset", + "ts-aarch64-apple-darwin", + "--expected-source-commit", + "main", + ], + vec![ + "release", + "verify", + "v1.2.0", + "--asset", + "ts-aarch64-apple-darwin", + "--expected-source-commit", + COMMIT, + "--offline-deploy", + ], + ] { + command() + .current_dir(directory.path()) + .env("PATH", directory.path()) + .env("GH_TOKEN", "fictional-token") + .args(args) + .assert() + .failure(); + assert!( + !marker.exists(), + "should invoke zero provider, build or verification hooks for invalid input" + ); + } +} + +struct OwnedVerifier { + child: std::process::Child, + gh_group: Option, +} + +impl Drop for OwnedVerifier { + fn drop(&mut self) { + if let Some(group) = self.gh_group { + // The PID belongs to the fake gh process spawned by this test. + unsafe { + libc::kill(-group, libc::SIGKILL); + } + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn interrupt_during_first_gh_child_cancels_and_reaps_it() { + let directory = tempfile::tempdir().expect("should create isolated fake gh installation"); + let pid_path = directory.path().join("owned-gh.pid"); + let gh = directory.path().join("gh"); + std::fs::write( + &gh, + format!( + "#!/bin/sh\necho $$ > '{}'\nexec /bin/sleep 30\n", + pid_path.display() + ), + ) + .expect("should write stalled fake gh"); + std::fs::set_permissions(&gh, std::fs::Permissions::from_mode(0o700)) + .expect("should mark fake gh executable"); + let child = std::process::Command::new(assert_cmd::cargo::cargo_bin!("ts")) + .args([ + "release", + "verify", + "v1.2.0", + "--asset", + "ts-aarch64-apple-darwin", + "--expected-source-commit", + COMMIT, + ]) + .env_clear() + .env("PATH", directory.path()) + .env("GH_TOKEN", "fictional-read-only-token") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("should spawn verifier process"); + let mut owned = OwnedVerifier { + child, + gh_group: None, + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !pid_path.exists() && std::time::Instant::now() < deadline { + assert!( + owned + .child + .try_wait() + .expect("should inspect verifier process") + .is_none(), + "should reach initial gh child before exiting" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if !pid_path.exists() { + let _ = owned.child.kill(); + let _ = owned.child.wait(); + let diagnostic = + std::io::read_to_string(owned.child.stderr.take().expect("should own stderr pipe")) + .expect("should collect early diagnostic"); + panic!("should reach owned gh child before deadline: {diagnostic}"); + } + let gh_pid: i32 = std::fs::read_to_string(pid_path) + .expect("should observe test-owned gh PID") + .trim() + .parse() + .expect("should parse owned PID"); + owned.gh_group = Some(gh_pid); + // Signal only this test-owned verifier, after its listener and child exist. + let signalled = unsafe { libc::kill(owned.child.id() as i32, libc::SIGINT) }; + assert_eq!(signalled, 0, "should send interrupt to owned verifier"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let status = loop { + if let Some(status) = owned + .child + .try_wait() + .expect("should inspect interrupted verifier") + { + break status; + } + assert!( + std::time::Instant::now() < deadline, + "should cancel verifier promptly" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + assert!(!status.success(), "should return nonzero on interrupt"); + let stderr = std::io::read_to_string( + owned + .child + .stderr + .take() + .expect("should retain diagnostic pipe"), + ) + .expect("should read bounded diagnostic"); + assert!( + stderr.contains("cancelled"), + "should report controlled cancellation: {stderr}" + ); + // The supervised leader must have been killed and reaped, rather than orphaned. + assert_eq!( + unsafe { libc::kill(gh_pid, 0) }, + -1, + "should reap initial gh process before returning" + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH), + "should leave no gh process alive" + ); + owned.gh_group = None; +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4220851797..91d202e064 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -101,6 +101,10 @@ export default withMermaid( { text: 'EdgeZero Lifecycle', link: '/guide/edgezero' }, { text: 'Configuration', link: '/guide/configuration' }, { text: 'CLI', link: '/guide/cli' }, + { + text: 'Release verification', + link: '/guide/release-verification', + }, { text: 'Dev Proxy', link: '/guide/ts-dev-proxy' }, { text: 'Testing', link: '/guide/testing' }, { text: 'Auction Testing', link: '/guide/auction-testing' }, diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 9466e1f3df..97b03a15c1 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -52,6 +52,7 @@ commands are shown explicitly. | `ts prebid server secrets` | Linux + macOS | Manage values for existing, explicitly declared Secrets Manager secrets | `ts prebid server secrets [OPTIONS] ` | | `ts prebid server secrets set` | Linux + macOS | Write a complete JSON credential payload; does not deploy or rotate partner credentials | `ts prebid server secrets set [OPTIONS] --deployment --region ` | | `ts provision` | Linux + macOS | Provision platform resources through a target adapter | `ts provision [OPTIONS] --adapter ` | +| `ts release verify` | Linux + macOS | Verify one official release asset and its evidence | `ts release verify --asset --expected-source-commit ` | | `ts rollback` | Linux + macOS | Roll a service back to a previously active deployment version | `ts rollback [OPTIONS] --adapter --service-id --version ` | | `ts serve` | Linux + macOS | Serve the project locally through a target adapter | `ts serve --adapter ` | @@ -80,6 +81,22 @@ Verify the install: ts --help ``` +## Release verification + +Use an independently trusted verifier and gh 2.97.0 to check official release +assets before running downloaded binaries. See [Release verification](./release-verification.md) +for trusted bootstrap, required policy joins and offline limits. + +```bash +ts release verify v1.2.0 --asset ts-aarch64-apple-darwin \ + --expected-source-commit 0123456789abcdef0123456789abcdef01234567 +``` + +Replace this fictional commit with an independently approved full source commit. +The command is read-only, uses an explicit read-only `GH_TOKEN` (falling back to +`GITHUB_TOKEN`), and returns a verification report. It has no deployment or public +offline option. + ## Common workflow ```bash diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md new file mode 100644 index 0000000000..14815c734d --- /dev/null +++ b/docs/guide/release-verification.md @@ -0,0 +1,157 @@ +# Release verification + +`ts release verify` checks one official release executable and its complete +attestation graph against an independently approved source commit. It prints a +read-only report. It does not install, export or deploy the executable. + +## Establish the verifier first + +Before executing any downloaded `ts`, obtain a trusted verifier independently. +Use a previously trusted verifier, or inspect and build the verifier from an +independently approved source checkout with its reviewed lockfile and toolchain. +For example, in that approved checkout on Apple Silicon macOS: + +```bash +cargo build --locked --package trusted-server-cli --target aarch64-apple-darwin +TRUSTED_TS="$PWD/target/aarch64-apple-darwin/debug/ts" +``` + +Use the corresponding native host target on Linux. An approved-source build +establishes trust in that verifier's source; it does not establish official binary +provenance for the verifier itself. Do not run the newly downloaded executable to +verify itself, and do not treat a checksum downloaded beside it as an independent +trust anchor. + +Install **gh 2.97.0** through an independently trusted package or verified upstream +distribution, following the [GitHub CLI installation instructions](https://github.com/cli/cli#installation). +Place that executable in a trusted absolute `PATH` entry. The verifier resolves +it once and checks the exact version. Version text is a compatibility check; a +hostile executable can print the same text. The verifier's isolated child +environment does not use existing gh login state or configuration. + +The pinned **gh 2.97.0 / actions/attest 4.2.2** real signed fixtures still require +an external qualification record. Synthetic tests exercise orchestration and +policy rejection; they do not qualify those signatures or the pinned tools. + +## Verify an official asset + +Independently review the release's full, lowercase 40-character source commit. +Provide an explicit read-only GitHub token through `GH_TOKEN`, or `GITHUB_TOKEN` +when `GH_TOKEN` is absent. Prefer a token restricted to the reads needed for the +public repository; never put it in command arguments. The verifier cannot infer a +token's permissions from its text. + +```bash +"$TRUSTED_TS" release verify v1.2.0 \ + --asset ts-aarch64-apple-darwin \ + --expected-source-commit 0123456789abcdef0123456789abcdef01234567 +``` + +The commit above is fictional; replace it with your independently approved +commit. The initial cohort accepts exactly these executable names: + +- `trusted-server-fastly.wasm` +- `ts-x86_64-unknown-linux-gnu` +- `ts-x86_64-apple-darwin` +- `ts-aarch64-apple-darwin` + +All three arguments are validated before token lookup, configuration loading or +network activity. No provider configuration or build hooks are needed. There are +no public offline, cache, export, deploy or output-format options. + +The verifier obtains producer-policy bytes from the fixed official repository +Contents API path at the independently approved commit. It checks the index's +provenance before using index fields, applies its embedded consumer policy, and +checks selected executable bytes, provenance and SBOM bundles, SPDX document, +build evidence, recipe, comparison record and JS inventory. A successful primitive +`gh attestation verify` invocation alone does not establish these document, +certificate, subject, policy and workflow-run joins. + +Online publication checks additionally require the official lightweight tag to +point to the approved commit, an immutable published release, successful typed +`gh release verify` evidence, and exact asset-name, size and digest membership. +The report includes source, asset digest, assurance profile, both policy versions +and digests, build/publication outcomes and the informational rebuild comparison. +A comparison result does not grant deployment authorization. + +Metadata is limited to 1 MiB, each bundle/build companion to 32 MiB, SPDX to +16 MiB, and executables to 512 MiB. Executables stream into private retained files; +they are not buffered through a JSON pipe. Each gh child has a 120-second deadline +and bounded output; the complete attempt has a 15-minute deadline. Invalid JSON, +truncation, failed children, changed bytes and timeouts fail closed with nonzero +exit status. Retained inputs are rehashed before use and discarded afterward. + +Policy and publication requests use fixed official HTTPS API paths without +redirects. Asset URLs are constructed from the fixed official repository, checked +tag and canonical asset name, independently of JSON download URLs. Public asset +requests carry no token and accept at most three HTTPS redirects, only to +`release-assets.githubusercontent.com`, the host +[documented by GitHub for release downloads](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +The client disables inherited proxy settings. + +## Manual offline build checks + +There is no public offline command in this cohort. A private verifier path checks +build evidence using the same parsers, policy and graph joins with local bundles, +independently approved source-policy bytes, a securely transported trusted-root +snapshot and independently approved artifact/root SHA-256 pins. Its result cannot +be used as publication evidence or deployment authorization. + +Prepare trust inputs through a separate trusted channel. For example, online +preparation may obtain roots with the independently trusted gh: + +```bash +gh attestation trusted-root --hostname github.com > trusted_root.jsonl +``` + +Approve and securely transfer the exact root snapshot digest independently of the +evidence transfer. Also transfer the approved source-policy snapshot, approved +source commit, independent executable digest, index and all required companion +bytes. A digest or root snapshot supplied only by an untrusted evidence package +is not an independent approval. + +Under an operating-system boundary that denies networking, pinned gh supports +local verification with `--bundle` and `--custom-trusted-root`. It does not have an +`--offline` flag. Token variables must be absent, and the denial boundary should +be proved using a successful unconfined socket control and a denied confined +socket probe. These primitive commands illustrate the certificate/source checks: + +```bash +RELEASE_TAG=v1.2.0 +REVIEWED_COMMIT=0123456789abcdef0123456789abcdef01234567 +ASSET=trusted-server-fastly.wasm +SIGNER="https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/$RELEASE_TAG" +COMMON=(-R IABTechLab/trusted-server --hostname github.com + --custom-trusted-root trusted_root.jsonl + --cert-identity "$SIGNER" + --cert-oidc-issuer https://token.actions.githubusercontent.com + --source-ref "refs/tags/$RELEASE_TAG" --source-digest "$REVIEWED_COMMIT" + --signer-digest "$REVIEWED_COMMIT" --deny-self-hosted-runners --format json) + +gh attestation verify release-index.json "${COMMON[@]}" \ + --bundle release-index.provenance.sigstore.json \ + --predicate-type https://slsa.dev/provenance/v1 + +gh attestation verify "$ASSET" "${COMMON[@]}" \ + --bundle "$ASSET.provenance.sigstore.json" \ + --predicate-type https://slsa.dev/provenance/v1 + +gh attestation verify "$ASSET" "${COMMON[@]}" \ + --bundle "$ASSET.sbom.sigstore.json" \ + --predicate-type https://spdx.dev/Document/v2.3 +``` + +Replace the fictional commit and first compare both independent digest pins. +These exit statuses are only primitive signature checks. Manually enforce the +same bounds and strict document parsing, exact signed subject sets, certificate +repository IDs and workflow identity, approved source-policy digest/version, +embedded cohort and capability policy, common workflow-run attempt, all index +companion sizes/digests, JS inventory/build-evidence/recipe joins, and equality of +the signed SPDX predicate to the companion document. No unsigned companion or +checksum may supply authority. The design specification in the approved source checkout (`docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md`) +defines the full required joins. + +Offline success establishes approved bytes and build evidence. It cannot +re-establish official immutable release membership, current release state or +fresh revocation. GitHub release verification requires online publication checks; +offline official deployment is unsupported. From 39f59bb2cd3c493c72e4f444afac556d784aabc7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 15:49:20 +0530 Subject: [PATCH 049/153] Gate macOS offline test imports by platform --- crates/trusted-server-cli/src/release/tests/offline.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/trusted-server-cli/src/release/tests/offline.rs b/crates/trusted-server-cli/src/release/tests/offline.rs index c4363658da..27ba24aa5a 100644 --- a/crates/trusted-server-cli/src/release/tests/offline.rs +++ b/crates/trusted-server-cli/src/release/tests/offline.rs @@ -10,9 +10,12 @@ use crate::release::source_policy::ApprovedSourceRoot; use crate::release::tests::online::Case; use crate::release::tests::verification::fixture::{self, ASSET, COMMIT}; use std::fs; +#[cfg(target_os = "macos")] use std::net::{SocketAddr, TcpListener, TcpStream}; use std::os::unix::fs::PermissionsExt as _; +#[cfg(target_os = "macos")] use std::process::Command; +#[cfg(target_os = "macos")] use std::time::Duration; #[test] From 39fbf7cf2f6c7c3621e192d1897920250e5f9dab Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 15:51:15 +0530 Subject: [PATCH 050/153] Add guarded producer boundaries and original material verification --- Cargo.lock | 12 + crates/trusted-server-release/Cargo.toml | 13 + crates/trusted-server-release/src/boundary.rs | 303 +++++++++++ .../src/boundary/command.rs | 149 ++++++ .../src/boundary/daemon.rs | 196 +++++++ .../src/boundary/docker.rs | 484 +++++++++++++++++ .../src/boundary/host_inputs.rs | 312 +++++++++++ .../src/boundary/macos.rs | 252 +++++++++ .../src/boundary/probe.rs | 273 ++++++++++ .../src/boundary/proof.rs | 250 +++++++++ .../src/boundary/roots.rs | 463 ++++++++++++++++ .../src/boundary/seccomp.rs | 112 ++++ .../src/boundary/survivors.rs | 148 ++++++ .../src/boundary/tests.rs | 235 +++++++++ .../src/boundary/tests/container.rs | 479 +++++++++++++++++ .../src/boundary/tests/linux.rs | 337 ++++++++++++ .../src/boundary/tests/macos.rs | 492 +++++++++++++++++ .../src/boundary/tests/modes.rs | 269 ++++++++++ .../src/boundary/tests/unit.rs | 481 +++++++++++++++++ crates/trusted-server-release/src/error.rs | 6 + crates/trusted-server-release/src/inspect.rs | 378 ++++++++++++++ .../trusted-server-release/src/inspect/elf.rs | 404 ++++++++++++++ .../src/inspect/elf/loader.rs | 299 +++++++++++ .../src/inspect/macho.rs | 282 ++++++++++ .../src/inspect/tests.rs | 111 ++++ .../src/inspect/tests/builders.rs | 493 ++++++++++++++++++ .../src/inspect/tests/elf.rs | 309 +++++++++++ .../src/inspect/tests/macho.rs | 206 ++++++++ .../src/inspect/tests/wasm.rs | 127 +++++ .../src/inspect/wasm.rs | 102 ++++ crates/trusted-server-release/src/main.rs | 39 ++ .../trusted-server-release/src/materials.rs | 118 +++++ .../src/materials/archive.rs | 198 +++++++ .../src/materials/locked.rs | 243 +++++++++ .../src/materials/tests.rs | 328 ++++++++++++ crates/trusted-server-release/src/source.rs | 23 + .../trusted-server-release/src/source/git.rs | 254 +++++---- .../src/source/tests.rs | 1 + .../src/source/tests/closure.rs | 71 +++ .../src/source/tests/process.rs | 31 ++ .../trusted-server-release/tests/boundary.rs | 141 +++++ ...6-10-13-attested-release-implementation.md | 4 +- ...26-10-14-release-tooling-rust-migration.md | 2 +- scripts/release/qualification/README.md | 13 +- 44 files changed, 9303 insertions(+), 140 deletions(-) create mode 100644 crates/trusted-server-release/src/boundary.rs create mode 100644 crates/trusted-server-release/src/boundary/command.rs create mode 100644 crates/trusted-server-release/src/boundary/daemon.rs create mode 100644 crates/trusted-server-release/src/boundary/docker.rs create mode 100644 crates/trusted-server-release/src/boundary/host_inputs.rs create mode 100644 crates/trusted-server-release/src/boundary/macos.rs create mode 100644 crates/trusted-server-release/src/boundary/probe.rs create mode 100644 crates/trusted-server-release/src/boundary/proof.rs create mode 100644 crates/trusted-server-release/src/boundary/roots.rs create mode 100644 crates/trusted-server-release/src/boundary/seccomp.rs create mode 100644 crates/trusted-server-release/src/boundary/survivors.rs create mode 100644 crates/trusted-server-release/src/boundary/tests.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/container.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/linux.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/macos.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/modes.rs create mode 100644 crates/trusted-server-release/src/boundary/tests/unit.rs create mode 100644 crates/trusted-server-release/src/inspect.rs create mode 100644 crates/trusted-server-release/src/inspect/elf.rs create mode 100644 crates/trusted-server-release/src/inspect/elf/loader.rs create mode 100644 crates/trusted-server-release/src/inspect/macho.rs create mode 100644 crates/trusted-server-release/src/inspect/tests.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/builders.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/elf.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/macho.rs create mode 100644 crates/trusted-server-release/src/inspect/tests/wasm.rs create mode 100644 crates/trusted-server-release/src/inspect/wasm.rs create mode 100644 crates/trusted-server-release/src/materials.rs create mode 100644 crates/trusted-server-release/src/materials/archive.rs create mode 100644 crates/trusted-server-release/src/materials/locked.rs create mode 100644 crates/trusted-server-release/src/materials/tests.rs create mode 100644 crates/trusted-server-release/src/source/tests/process.rs create mode 100644 crates/trusted-server-release/tests/boundary.rs diff --git a/Cargo.lock b/Cargo.lock index 7ff99b9ef0..7124d6b129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4368,6 +4368,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -6931,18 +6940,21 @@ dependencies = [ name = "trusted-server-release" version = "0.1.0" dependencies = [ + "base64", "clap", "derive_more", "error-stack", "hex", "jsonschema", "libc", + "object", "serde", "serde_json", "sha1-checked", "sha2 0.10.9", "tempfile", "toml", + "wasmparser", ] [[package]] diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index 8be0ee2584..a944871935 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -7,18 +7,31 @@ version.workspace = true rust-version = "1.95" [dependencies] +base64.workspace = true clap.workspace = true derive_more.workspace = true error-stack.workspace = true hex.workspace = true libc.workspace = true jsonschema = { version = "=0.58.6", default-features = false } +object = { version = "=0.37.3", default-features = false, features = [ + "elf", + "macho", + "read_core", + "std", +] } serde.workspace = true serde_json.workspace = true sha1-checked = { version = "=0.10.0", default-features = false } sha2.workspace = true tempfile.workspace = true toml.workspace = true +wasmparser = { version = "=0.247.0", default-features = false, features = [ + "features", + "simd", + "std", + "validate", +] } [lints] workspace = true diff --git a/crates/trusted-server-release/src/boundary.rs b/crates/trusted-server-release/src/boundary.rs new file mode 100644 index 0000000000..e9750eb902 --- /dev/null +++ b/crates/trusted-server-release/src/boundary.rs @@ -0,0 +1,303 @@ +//! Native OS network-denial boundary (`source-owned-os-denial-v1`). +//! +//! A [`Boundary`] runs a [`ConfinedCommand`] under operating-system enforced +//! network denial: on macOS a generated deny-default `sandbox-exec` profile, +//! on Linux a Docker container with `--network none`, an effective seccomp +//! profile denying non-Unix sockets, and `--init` in Docker's default private +//! PID namespace. [`Boundary::run_confined`] requires a [`DenialProof`], +//! which [`Boundary::establish_denial`] constructs only after an unconfined +//! positive socket control connected and the confined probe was denied with +//! a real errno, for this boundary instance, in this process run. +//! +//! Every fingerprint includes a fresh per-instance random nonce, so a proof +//! never transfers to another [`Boundary`], even one with identical roots. +//! +//! # Root checks around every confined execution +//! +//! - Immutable and tool roots: every regular file must have `nlink == 1` and +//! every alias must be relative, inside its root and not dangling. Full file +//! facts (content hash, mode, inode, times) are recorded before and +//! compared after; any change rejects the run. Tool executables must +//! therefore be private single-link copies ([`ProbeProgram::staged`]), not +//! Cargo's hard-linked `target/` outputs. +//! - Writable roots: `nlink == 1` and safe aliases before the run; safe +//! aliases again after it. +//! +//! # Descendants +//! +//! On Linux the container's private PID namespace with `--init` ends every +//! descendant when the run's container is stopped and removed. On macOS: +//! +//! - The profile denies the `setsid` and `setpgid` system calls, so a +//! descendant cannot leave the supervised process group that way, and the +//! process module's group kill reaps it. +//! - `posix_spawn` with `POSIX_SPAWN_SETPGROUP`/`POSIX_SPAWN_SETSID` (Rust's +//! `Command::process_group`) bypasses that syscall filter (observed). As a +//! fail-closed backstop, each run carries a unique nonce in its environment +//! and, after the run, every same-user process whose exec-time environment +//! holds it is killed and the run rejected. +//! - Residual: a descendant that escapes the group **and** execs with that +//! variable scrubbed is not found. It stays inside the same sandbox +//! instance (network-denied, writes only in the writable roots), but may +//! outlive the run. +//! +//! # What this module does not prove +//! +//! - It probes one IPv4 TCP loopback connect; the UDP, IPv6 and +//! documentation-address matrix belongs to Task 8 qualification. +//! - No sandbox profile or container image is qualified: +//! [`QualifiedSandboxProfile`] and [`QualifiedImage`](docker::QualifiedImage) +//! are uninhabited, so +//! production execution stays closed. +//! - The Docker client is trust-on-first-use pinned by digest for the life of +//! a boundary, not authenticated against an independent trust root. + +mod command; +mod daemon; +mod docker; +mod host_inputs; +mod macos; +mod probe; +mod proof; +mod roots; +mod seccomp; +#[cfg(target_os = "macos")] +mod survivors; +#[cfg(test)] +mod tests; + +use std::fs::File; +use std::io::Read as _; +use std::path::Path; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +pub(crate) use command::ConfinedCommand; +pub(crate) use docker::{DockerSettings, ImageSelection}; +pub(crate) use probe::{DENIED_STATUS, ProbeProgram, ProbeReport, connect_probe, parse_loopback}; +pub(crate) use proof::DenialProof; +pub(crate) use roots::BoundaryRoots; + +use crate::error::ReleaseError; +use crate::process::{ProcessLimits, ProcessResult}; +use roots::{RootRole, ScanPolicy, TreeFacts}; + +/// Domain separating the instance fingerprint from platform fingerprints. +const INSTANCE_DOMAIN: &[u8] = b"trusted-server-boundary-instance-v1\n"; + +fn boundary_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Returns `N` bytes from the kernel random source. +fn random_bytes() -> Result<[u8; N], Report> { + let mut bytes = [0_u8; N]; + File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut bytes)) + .map_err(|error| boundary_error(format!("cannot read kernel randomness: {error}")))?; + Ok(bytes) +} + +/// A macOS sandbox profile that passed Task 8 qualification. +/// +/// None has, so this type is uninhabited and production macOS selection +/// stays closed; tests use a `#[cfg(test)]` constructor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedSandboxProfile {} + +/// The platform mechanism enforcing denial. +#[derive(Debug)] +enum Platform { + MacSandbox(macos::MacProfile), + LinuxContainer(Box), +} + +/// A configured OS network-denial boundary. +#[derive(Debug)] +pub(crate) struct Boundary { + roots: BoundaryRoots, + platform: Platform, + fingerprint: [u8; 32], +} + +/// Binds a platform fingerprint to a fresh per-instance nonce. +fn instance_fingerprint(platform: &[u8; 32]) -> Result<[u8; 32], Report> { + let nonce: [u8; 16] = random_bytes()?; + let mut hasher = Sha256::new(); + hasher.update(INSTANCE_DOMAIN); + hasher.update(platform); + hasher.update(nonce); + Ok(hasher.finalize().into()) +} + +/// Before-run facts of the immutable and tool roots. +struct FixedFacts(Vec); + +impl Boundary { + /// Configures the qualified macOS `sandbox-exec` boundary. + /// + /// # Errors + /// + /// Never returns: [`QualifiedSandboxProfile`] is uninhabited until a + /// profile is qualified. + pub(crate) fn mac_sandbox( + _roots: BoundaryRoots, + qualification: QualifiedSandboxProfile, + ) -> Result> { + match qualification {} + } + + /// Configures the unqualified macOS boundary for tests. + /// + /// # Errors + /// + /// As [`Self::mac_sandbox_unqualified`]. + #[cfg(test)] + pub(crate) fn mac_sandbox_for_tests( + roots: BoundaryRoots, + ) -> Result> { + Self::mac_sandbox_unqualified(roots) + } + + /// Generates the profile and binds the instance fingerprint. + fn mac_sandbox_unqualified(roots: BoundaryRoots) -> Result> { + if !cfg!(target_os = "macos") { + return Err(boundary_error("the sandbox-exec boundary is macOS-only")); + } + let profile = macos::MacProfile::generate(&roots)?; + Ok(Self { + fingerprint: instance_fingerprint(&profile.fingerprint())?, + platform: Platform::MacSandbox(profile), + roots, + }) + } + + /// Configures the Linux Docker container boundary. + /// + /// Host-owned seccomp profiles and the empty client configuration are + /// created in a fresh directory under `state_parent`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] on any target other than Linux, or + /// as [`docker::DockerBoundary::new`] does. + pub(crate) fn linux_container( + roots: BoundaryRoots, + settings: DockerSettings, + image: ImageSelection, + state_parent: &Path, + ) -> Result> { + if !cfg!(target_os = "linux") { + return Err(boundary_error( + "the Docker container boundary is Linux-only", + )); + } + let container = docker::DockerBoundary::new(&roots, settings, image, state_parent)?; + Ok(Self { + fingerprint: instance_fingerprint(&container.fingerprint())?, + platform: Platform::LinuxContainer(Box::new(container)), + roots, + }) + } + + /// Runs the unconfined control and the confined probe for this instance + /// and returns the proof only when both behave. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the control or denial fails, + /// or [`ReleaseError::Process`] when a probe cannot be run. + pub(crate) fn establish_denial( + &self, + probe: &ProbeProgram, + limits: ProcessLimits, + ) -> Result> { + proof::establish(self, probe, limits) + } + + /// Runs `command` confined, requiring a proof for this instance. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for a proof from another instance, a + /// rejected or changed root, a surviving escaped descendant, or a command + /// outside the roots, or [`ReleaseError::Process`] when the child cannot + /// be run. + pub(crate) fn run_confined( + &self, + command: &ConfinedCommand, + proof: &DenialProof, + ) -> Result> { + if !proof.matches(&self.fingerprint) { + return Err(boundary_error( + "denial proof belongs to a different boundary instance", + )); + } + self.execute(command) + } + + /// Checks every root, runs `command` confined, then rechecks them. + fn execute(&self, command: &ConfinedCommand) -> Result> { + self.check_placement(command)?; + let before = self.scan_fixed()?; + self.scan_writable(ScanPolicy::SingleLinks)?; + let outcome = match &self.platform { + Platform::MacSandbox(profile) => profile.run(command), + Platform::LinuxContainer(container) => container.run(&self.roots, command), + }; + let after = self.verify_after(&before); + match (outcome, after) { + (Ok(result), Ok(())) => Ok(result), + (Ok(_), Err(changed)) => Err(changed), + (Err(run), Ok(())) => Err(run), + (Err(run), Err(changed)) => { + Err(run.attach(format!("roots also failed their checks: {changed:?}"))) + } + } + } + + fn fixed_roots(&self) -> impl Iterator { + self.roots + .immutable() + .iter() + .chain(self.roots.tools()) + .map(std::path::PathBuf::as_path) + } + + fn scan_fixed(&self) -> Result> { + self.fixed_roots() + .map(|root| TreeFacts::scan(root, ScanPolicy::Immutable)) + .collect::>() + .map(FixedFacts) + } + + fn scan_writable(&self, policy: ScanPolicy) -> Result<(), Report> { + for root in self.roots.writable() { + TreeFacts::scan(root, policy)?; + } + Ok(()) + } + + fn verify_after(&self, before: &FixedFacts) -> Result<(), Report> { + let after = self.scan_fixed()?; + for (before, after) in before.0.iter().zip(&after.0) { + before.ensure_unchanged(after)?; + } + self.scan_writable(ScanPolicy::AliasesOnly) + } + + fn check_placement(&self, command: &ConfinedCommand) -> Result<(), Report> { + if self.roots.locate(&command.program).is_none() { + return Err(boundary_error( + "confined program is outside every boundary root", + )); + } + match self.roots.locate(&command.working_directory) { + Some((RootRole::Immutable | RootRole::Writable, _, _)) => Ok(()), + _ => Err(boundary_error( + "confined working directory is outside the immutable and writable roots", + )), + } + } +} diff --git a/crates/trusted-server-release/src/boundary/command.rs b/crates/trusted-server-release/src/boundary/command.rs new file mode 100644 index 0000000000..6c23bf644e --- /dev/null +++ b/crates/trusted-server-release/src/boundary/command.rs @@ -0,0 +1,149 @@ +//! The command description a [`Boundary`](super::Boundary) runs confined. + +use std::ffi::{OsStr, OsString}; +use std::os::unix::ffi::OsStrExt as _; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, Cancellation, ProcessLimits}; + +/// A child to run inside a [`Boundary`](super::Boundary), described by host +/// paths. +/// +/// The program must lie inside a boundary root and the working directory +/// inside an immutable or writable root; on Linux both are translated to the +/// container namespace. Both must be absolute with only normal components +/// (no `.` or `..`), so the root they are located in is the root they reach. +/// Arguments are passed verbatim. Environment values are validated by the +/// same rules as [`BoundedCommand::environment`]; secret values are not +/// supported because Docker would carry them in argv. +#[derive(Debug)] +pub(crate) struct ConfinedCommand { + pub(super) program: PathBuf, + pub(super) arguments: Vec, + pub(super) working_directory: PathBuf, + pub(super) environment: Vec<(OsString, OsString)>, + pub(super) stdin: Option>, + pub(super) limits: ProcessLimits, + pub(super) cancellation: Option, +} + +fn invalid_path(path: &Path) -> Report { + Report::new(ReleaseError::Boundary).attach(format!( + "confined path {} must be absolute with only normal components", + path.display() + )) +} + +/// Rejects relative paths and `.`/`..` segments. +/// +/// The raw bytes are split because [`Path::components`] silently drops +/// interior `.` segments. +fn require_normal(path: &Path) -> Result<(), Report> { + let dotted = path + .as_os_str() + .as_bytes() + .split(|byte| *byte == b'/') + .any(|segment| segment == b"." || segment == b".."); + if !path.has_root() || dotted { + return Err(invalid_path(path)); + } + Ok(()) +} + +impl ConfinedCommand { + /// Describes `program` running in `working_directory` under `limits`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when either path is relative or has + /// a `.` or `..` component. + pub(crate) fn new( + program: &Path, + working_directory: &Path, + limits: ProcessLimits, + ) -> Result> { + require_normal(program)?; + require_normal(working_directory)?; + Ok(Self { + program: program.to_path_buf(), + arguments: Vec::new(), + working_directory: working_directory.to_path_buf(), + environment: Vec::new(), + stdin: None, + limits, + cancellation: None, + }) + } + + /// Appends fixed arguments. + #[must_use] + pub(crate) fn arguments(mut self, arguments: I) -> Self + where + I: IntoIterator, + { + self.arguments.extend(arguments); + self + } + + /// Adds a non-secret environment variable. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] for any name or value + /// [`BoundedCommand::environment`] rejects, including a duplicate. + pub(crate) fn environment( + mut self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + self.validated(Some((name.as_ref(), value.as_ref())))?; + self.environment + .push((name.as_ref().to_os_string(), value.as_ref().to_os_string())); + Ok(self) + } + + /// Validates the environment plus `extra` through a scratch + /// [`BoundedCommand`]. + pub(super) fn validated( + &self, + extra: Option<(&OsStr, &OsStr)>, + ) -> Result> { + let mut validator = + BoundedCommand::new(&self.program, &self.working_directory, self.limits)?; + for (name, value) in self.environment.iter() { + validator = validator.environment(name, value)?; + } + if let Some((name, value)) = extra { + validator = validator.environment(name, value)?; + } + Ok(validator) + } + + /// Supplies fixed stdin bytes. + #[must_use] + pub(crate) fn stdin_bytes(mut self, bytes: Vec) -> Self { + self.stdin = Some(bytes); + self + } + + /// Observes `cancellation`. + #[must_use] + pub(crate) fn cancellation(mut self, cancellation: Cancellation) -> Self { + self.cancellation = Some(cancellation); + self + } + + /// Applies stdin and cancellation to the wrapping bounded command. + pub(super) fn finish(&self, mut bounded: BoundedCommand) -> BoundedCommand { + if let Some(bytes) = &self.stdin { + bounded = bounded.stdin_bytes(bytes.clone()); + } + if let Some(cancellation) = &self.cancellation { + bounded = bounded.cancellation(cancellation.clone()); + } + bounded + } +} diff --git a/crates/trusted-server-release/src/boundary/daemon.rs b/crates/trusted-server-release/src/boundary/daemon.rs new file mode 100644 index 0000000000..f8f7db7bc5 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/daemon.rs @@ -0,0 +1,196 @@ +//! The pinned Docker client and its daemon-socket checks. +//! +//! Before every Docker host command the client rehashes its pinned inputs +//! (see [`HostInputs`]) and rechecks the daemon socket: the socket itself +//! must be a root-owned socket reached without a final alias, and every +//! ancestor directory, both on the configured path and on its canonical +//! form (for example `/var/run` → `/run`), must be root-owned and not group- +//! or other-writable, so no unprivileged user can substitute the endpoint. +//! +//! The client executable is trust-on-first-use pinned by digest for the life +//! of a boundary; it is not authenticated against an independent root. +//! Production use stays closed through the uninhabited +//! [`QualifiedImage`](super::docker::QualifiedImage). + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _}; +use std::path::Path; + +use error_stack::Report; + +use super::host_inputs::HostInputs; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits, ProcessResult}; + +/// The fixed Docker daemon endpoint. +pub(crate) const DOCKER_HOST: &str = "unix:///var/run/docker.sock"; +/// The daemon socket behind [`DOCKER_HOST`]. +pub(crate) const DOCKER_SOCKET: &str = "/var/run/docker.sock"; +/// Group and other write permission bits. +const SHARED_WRITE: u32 = 0o022; + +fn daemon_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Requires every ancestor of `path` to be root-owned, and every ancestor +/// directory not group- or other-writable. +fn check_ancestors(path: &Path) -> Result<(), Report> { + for ancestor in path.ancestors().skip(1) { + let metadata = fs::symlink_metadata(ancestor).map_err(|error| { + daemon_error(format!("cannot inspect {}: {error}", ancestor.display())) + })?; + let shared = metadata.is_dir() && metadata.mode() & SHARED_WRITE != 0; + if metadata.uid() != 0 || shared { + return Err(daemon_error(format!( + "Docker socket ancestor {} is not root-owned and protected", + ancestor.display() + ))); + } + } + Ok(()) +} + +/// Checks the daemon socket at `path`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the socket is missing, is an +/// alias, is not a root-owned socket, or any ancestor (configured or +/// canonical) is not root-owned or is group- or other-writable. +pub(crate) fn check_daemon_socket(path: &Path) -> Result<(), Report> { + let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { + return Err(daemon_error("Docker socket path has no parent")); + }; + check_ancestors(path)?; + let canonical = fs::canonicalize(parent) + .map_err(|error| daemon_error(format!("cannot resolve Docker socket parent: {error}")))? + .join(name); + check_ancestors(&canonical)?; + let metadata = fs::symlink_metadata(&canonical) + .map_err(|error| daemon_error(format!("Docker daemon socket unavailable: {error}")))?; + if !metadata.file_type().is_socket() || metadata.uid() != 0 { + return Err(daemon_error( + "Docker daemon endpoint is not a root-owned socket (aliases are rejected)", + )); + } + Ok(()) +} + +/// Returns the client-level arguments selecting the empty config and daemon. +#[must_use] +pub(crate) fn host_arguments(config: &Path) -> Vec { + vec![ + OsString::from("--config"), + config.as_os_str().to_os_string(), + OsString::from("--host"), + OsString::from(DOCKER_HOST), + ] +} + +/// A pinned Docker client that rehashes its inputs before every command. +#[derive(Debug)] +pub(crate) struct DockerClient { + inputs: HostInputs, + host_limits: ProcessLimits, +} + +impl DockerClient { + /// Creates pinned host inputs under `state_parent`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] as [`HostInputs::create`] does. + pub(crate) fn new( + client: &Path, + state_parent: &Path, + mounted: &[&Path], + host_limits: ProcessLimits, + ) -> Result> { + Ok(Self { + inputs: HostInputs::create(state_parent, client, mounted)?, + host_limits, + }) + } + + /// Returns the pinned inputs. + #[must_use] + pub(crate) fn inputs(&self) -> &HostInputs { + &self.inputs + } + + /// Builds a client command after rehashing every pinned input and + /// rechecking the daemon socket. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when an input changed or the socket + /// check fails, or [`ReleaseError::Process`] for an invalid command. + pub(crate) fn command( + &self, + arguments: Vec, + limits: ProcessLimits, + ) -> Result> { + self.inputs.verify()?; + self.control_command(arguments, limits) + } + + /// Builds a command after checking its client, config and daemon inputs. + fn control_command( + &self, + arguments: Vec, + limits: ProcessLimits, + ) -> Result> { + self.inputs.verify_control()?; + check_daemon_socket(Path::new(DOCKER_SOCKET))?; + Ok( + BoundedCommand::new(self.inputs.client(), self.inputs.directory(), limits)? + .arguments(arguments), + ) + } + + fn full_arguments(&self, arguments: &[&str]) -> Vec { + let mut full = host_arguments(self.inputs.config()); + full.extend(arguments.iter().map(OsString::from)); + full + } + + /// Runs one host command (`docker --config … --host … `). + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when an input changed or the socket + /// check fails before the command, or [`ReleaseError::Process`] when the + /// child cannot be run. + pub(crate) fn host(&self, arguments: &[&str]) -> Result> { + self.command(self.full_arguments(arguments), self.host_limits)? + .run() + } + + /// Runs one cleanup command, rehashing inputs first when they are still + /// intact. Profile-only failures are recorded while safe cleanup is + /// attempted; a changed client or config prevents all execution. + pub(crate) fn cleanup_host( + &self, + arguments: &[&str], + failures: &mut Vec>, + ) -> Option { + if let Err(integrity) = self.inputs.verify_control() { + failures.push(integrity); + return None; + } + let full = self.full_arguments(arguments); + if let Err(integrity) = self.inputs.verify() { + failures.push(integrity); + } + let command = self.control_command(full, self.host_limits); + match command.and_then(|command| command.run()) { + Ok(result) => Some(result), + Err(error) => { + failures.push(error); + None + } + } + } +} diff --git a/crates/trusted-server-release/src/boundary/docker.rs b/crates/trusted-server-release/src/boundary/docker.rs new file mode 100644 index 0000000000..78231bb51c --- /dev/null +++ b/crates/trusted-server-release/src/boundary/docker.rs @@ -0,0 +1,484 @@ +//! Linux Docker container boundary. +//! +//! Every container runs with `--init` in Docker's default private PID +//! namespace (never the invalid `--pid=private`), `--network none`, the +//! effective seccomp profile (every non-Unix socket denied with `EPERM`), a read-only +//! root filesystem, all capabilities dropped, `no-new-privileges`, private +//! IPC, a non-root user and CPU, memory and process limits that fit the +//! actual host. The client is a fixed absolute executable invoked with an +//! explicit empty `--config` and the fixed Unix daemon socket, a cleared +//! environment (no `DOCKER_HOST`, `DOCKER_CONTEXT` or `DOCKER_CONFIG`), no TTY +//! and `--pull never`. +//! +//! Mounts use `--mount type=bind`, which fails on a missing source instead of +//! creating it the way `-v` does. Immutable roots and tools are mounted +//! read-only and recursively read-only (`bind-recursive=readonly`), writable +//! roots read-write, at fixed `/boundary//` targets. +//! +//! Every run is followed, on success and failure alike, by bounded +//! `docker stop --time `, `docker rm --force` and a listing that must show +//! the named container gone. Each step is attempted even when an earlier one +//! failed, and every failure is reported. If the pinned host inputs fail +//! their integrity check during cleanup, cleanup still proceeds through the +//! pinned client path (a left-running container is the worse outcome) and the +//! integrity failure is returned. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::ConfinedCommand; +use super::daemon::{DOCKER_SOCKET, DockerClient, check_daemon_socket, host_arguments}; +use super::roots::{BoundaryRoots, RootRole, validate_path_text}; +use crate::error::ReleaseError; +use crate::process::{ProcessLimits, ProcessOutcome, ProcessResult}; + +/// Container-namespace prefix for every mount target. +const MOUNT_PREFIX: &str = "/boundary"; +/// Configuration fingerprint domain. +const FINGERPRINT_DOMAIN: &[u8] = b"trusted-server-boundary-docker-v1\n"; + +fn docker_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// A production OCI image that passed Task 8 qualification. +/// +/// No image is qualified yet (`release-tools.json` keeps the GNU builder +/// `image_reference` null), so this type is uninhabited and production +/// selection stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedImage {} + +/// The image a container boundary runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ImageSelection { + /// A qualified production image (currently impossible to construct). + Qualified(QualifiedImage), + /// A test-only local image resolved to its content ID. + #[cfg(test)] + Test { id: String }, +} + +impl ImageSelection { + fn id(&self) -> &str { + match self { + Self::Qualified(image) => match *image {}, + #[cfg(test)] + Self::Test { id } => id, + } + } +} + +/// Validates a local image content ID (`sha256:<64 lowercase hex>`). +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] for anything else. +pub(crate) fn validate_image_id(id: &str) -> Result<(), Report> { + let valid = id.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }); + if !valid { + return Err(docker_error("image is not pinned by a sha256 content ID")); + } + Ok(()) +} + +/// Container resource and cleanup settings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DockerSettings { + /// Absolute fixed Docker client executable. + pub(crate) client: PathBuf, + /// Whole CPUs; never more than the host's available parallelism. + pub(crate) cpus: u32, + /// Memory limit in bytes; never more than host physical memory. + pub(crate) memory_bytes: u64, + /// Maximum container processes. + pub(crate) pids_limit: u32, + /// Size of the private `/tmp` tmpfs in bytes. + pub(crate) tmpfs_bytes: u64, + /// Non-root container user and group IDs. + pub(crate) user: (u32, u32), + /// `docker stop -t` grace in seconds. + pub(crate) stop_timeout_seconds: u32, + /// Limits for every Docker host command other than `run`. + pub(crate) host_limits: ProcessLimits, +} + +/// Returns host physical memory in bytes, when the OS reports it. +fn physical_memory() -> Option { + // SAFETY: `sysconf` has no preconditions and only reads system values. + let (pages, page_size) = unsafe { + ( + libc::sysconf(libc::_SC_PHYS_PAGES), + libc::sysconf(libc::_SC_PAGESIZE), + ) + }; + let pages = u64::try_from(pages).ok()?; + let page_size = u64::try_from(page_size).ok()?; + pages.checked_mul(page_size) +} + +impl DockerSettings { + /// Checks the settings against the actual host capacity. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for zero limits, CPUs above + /// [`std::thread::available_parallelism`], memory above physical memory, + /// a root user, or a stop grace not below the host command deadline. + pub(crate) fn validate(&self) -> Result<(), Report> { + let parallelism = std::thread::available_parallelism() + .map_err(|error| docker_error(format!("cannot read host parallelism: {error}")))?; + let parallelism = u32::try_from(parallelism.get()).unwrap_or(u32::MAX); + if self.cpus == 0 || self.cpus > parallelism { + return Err(docker_error(format!( + "container CPUs {} must be between 1 and host parallelism {parallelism}", + self.cpus + ))); + } + let memory = + physical_memory().ok_or_else(|| docker_error("cannot read host physical memory"))?; + if self.memory_bytes == 0 || self.memory_bytes > memory { + return Err(docker_error(format!( + "container memory {} must be nonzero and at most host memory {memory}", + self.memory_bytes + ))); + } + if self.pids_limit == 0 || self.tmpfs_bytes == 0 || self.user.0 == 0 { + return Err(docker_error( + "pids, tmpfs and non-root user must all be set", + )); + } + let stop = std::time::Duration::from_secs(u64::from(self.stop_timeout_seconds)); + if self.stop_timeout_seconds == 0 || stop >= self.host_limits.wall { + return Err(docker_error( + "stop grace must be nonzero and below the host deadline", + )); + } + Ok(()) + } +} + +/// One bind mount into the container namespace. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Mount { + source: PathBuf, + target: String, + read_only: bool, +} + +fn role_directory(role: RootRole) -> &'static str { + match role { + RootRole::Immutable => "immutable", + RootRole::Tool => "tool", + RootRole::Writable => "writable", + } +} + +/// Returns the container path of `host`, which must lie inside a root. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `host` is outside every root. +pub(crate) fn container_path( + roots: &BoundaryRoots, + host: &Path, +) -> Result> { + let (role, index, relative) = roots.locate(host).ok_or_else(|| { + docker_error(format!("{} is outside every boundary root", host.display())) + })?; + let base = PathBuf::from(format!("{MOUNT_PREFIX}/{}/{index}", role_directory(role))); + Ok(if relative.as_os_str().is_empty() { + base + } else { + base.join(relative) + }) +} + +/// Builds the bind mounts for `roots`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when a source path contains `,` or `=`, +/// which the `--mount` field syntax cannot carry unambiguously. +pub(crate) fn mounts(roots: &BoundaryRoots) -> Result, Report> { + roots + .entries() + .map(|(role, _, source)| { + let text = validate_path_text(source)?; + if text.contains(',') || text.contains('=') { + return Err(docker_error(format!( + "mount source {text:?} contains ',' or '='" + ))); + } + Ok(Mount { + source: source.to_path_buf(), + target: container_path(roots, source)? + .to_string_lossy() + .into_owned(), + read_only: role != RootRole::Writable, + }) + }) + .collect() +} + +/// Everything one `docker run` invocation needs besides settings. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RunPlan<'a> { + pub(crate) config: &'a Path, + pub(crate) seccomp: &'a Path, + pub(crate) name: &'a str, + pub(crate) image: &'a str, + pub(crate) mounts: &'a [Mount], + pub(crate) environment: &'a [(OsString, OsString)], + pub(crate) working_directory: &'a Path, + pub(crate) program: &'a Path, + pub(crate) arguments: &'a [OsString], + pub(crate) interactive: bool, +} + +fn mount_argument(mount: &Mount) -> OsString { + let mut value = OsString::from("type=bind,source="); + value.push(&mount.source); + value.push(",target="); + value.push(&mount.target); + if mount.read_only { + value.push(",readonly,bind-recursive=readonly"); + } + value +} + +/// Fixed isolation flags; deliberately no `--pid` (the default namespace is +/// already private) and no `--tty`. +const ISOLATION_ARGUMENTS: [&str; 13] = [ + "--rm", + "--init", + "--pull", + "never", + "--network", + "none", + "--ipc", + "private", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", +]; + +fn limit_arguments(settings: &DockerSettings) -> [String; 10] { + [ + String::from("--pids-limit"), + settings.pids_limit.to_string(), + String::from("--cpus"), + settings.cpus.to_string(), + String::from("--memory"), + format!("{}b", settings.memory_bytes), + String::from("--user"), + format!("{}:{}", settings.user.0, settings.user.1), + String::from("--tmpfs"), + format!("/tmp:rw,noexec,nosuid,nodev,size={}", settings.tmpfs_bytes), + ] +} + +/// Returns the complete fixed `docker` argument vector for `plan`. +#[must_use] +pub(crate) fn run_arguments(settings: &DockerSettings, plan: &RunPlan<'_>) -> Vec { + let mut arguments = host_arguments(plan.config); + arguments.extend(["run", "--name", plan.name].iter().map(OsString::from)); + arguments.extend(ISOLATION_ARGUMENTS.iter().map(OsString::from)); + let mut seccomp = OsString::from("seccomp="); + seccomp.push(plan.seccomp); + arguments.extend([OsString::from("--security-opt"), seccomp]); + arguments.extend(limit_arguments(settings).into_iter().map(OsString::from)); + if plan.interactive { + arguments.push(OsString::from("--interactive")); + } + for mount in plan.mounts { + arguments.extend([OsString::from("--mount"), mount_argument(mount)]); + } + for (name, value) in plan.environment { + let mut assignment = name.clone(); + assignment.push("="); + assignment.push(value); + arguments.extend([OsString::from("--env"), assignment]); + } + arguments.extend([ + OsString::from("--workdir"), + plan.working_directory.as_os_str().to_os_string(), + OsString::from(plan.image), + plan.program.as_os_str().to_os_string(), + ]); + arguments.extend(plan.arguments.iter().cloned()); + arguments +} + +fn container_name() -> Result> { + Ok(format!( + "ts-release-boundary-{}", + hex::encode(super::random_bytes::<12>()?) + )) +} + +/// Folds `failures` into one report, or `Ok` when there are none. +fn aggregate(mut failures: Vec>) -> Result<(), Report> { + if failures.is_empty() { + return Ok(()); + } + let first = failures.remove(0); + Err(failures.into_iter().fold(first, |report, other| { + report.attach(format!("additional cleanup failure: {other:?}")) + })) +} + +/// A configured container boundary. +#[derive(Debug)] +pub(crate) struct DockerBoundary { + client: DockerClient, + settings: DockerSettings, + image: ImageSelection, + mounts: Vec, +} + +impl DockerBoundary { + /// Validates settings, the daemon socket and mounts, then pins inputs. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for invalid settings, an unusable + /// daemon socket, an unpinned image, unmountable roots or host inputs + /// that overlap a mounted root. + pub(crate) fn new( + roots: &BoundaryRoots, + settings: DockerSettings, + image: ImageSelection, + state_parent: &Path, + ) -> Result> { + settings.validate()?; + validate_image_id(image.id())?; + check_daemon_socket(Path::new(DOCKER_SOCKET))?; + let mounts = mounts(roots)?; + let mounted: Vec<&Path> = mounts.iter().map(|mount| mount.source.as_path()).collect(); + let client = DockerClient::new( + &settings.client, + state_parent, + &mounted, + settings.host_limits, + )?; + Ok(Self { + client, + settings, + image, + mounts, + }) + } + + /// Returns the configuration fingerprint (before the instance nonce). + #[must_use] + pub(crate) fn fingerprint(&self) -> [u8; 32] { + let plan = RunPlan { + config: self.client.inputs().config(), + seccomp: self.client.inputs().effective_profile(), + name: "", + image: self.image.id(), + mounts: &self.mounts, + environment: &[], + working_directory: Path::new("/"), + program: Path::new("/"), + arguments: &[], + interactive: false, + }; + let mut hasher = Sha256::new(); + hasher.update(FINGERPRINT_DOMAIN); + let digests = self.client.inputs().digests(); + let parts = run_arguments(&self.settings, &plan) + .into_iter() + .chain(digests.iter().map(OsString::from)); + for part in parts { + let bytes = part.as_encoded_bytes(); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + hasher.finalize().into() + } + + /// Runs `command` in a fresh container, then always cleans it up. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for changed host inputs, a failed + /// daemon-socket check or failed cleanup, or [`ReleaseError::Process`] + /// when the client cannot be run. + pub(crate) fn run( + &self, + roots: &BoundaryRoots, + command: &ConfinedCommand, + ) -> Result> { + let name = container_name()?; + let working_directory = container_path(roots, &command.working_directory)?; + let program = container_path(roots, &command.program)?; + let plan = RunPlan { + config: self.client.inputs().config(), + seccomp: self.client.inputs().effective_profile(), + name: &name, + image: self.image.id(), + mounts: &self.mounts, + environment: &command.environment, + working_directory: &working_directory, + program: &program, + arguments: &command.arguments, + interactive: command.stdin.is_some(), + }; + let arguments = run_arguments(&self.settings, &plan); + let outcome = self + .client + .command(arguments, command.limits) + .and_then(|bounded| command.finish(bounded).run()); + let cleanup = self.cleanup(&name); + match (outcome, cleanup) { + (Ok(result), Ok(())) => Ok(result), + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(run), Ok(())) => Err(run), + (Err(run), Err(cleanup)) => { + Err(run.attach(format!("cleanup also failed: {cleanup:?}"))) + } + } + } + + /// Attempts bounded stop, remove and a confirming listing independently, + /// and aggregates every failure, including a host-input integrity + /// failure observed during cleanup. + fn cleanup(&self, name: &str) -> Result<(), Report> { + let grace = self.settings.stop_timeout_seconds.to_string(); + let filter = format!("name=^/{name}$"); + let mut failures = Vec::new(); + // A container already removed by `--rm` makes stop and rm fail; only + // the final listing decides whether the container is gone. + let _ = self + .client + .cleanup_host(&["stop", "--time", &grace, name], &mut failures); + let _ = self + .client + .cleanup_host(&["rm", "--force", name], &mut failures); + let listing = self.client.cleanup_host( + &["ps", "--all", "--quiet", "--filter", &filter], + &mut failures, + ); + let gone = listing.as_ref().is_some_and(|listing| { + listing.outcome() == (ProcessOutcome::Exited { code: 0 }) && listing.stdout().is_empty() + }); + if !gone { + failures.push(docker_error(format!( + "container {name} was not confirmed removed" + ))); + } + aggregate(failures) + } +} diff --git a/crates/trusted-server-release/src/boundary/host_inputs.rs b/crates/trusted-server-release/src/boundary/host_inputs.rs new file mode 100644 index 0000000000..1c92f2d409 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/host_inputs.rs @@ -0,0 +1,312 @@ +//! Host-owned Docker client inputs pinned for the life of one boundary. +//! +//! The reviewed seccomp baseline, the derived effective profile and an empty +//! Docker client configuration directory live in a fresh private directory +//! that is disjoint from every mounted root (checked before anything is +//! created). The two profiles, the fixed client executable and the empty +//! configuration are rehashed before every Docker host command, so a change +//! between commands rejects the run instead of silently applying. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::roots::{overlaps, validate_path_text}; +use super::seccomp; +use crate::error::ReleaseError; + +const BASELINE_NAME: &str = "seccomp-baseline.json"; +const EFFECTIVE_NAME: &str = "seccomp-effective.json"; +const CONFIG_NAME: &str = "docker-config"; +const READ_ONLY_FILE: u32 = 0o444; +const READ_ONLY_DIRECTORY: u32 = 0o500; +const HASH_CHUNK: usize = 64 * 1024; + +fn input_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Hashes a regular file without following a final symlink. +fn hash_regular(path: &Path) -> Result> { + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|error| input_error(format!("cannot open {}: {error}", path.display())))?; + let metadata = file + .metadata() + .map_err(|error| input_error(format!("cannot stat {}: {error}", path.display())))?; + if !metadata.is_file() { + return Err(input_error(format!( + "{} is not a regular file", + path.display() + ))); + } + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; HASH_CHUNK]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| input_error(format!("cannot read {}: {error}", path.display())))?; + let Some(chunk) = buffer.get(..read).filter(|chunk| !chunk.is_empty()) else { + return Ok(hex::encode(hasher.finalize())); + }; + hasher.update(chunk); + } +} + +/// A file whose digest must not change. +#[derive(Debug)] +struct PinnedFile { + path: PathBuf, + sha256: String, +} + +impl PinnedFile { + fn verify(&self) -> Result<(), Report> { + if hash_regular(&self.path)? != self.sha256 { + return Err(input_error(format!( + "pinned Docker input {} changed", + self.path.display() + ))); + } + Ok(()) + } +} + +/// Identity of the empty configuration directory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DirectoryIdentity { + device: u64, + inode: u64, + mode: u32, +} + +fn directory_identity(path: &Path) -> Result> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| input_error(format!("cannot inspect {}: {error}", path.display())))?; + if !metadata.is_dir() { + return Err(input_error(format!( + "{} is not a directory", + path.display() + ))); + } + Ok(DirectoryIdentity { + device: metadata.dev(), + inode: metadata.ino(), + mode: metadata.mode(), + }) +} + +fn entry_names(path: &Path) -> Result, Report> { + let list_error = + |error: std::io::Error| input_error(format!("cannot list {}: {error}", path.display())); + let mut names = BTreeSet::new(); + for entry in fs::read_dir(path).map_err(list_error)? { + names.insert(entry.map_err(list_error)?.file_name()); + } + Ok(names) +} + +fn write_read_only(path: &Path, bytes: &[u8]) -> Result> { + let write_error = + |error: std::io::Error| input_error(format!("cannot write {}: {error}", path.display())); + let mut file = File::options() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .map_err(write_error)?; + file.write_all(bytes).map_err(write_error)?; + file.sync_all().map_err(write_error)?; + fs::set_permissions(path, fs::Permissions::from_mode(READ_ONLY_FILE)).map_err(write_error)?; + let pinned = PinnedFile { + path: path.to_path_buf(), + sha256: seccomp::sha256_hex(bytes), + }; + pinned.verify()?; + Ok(pinned) +} + +/// Rejects a state parent that overlaps any mounted root. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `parent` is invalid, missing, or +/// equal to, inside or above any of `mounted`. +pub(crate) fn check_disjoint( + parent: &Path, + mounted: &[&Path], +) -> Result> { + validate_path_text(parent)?; + let canonical = fs::canonicalize(parent) + .map_err(|error| input_error(format!("cannot resolve Docker state parent: {error}")))?; + validate_path_text(&canonical)?; + if let Some(root) = mounted.iter().find(|root| overlaps(&canonical, root)) { + return Err(input_error(format!( + "Docker host inputs at {} overlap mounted root {}", + canonical.display(), + root.display() + ))); + } + Ok(canonical) +} + +/// Pinned host-owned inputs for every Docker client invocation. +#[derive(Debug)] +pub(crate) struct HostInputs { + directory: TempDir, + client: PinnedFile, + baseline: PinnedFile, + effective: PinnedFile, + config: PathBuf, + config_identity: DirectoryIdentity, +} + +impl HostInputs { + /// Scans, then creates the inputs in a fresh directory under `parent`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when `parent` overlaps a mounted + /// root, `client` is not a canonical regular file, the seccomp baseline + /// fails its reviewed digest, or any input cannot be written and pinned. + pub(crate) fn create( + parent: &Path, + client: &Path, + mounted: &[&Path], + ) -> Result> { + let parent = check_disjoint(parent, mounted)?; + validate_path_text(client)?; + let canonical_client = fs::canonicalize(client) + .map_err(|error| input_error(format!("cannot resolve Docker client: {error}")))?; + if canonical_client != client { + return Err(input_error( + "Docker client path must be canonical, not an alias", + )); + } + if mounted.iter().any(|root| overlaps(&canonical_client, root)) { + return Err(input_error( + "Docker client must be outside every mounted root", + )); + } + let client = PinnedFile { + sha256: hash_regular(client)?, + path: canonical_client, + }; + let directory = tempfile::Builder::new() + .prefix("ts-boundary-host-") + .tempdir_in(&parent) + .map_err(|error| input_error(format!("cannot create Docker host inputs: {error}")))?; + if !entry_names(directory.path())?.is_empty() { + return Err(input_error( + "fresh Docker host input directory is not empty", + )); + } + let baseline_bytes = seccomp::baseline()?; + let baseline = write_read_only(&directory.path().join(BASELINE_NAME), baseline_bytes)?; + let effective_bytes = seccomp::effective(baseline_bytes)?; + let effective = write_read_only(&directory.path().join(EFFECTIVE_NAME), &effective_bytes)?; + let config = directory.path().join(CONFIG_NAME); + fs::create_dir(&config) + .and_then(|()| { + fs::set_permissions(&config, fs::Permissions::from_mode(READ_ONLY_DIRECTORY)) + }) + .map_err(|error| input_error(format!("cannot create empty Docker config: {error}")))?; + let config_identity = directory_identity(&config)?; + let inputs = Self { + directory, + client, + baseline, + effective, + config, + config_identity, + }; + inputs.verify()?; + Ok(inputs) + } + + /// Rehashes every input; called before every Docker host command. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the client or either profile + /// changed, the configuration directory was replaced or is not empty, or + /// the input directory gained or lost an entry. + pub(crate) fn verify(&self) -> Result<(), Report> { + self.verify_control()?; + self.baseline.verify()?; + self.effective.verify()?; + Ok(()) + } + + /// Verifies the inputs used by every host operation, including cleanup. + /// + /// Cleanup does not consume seccomp profiles, so it may still stop a + /// container after profile tampering. It must never execute a changed + /// client or consume a changed configuration. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when a control input changed. + pub(crate) fn verify_control(&self) -> Result<(), Report> { + self.client.verify()?; + if directory_identity(&self.config)? != self.config_identity { + return Err(input_error("empty Docker config directory was replaced")); + } + if !entry_names(&self.config)?.is_empty() { + return Err(input_error("Docker config directory is no longer empty")); + } + let expected: BTreeSet = [BASELINE_NAME, EFFECTIVE_NAME, CONFIG_NAME] + .into_iter() + .map(OsString::from) + .collect(); + if entry_names(self.directory.path())? != expected { + return Err(input_error( + "Docker host input directory gained or lost entries", + )); + } + Ok(()) + } + + /// Returns the private host input directory. + #[must_use] + pub(crate) fn directory(&self) -> &Path { + self.directory.path() + } + + /// Returns the pinned client executable. + #[must_use] + pub(crate) fn client(&self) -> &Path { + &self.client.path + } + + /// Returns the empty client configuration directory. + #[must_use] + pub(crate) fn config(&self) -> &Path { + &self.config + } + + /// Returns the effective seccomp profile path. + #[must_use] + pub(crate) fn effective_profile(&self) -> &Path { + &self.effective.path + } + + /// Returns the client, baseline and effective digests, in that order. + #[must_use] + pub(crate) fn digests(&self) -> [&str; 3] { + [ + &self.client.sha256, + &self.baseline.sha256, + &self.effective.sha256, + ] + } +} diff --git a/crates/trusted-server-release/src/boundary/macos.rs b/crates/trusted-server-release/src/boundary/macos.rs new file mode 100644 index 0000000000..f48305308f --- /dev/null +++ b/crates/trusted-server-release/src/boundary/macos.rs @@ -0,0 +1,252 @@ +//! macOS `sandbox-exec` deny-default profile. +//! +//! The profile text is assembled only from compiled-in fragments and +//! generated slot indices; every path reaches the sandbox as a `-D NAME=PATH` +//! define read through `(param "NAME")`, never as profile text. A missing +//! define makes `sandbox-exec` reject the profile, so slots fail closed. +//! +//! The base rules follow the observed working deny-default profile that ran +//! offline `npm ci` and the full Vitest suite under denial: system library +//! reads from explicit `/usr` subdirectories (never `/usr/local`), +//! `same-sandbox` signals only, file writes only under private writable roots +//! and `(deny network*)`, after which only Unix-domain sockets whose paths lie +//! under a writable root are re-allowed. `connect` to an INET address from +//! the confined process and every descendant fails with `EPERM`. +//! +//! Observed on macOS 26.6.2: under `(deny default)` alone the `setsid` and +//! `setpgid` system calls are not filtered; `(allow syscall-unix)` followed by +//! `(deny syscall-unix (syscall-number …))` makes both fail with `EPERM` in +//! the confined process and its descendants. + +use std::ffi::OsString; +use std::path::Path; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::ConfinedCommand; +use super::roots::{BoundaryRoots, RootRole, validate_path_text}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessResult}; + +/// The fixed system `sandbox-exec` executable. +pub(crate) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; +/// Environment variable carrying the per-run survivor-detection nonce. +pub(crate) const RUN_NONCE_VARIABLE: &str = "TS_RELEASE_BOUNDARY_RUN"; +/// Darwin `SYS_setpgid` (``), identical on `arm64` and `x86_64`. +pub(crate) const SYS_SETPGID: u32 = 82; +/// Darwin `SYS_setsid` (``), identical on `arm64` and `x86_64`. +pub(crate) const SYS_SETSID: u32 = 147; + +/// Deny-default base rules; contains no caller-supplied text. +const PROFILE_HEADER: &str = r#"(version 1) +(deny default) +(allow process-fork) +(allow signal (target same-sandbox)) +(allow sysctl-read) +(allow syscall-unix) +(deny syscall-unix (syscall-number 147 82)) +(allow file-read-metadata) +(allow file-read* + (literal "/") + (subpath "/usr/bin") + (subpath "/usr/lib") + (subpath "/usr/libexec") + (subpath "/usr/share") + (subpath "/bin") + (subpath "/System") + (subpath "/Library/Apple") + (subpath "/private/var/db/dyld") + (subpath "/dev") + (literal "/private/var/select/sh")) +(allow process-exec + (subpath "/usr/bin") + (subpath "/usr/libexec") + (subpath "/bin") + (subpath "/System")) +(allow file-read* file-write* (literal "/dev/null")) +(allow file-ioctl (literal "/dev/null")) +"#; +/// Network denial; later SBPL rules take precedence over earlier ones. +const NETWORK_DENIAL: &str = "(deny network*)\n"; +/// Profile identity domain for the configuration fingerprint. +const FINGERPRINT_DOMAIN: &[u8] = b"trusted-server-boundary-macos-sbpl-v2\n"; + +/// A generated profile and the defines that bind its slots. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MacProfile { + text: String, + defines: Vec<(String, String)>, +} + +fn slot_name(role: RootRole, index: usize) -> String { + let prefix = match role { + RootRole::Immutable => "IMMUTABLE", + RootRole::Tool => "TOOL", + RootRole::Writable => "WRITABLE", + }; + format!("{prefix}_{index}") +} + +fn allow_rule(role: RootRole, name: &str) -> String { + let operations = match role { + RootRole::Immutable | RootRole::Tool => "file-read* process-exec", + RootRole::Writable => "file-read* file-write* process-exec", + }; + format!("(allow {operations} (subpath (param \"{name}\")))\n") +} + +fn deny_write_rule(name: &str) -> String { + format!("(deny file-write* (subpath (param \"{name}\")))\n") +} + +fn unix_socket_rule(name: &str) -> String { + format!( + "(allow network-bind network-outbound network-inbound \ + (local unix-socket (subpath (param \"{name}\"))) \ + (remote unix-socket (subpath (param \"{name}\"))))\n" + ) +} + +/// Validates one define; the value must be safe profile-parameter text. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `path` fails +/// [`validate_path_text`]. +pub(crate) fn define(name: &str, path: &Path) -> Result<(String, String), Report> { + let value = validate_path_text(path)?; + Ok((name.to_owned(), value.to_owned())) +} + +/// Returns 16 random bytes as lowercase hex. +fn run_nonce() -> Result> { + Ok(hex::encode(super::random_bytes::<16>()?)) +} + +impl MacProfile { + /// Generates the profile for `roots`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when a root path is not safe define + /// text. + pub(crate) fn generate(roots: &BoundaryRoots) -> Result> { + let mut text = String::from(PROFILE_HEADER); + let mut denials = String::new(); + let mut sockets = String::new(); + let mut defines = Vec::new(); + for (role, index, path) in roots.entries() { + let name = slot_name(role, index); + text.push_str(&allow_rule(role, &name)); + if role == RootRole::Writable { + sockets.push_str(&unix_socket_rule(&name)); + } else { + denials.push_str(&deny_write_rule(&name)); + } + defines.push(define(&name, path)?); + } + text.push_str(&denials); + text.push_str(NETWORK_DENIAL); + text.push_str(&sockets); + Ok(Self { text, defines }) + } + + /// Returns the profile text. + #[must_use] + pub(crate) fn text(&self) -> &str { + &self.text + } + + /// Returns the `-D` defines in slot order. + #[must_use] + pub(crate) fn defines(&self) -> &[(String, String)] { + &self.defines + } + + /// Returns `sandbox-exec` arguments preceding the confined program. + #[must_use] + pub(crate) fn arguments(&self) -> Vec { + let mut arguments = vec![OsString::from("-p"), OsString::from(&self.text)]; + for (name, value) in &self.defines { + arguments.push(OsString::from("-D")); + arguments.push(OsString::from(format!("{name}={value}"))); + } + arguments + } + + /// Returns the profile fingerprint (before the instance nonce). + #[must_use] + pub(crate) fn fingerprint(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(FINGERPRINT_DOMAIN); + for argument in self.arguments() { + let bytes = argument.as_encoded_bytes(); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + hasher.finalize().into() + } + + /// Wraps `command` in `sandbox-exec`, adding the run nonce variable. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] when the bounded command rejects the + /// working directory or an environment variable (including a caller + /// variable named [`RUN_NONCE_VARIABLE`]). + pub(crate) fn wrap( + &self, + command: &ConfinedCommand, + run_nonce: &str, + ) -> Result> { + let mut arguments = self.arguments(); + arguments.push(command.program.clone().into_os_string()); + arguments.extend(command.arguments.iter().cloned()); + let mut bounded = BoundedCommand::new( + Path::new(SANDBOX_EXEC), + &command.working_directory, + command.limits, + )? + .arguments(arguments) + .environment(RUN_NONCE_VARIABLE, run_nonce)?; + for (name, value) in &command.environment { + bounded = bounded.environment(name, value)?; + } + Ok(command.finish(bounded)) + } + + /// Runs `command` confined, then kills and rejects any descendant that + /// escaped the process group while keeping the run nonce. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when an escaped descendant was found + /// (even if the run itself succeeded), or the run's own error. + pub(crate) fn run( + &self, + command: &ConfinedCommand, + ) -> Result> { + let nonce = run_nonce()?; + let outcome = self.wrap(command, &nonce).and_then(|bounded| bounded.run()); + let survivors = reap_survivors(&nonce); + match (outcome, survivors) { + (Ok(result), Ok(())) => Ok(result), + (Err(run), Ok(())) => Err(run), + (Ok(_), Err(survivors)) => Err(survivors), + (Err(run), Err(survivors)) => { + Err(survivors.attach(format!("the run also failed: {run:?}"))) + } + } + } +} + +#[cfg(target_os = "macos")] +fn reap_survivors(nonce: &str) -> Result<(), Report> { + super::survivors::reap(RUN_NONCE_VARIABLE, nonce) +} + +#[cfg(not(target_os = "macos"))] +fn reap_survivors(_nonce: &str) -> Result<(), Report> { + Err(Report::new(ReleaseError::Boundary).attach("sandbox-exec survivor checks are macOS-only")) +} diff --git a/crates/trusted-server-release/src/boundary/probe.rs b/crates/trusted-server-release/src/boundary/probe.rs new file mode 100644 index 0000000000..425ee5d803 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/probe.rs @@ -0,0 +1,273 @@ +//! Loopback TCP socket probe and the listener that observes it. +//! +//! The probe makes exactly one TCP `connect` to a loopback `IP:PORT` and +//! reports either `connected` or `denied errno=` with the operating +//! system's real errno. It never synthesizes an errno: a failure without one +//! is an error, not a report. `socket()` success is not evidence (macOS +//! allows `socket(AF_INET)` under `deny default`), so only `connect` against a +//! listener the unconfined control actually reached counts. + +use std::ffi::OsString; +use std::io::{self, ErrorKind}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use crate::error::ReleaseError; + +/// Exit status of a probe whose connect was refused by the operating system. +pub(crate) const DENIED_STATUS: u8 = 3; +/// `boundary-probe` subcommand arguments preceding the address. +const RELEASE_PROBE_ARGUMENTS: [&str; 2] = ["boundary-probe", "connect"]; + +/// The single-line result of one probe connect. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum ProbeReport { + /// The TCP handshake completed. + #[display("connected")] + Connected, + /// `connect` failed with the operating system's `errno`. + #[display("denied errno={errno}")] + Denied { errno: i32 }, +} + +impl ProbeReport { + /// Parses exactly one report line terminated by a newline. + #[must_use] + pub(crate) fn parse_line(output: &[u8]) -> Option { + let line = std::str::from_utf8(output.strip_suffix(b"\n")?).ok()?; + if line == "connected" { + return Some(Self::Connected); + } + let digits = line.strip_prefix("denied errno=")?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + digits.parse().ok().map(|errno| Self::Denied { errno }) + } +} + +fn boundary_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Parses a literal loopback `IP:PORT` with a nonzero port. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Usage`] for anything other than a literal IPv4 or +/// IPv6 loopback socket address with a nonzero port; host names are never +/// resolved. +pub(crate) fn parse_loopback(address: &str) -> Result> { + let parsed: SocketAddr = address.parse().map_err(|_| { + Report::new(ReleaseError::Usage).attach("probe address is not a literal IP:PORT") + })?; + if !parsed.ip().is_loopback() || parsed.port() == 0 { + return Err(Report::new(ReleaseError::Usage) + .attach("probe address must be loopback with a nonzero port")); + } + Ok(parsed) +} + +/// Attempts one TCP connect and reports the real outcome. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `connect` fails without an +/// operating-system errno; no errno is invented for it. +pub(crate) fn connect_probe(address: SocketAddr) -> Result> { + match TcpStream::connect(address) { + Ok(_) => Ok(ProbeReport::Connected), + Err(error) => match error.raw_os_error() { + Some(errno) => Ok(ProbeReport::Denied { errno }), + None => Err(boundary_error(format!( + "probe connect failed without an errno: {error}" + ))), + }, + } +} + +/// How a probe program frames its report on stdout. +#[derive(Clone, Copy, Debug)] +enum ProbeOutput { + /// Stdout is exactly one report line. + Exact, + /// Stdout carries a harness preamble, then the sentinel, then the line. + #[cfg(test)] + AfterSentinel(&'static [u8]), +} + +/// An executable that speaks the probe protocol: the final argument is the +/// loopback address, stdout is the report, exit `0` means connected and +/// [`DENIED_STATUS`] means denied. +#[derive(Clone, Debug)] +pub(crate) struct ProbeProgram { + program: PathBuf, + prefix: Vec, + environment: Vec<(OsString, OsString)>, + output: ProbeOutput, +} + +impl ProbeProgram { + /// Selects this release binary's documented `boundary-probe connect` + /// subcommand. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the running executable cannot + /// be located or canonicalized. + pub(crate) fn release_binary() -> Result> { + let program = std::env::current_exe() + .and_then(std::fs::canonicalize) + .map_err(|error| boundary_error(format!("cannot locate release binary: {error}")))?; + Ok(Self { + program, + prefix: RELEASE_PROBE_ARGUMENTS.iter().map(OsString::from).collect(), + environment: Vec::new(), + output: ProbeOutput::Exact, + }) + } + + /// Selects a test-binary helper speaking the same protocol after a + /// harness preamble ending in `sentinel`. + #[cfg(test)] + pub(super) fn test_helper( + program: PathBuf, + prefix: Vec, + environment: Vec<(OsString, OsString)>, + sentinel: &'static [u8], + ) -> Self { + Self { + program, + prefix, + environment, + output: ProbeOutput::AfterSentinel(sentinel), + } + } + + /// Copies the executable into `directory` (a tool root) as a private + /// single-link, read-only file and returns the probe pointing at it. + /// + /// Cargo hard-links its build outputs, and tool roots require + /// `nlink == 1`, so a probe is never run from `target/` directly. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the copy cannot be created + /// (including when the destination already exists) or made read-only. + pub(crate) fn staged(&self, directory: &Path) -> Result> { + let name = self + .program + .file_name() + .ok_or_else(|| boundary_error("probe program has no file name"))?; + let directory = std::fs::canonicalize(directory) + .map_err(|error| boundary_error(format!("cannot resolve tool directory: {error}")))?; + let destination = directory.join(name); + if destination.exists() { + return Err(boundary_error("staged probe destination already exists")); + } + std::fs::copy(&self.program, &destination) + .and_then(|_| { + std::fs::set_permissions(&destination, std::fs::Permissions::from_mode(0o555)) + }) + .map_err(|error| boundary_error(format!("cannot stage probe: {error}")))?; + Ok(Self { + program: destination, + ..self.clone() + }) + } + + /// Returns the probe executable path. + pub(super) fn program(&self) -> &Path { + &self.program + } + + /// Returns the fixed environment the probe needs. + pub(super) fn environment(&self) -> &[(OsString, OsString)] { + &self.environment + } + + /// Returns the argument vector targeting `address`. + pub(super) fn arguments(&self, address: SocketAddr) -> Vec { + let mut arguments = self.prefix.clone(); + arguments.push(OsString::from(address.to_string())); + arguments + } + + /// Parses the probe's stdout under its framing. + pub(super) fn parse(&self, stdout: &[u8]) -> Option { + match self.output { + ProbeOutput::Exact => ProbeReport::parse_line(stdout), + #[cfg(test)] + ProbeOutput::AfterSentinel(sentinel) => { + let start = stdout + .windows(sentinel.len()) + .rposition(|window| window == sentinel)?; + ProbeReport::parse_line(stdout.get(start + sentinel.len()..)?) + } + } + } +} + +/// A non-blocking IPv4 loopback listener that counts completed connections. +pub(crate) struct LoopbackListener { + listener: TcpListener, + address: SocketAddr, +} + +impl LoopbackListener { + /// Binds an ephemeral port on `127.0.0.1`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the listener cannot be bound or + /// made non-blocking. + pub(crate) fn bind() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .and_then(|listener| { + listener.set_nonblocking(true)?; + Ok(listener) + }) + .map_err(|error| boundary_error(format!("cannot bind control listener: {error}")))?; + let address = listener + .local_addr() + .map_err(|error| boundary_error(format!("cannot read listener address: {error}")))?; + Ok(Self { listener, address }) + } + + /// Returns the bound loopback address. + #[must_use] + pub(crate) fn address(&self) -> SocketAddr { + self.address + } + + /// Accepts every queued connection and returns how many there were. + /// + /// A probe's handshake completes before it exits, so after its process + /// is reaped any connection it made is already queued. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when `accept` fails with anything + /// other than would-block or interruption. + pub(crate) fn drain_accepts(&self) -> Result> { + let mut accepted = 0; + loop { + match self.listener.accept() { + Ok(_) => accepted += 1, + Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(accepted), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(accept_error(&error)), + } + } + } +} + +fn accept_error(error: &io::Error) -> Report { + boundary_error(format!( + "control listener accept failed (errno {:?}): {error}", + error.raw_os_error() + )) +} diff --git a/crates/trusted-server-release/src/boundary/proof.rs b/crates/trusted-server-release/src/boundary/proof.rs new file mode 100644 index 0000000000..b60d578f1e --- /dev/null +++ b/crates/trusted-server-release/src/boundary/proof.rs @@ -0,0 +1,250 @@ +//! Construct-only-through-success network denial proof. +//! +//! [`DenialProof`] has private fields, no `Default`, `Clone` or serde +//! implementations, and is built only by [`evaluate`] after, in this process +//! run and for one boundary instance fingerprint (configuration plus a fresh +//! per-instance nonce): +//! +//! 1. the probe ran **unconfined**, exited `0`, reported `connected`, and the +//! control listener accepted exactly one connection; then +//! 2. the same probe ran **confined**, exited with +//! [`DENIED_STATUS`], reported a real `EPERM` +//! or `EACCES`, and the listener accepted nothing. +//! +//! A failed or refused unconfined control (no local socket permission, an +//! enclosing sandbox) yields no proof, so confined denial is never inferred +//! from a probe that could not have connected anyway. + +use std::path::Path; + +use error_stack::Report; + +use super::probe::{DENIED_STATUS, LoopbackListener, ProbeProgram, ProbeReport}; +use super::{Boundary, ConfinedCommand}; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits, ProcessOutcome, ProcessResult}; + +/// Errnos accepted as an operating-system network denial. +const DENIAL_ERRNOS: [i32; 2] = [libc::EPERM, libc::EACCES]; + +fn proof_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Evidence that this boundary configuration denies network access. +#[derive(Debug)] +pub(crate) struct DenialProof { + fingerprint: [u8; 32], + denied_errno: i32, +} + +impl DenialProof { + /// Returns whether the proof was established for `fingerprint`. + pub(super) fn matches(&self, fingerprint: &[u8; 32]) -> bool { + &self.fingerprint == fingerprint + } + + /// Returns the real errno the confined probe observed. + #[must_use] + pub(crate) fn denied_errno(&self) -> i32 { + self.denied_errno + } +} + +/// One probe run as the controls observed it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Observation { + /// The probe's exit outcome. + outcome: ProcessOutcome, + /// The parsed report, if stdout carried exactly one. + report: Option, + /// Connections the control listener accepted during this run. + accepts: usize, +} + +/// Constructs a proof only from a successful control and a denied probe. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the control did not connect with +/// exactly one observed accept, or the confined probe was not denied with +/// `EPERM`/`EACCES` and zero accepts. +fn evaluate( + fingerprint: [u8; 32], + control: Observation, + confined: Observation, +) -> Result> { + let control_ok = control.outcome == (ProcessOutcome::Exited { code: 0 }) + && control.report == Some(ProbeReport::Connected) + && control.accepts == 1; + if !control_ok { + return Err(proof_error(format!( + "unconfined socket control failed: {control:?}" + ))); + } + let denied_status = ProcessOutcome::Exited { + code: i32::from(DENIED_STATUS), + }; + let denied_errno = match confined.report { + Some(ProbeReport::Denied { errno }) + if confined.outcome == denied_status + && DENIAL_ERRNOS.contains(&errno) + && confined.accepts == 0 => + { + errno + } + _ => { + return Err(proof_error(format!( + "confined probe was not denied: {confined:?}" + ))); + } + }; + Ok(DenialProof { + fingerprint, + denied_errno, + }) +} + +fn observe( + probe: &ProbeProgram, + listener: &LoopbackListener, + result: &ProcessResult, +) -> Result> { + Ok(Observation { + outcome: result.outcome(), + report: probe.parse(result.stdout()), + accepts: listener.drain_accepts()?, + }) +} + +fn unconfined_command( + probe: &ProbeProgram, + listener: &LoopbackListener, + limits: ProcessLimits, +) -> Result> { + let mut command = BoundedCommand::new(probe.program(), Path::new("/"), limits)? + .arguments(probe.arguments(listener.address())); + for (name, value) in probe.environment() { + command = command.environment(name, value)?; + } + Ok(command) +} + +fn confined_command( + boundary: &Boundary, + probe: &ProbeProgram, + listener: &LoopbackListener, + limits: ProcessLimits, +) -> Result> { + let working_directory = boundary + .roots + .writable() + .first() + .ok_or_else(|| proof_error("boundary has no writable root"))?; + let mut command = ConfinedCommand::new(probe.program(), working_directory, limits)? + .arguments(probe.arguments(listener.address())); + for (name, value) in probe.environment() { + command = command.environment(name, value)?; + } + Ok(command) +} + +/// Runs the unconfined control, then the confined probe, and evaluates both. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when either control fails, the probe is +/// outside the boundary's roots or the listener fails, or +/// [`ReleaseError::Process`] when a probe child cannot be run. +pub(super) fn establish( + boundary: &Boundary, + probe: &ProbeProgram, + limits: ProcessLimits, +) -> Result> { + let listener = LoopbackListener::bind()?; + if listener.drain_accepts()? != 0 { + return Err(proof_error( + "control listener had a connection before the control", + )); + } + let control_result = unconfined_command(probe, &listener, limits)?.run()?; + let control = observe(probe, &listener, &control_result)?; + let confined_result = + boundary.execute(&confined_command(boundary, probe, &listener, limits)?)?; + let confined = observe(probe, &listener, &confined_result)?; + evaluate(boundary.fingerprint, control, confined) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn observation(code: i32, report: Option, accepts: usize) -> Observation { + Observation { + outcome: ProcessOutcome::Exited { code }, + report, + accepts, + } + } + + fn denied(errno: i32, accepts: usize) -> Observation { + observation(3, Some(ProbeReport::Denied { errno }), accepts) + } + + #[test] + fn proof_requires_successful_control_and_denied_probe() { + let fingerprint = [7; 32]; + let control = observation(0, Some(ProbeReport::Connected), 1); + let proof = + evaluate(fingerprint, control, denied(libc::EPERM, 0)).expect("should prove denial"); + assert_eq!( + proof.denied_errno(), + libc::EPERM, + "should keep the real errno" + ); + assert!(proof.matches(&fingerprint), "should bind the instance"); + assert!( + !proof.matches(&[8; 32]), + "should not match another instance" + ); + let eacces = + evaluate(fingerprint, control, denied(libc::EACCES, 0)).expect("should accept EACCES"); + assert_eq!(eacces.denied_errno(), libc::EACCES, "should keep EACCES"); + } + + #[test] + fn proof_rejects_failed_controls() { + let bad_controls = [ + denied(libc::ECONNREFUSED, 0), + observation(0, Some(ProbeReport::Connected), 0), + observation(0, Some(ProbeReport::Connected), 2), + observation(0, None, 1), + observation(1, Some(ProbeReport::Connected), 1), + ]; + for control in bad_controls { + assert!( + evaluate([7; 32], control, denied(libc::EPERM, 0)).is_err(), + "should reject failed control {control:?}" + ); + } + } + + #[test] + fn proof_rejects_undenied_probes() { + let control = observation(0, Some(ProbeReport::Connected), 1); + let bad_confined = [ + observation(0, Some(ProbeReport::Connected), 1), + denied(libc::ECONNREFUSED, 0), + denied(111, 0), + denied(libc::EPERM, 1), + observation(0, Some(ProbeReport::Denied { errno: libc::EPERM }), 0), + observation(3, None, 0), + ]; + for confined in bad_confined { + assert!( + evaluate([7; 32], control, confined).is_err(), + "should reject undenied probe {confined:?}" + ); + } + } +} diff --git a/crates/trusted-server-release/src/boundary/roots.rs b/crates/trusted-server-release/src/boundary/roots.rs new file mode 100644 index 0000000000..72a11750a9 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/roots.rs @@ -0,0 +1,463 @@ +//! Boundary root validation, alias checks and immutable tree facts. +//! +//! Every root is canonicalized, must be UTF-8 without quote, backslash or +//! control characters (so it can travel as an SBPL `-D` define or a Docker +//! mount field without escaping), and must be disjoint from every other root. +//! +//! Immutable roots are scanned before and after each confined execution. +//! Every regular file must have `nlink == 1`: a pre-existing hard link outside +//! the root writes through path-based write denial, and a write followed by +//! content, mode and mtime restoration is invisible to a hash comparison, so +//! the link itself is rejected before anything runs. Symlinks must be +//! relative, resolve inside their root and not dangle, which keeps their +//! meaning identical in the host and container namespaces. + +use std::collections::BTreeMap; +use std::fs::{self, File, Metadata}; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +/// Maximum number of roots per role; SBPL parameter slots are fixed. +pub(crate) const MAX_ROOTS: usize = 8; +/// File read size while hashing immutable files. +const HASH_CHUNK: usize = 64 * 1024; + +fn boundary_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// How a root is exposed inside the boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +pub(crate) enum RootRole { + /// Read-only source or object store, scanned and rehashed. + #[display("immutable")] + Immutable, + /// Read-only authenticated tool file or directory. + #[display("tool")] + Tool, + /// Private writable installation or output root. + #[display("writable")] + Writable, +} + +/// Validated, canonical and pairwise-disjoint boundary roots. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BoundaryRoots { + immutable: Vec, + tools: Vec, + writable: Vec, +} + +/// Rejects path text that cannot travel unescaped as a profile define. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] for a relative or non-UTF-8 path, the +/// filesystem root, or one containing `"`, `\` or any control character +/// (including newline and NUL). +pub(crate) fn validate_path_text(path: &Path) -> Result<&str, Report> { + let text = path + .to_str() + .ok_or_else(|| boundary_error("boundary path is not UTF-8"))?; + if !path.is_absolute() || text == "/" { + return Err(boundary_error(format!( + "boundary path {text:?} must be absolute and not the filesystem root" + ))); + } + if text + .chars() + .any(|c| c == '"' || c == '\\' || c.is_control()) + { + return Err(boundary_error(format!( + "boundary path {text:?} contains a quote, backslash or control character" + ))); + } + Ok(text) +} + +fn canonical_root(path: &Path, role: RootRole) -> Result> { + validate_path_text(path)?; + let canonical = fs::canonicalize(path) + .map_err(|error| boundary_error(format!("cannot resolve {role} root: {error}")))?; + validate_path_text(&canonical)?; + let metadata = fs::symlink_metadata(&canonical) + .map_err(|error| boundary_error(format!("cannot inspect {role} root: {error}")))?; + let accepted = metadata.is_dir() || (role == RootRole::Tool && metadata.is_file()); + if !accepted { + return Err(boundary_error(format!( + "{role} root {} has an unsupported file type", + canonical.display() + ))); + } + Ok(canonical) +} + +fn canonical_roots( + paths: &[PathBuf], + role: RootRole, +) -> Result, Report> { + if paths.len() > MAX_ROOTS { + return Err(boundary_error(format!( + "at most {MAX_ROOTS} {role} roots are supported" + ))); + } + paths + .iter() + .map(|path| canonical_root(path, role)) + .collect() +} + +/// Returns whether either path contains the other. +#[must_use] +pub(crate) fn overlaps(first: &Path, second: &Path) -> bool { + first.starts_with(second) || second.starts_with(first) +} + +impl BoundaryRoots { + /// Canonicalizes and validates the roots. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for invalid path text, a missing + /// root, a non-directory immutable or writable root, more than + /// [`MAX_ROOTS`] roots of one role, no writable root, or any two roots + /// that are equal or nested. + pub(crate) fn new( + immutable: &[PathBuf], + tools: &[PathBuf], + writable: &[PathBuf], + ) -> Result> { + let roots = Self { + immutable: canonical_roots(immutable, RootRole::Immutable)?, + tools: canonical_roots(tools, RootRole::Tool)?, + writable: canonical_roots(writable, RootRole::Writable)?, + }; + if roots.writable.is_empty() { + return Err(boundary_error("a boundary needs a private writable root")); + } + let all: Vec<(RootRole, usize, &Path)> = roots.entries().collect(); + for (index, (_, _, first)) in all.iter().enumerate() { + for (_, _, second) in all.iter().skip(index + 1) { + if overlaps(first, second) { + return Err(boundary_error(format!( + "boundary roots {} and {} are not disjoint", + first.display(), + second.display() + ))); + } + } + } + Ok(roots) + } + + /// Returns every root with its role and per-role index. + pub(crate) fn entries(&self) -> impl Iterator { + fn tag( + role: RootRole, + paths: &[PathBuf], + ) -> impl Iterator { + paths + .iter() + .enumerate() + .map(move |(index, path)| (role, index, path.as_path())) + } + tag(RootRole::Immutable, &self.immutable) + .chain(tag(RootRole::Tool, &self.tools)) + .chain(tag(RootRole::Writable, &self.writable)) + } + + /// Returns the immutable roots. + #[must_use] + pub(crate) fn immutable(&self) -> &[PathBuf] { + &self.immutable + } + + /// Returns the tool roots. + #[must_use] + pub(crate) fn tools(&self) -> &[PathBuf] { + &self.tools + } + + /// Returns the writable roots. + #[must_use] + pub(crate) fn writable(&self) -> &[PathBuf] { + &self.writable + } + + /// Returns the root containing `path` and the path relative to it. + #[must_use] + pub(crate) fn locate<'a>(&self, path: &'a Path) -> Option<(RootRole, usize, &'a Path)> { + self.entries().find_map(|(role, index, root)| { + path.strip_prefix(root) + .ok() + .map(|relative| (role, index, relative)) + }) + } +} + +/// The observed identity of one entry under an immutable root. +#[derive(Clone, Debug, PartialEq, Eq)] +enum EntryKind { + Directory, + File { size: u64, sha256: [u8; 32] }, + Symlink { target: PathBuf }, +} + +/// Metadata compared before and after each confined execution. +#[derive(Clone, Debug, PartialEq, Eq)] +struct EntryFacts { + kind: EntryKind, + mode: u32, + nlink: u64, + device: u64, + inode: u64, + mtime_ns: i128, + ctime_ns: i128, +} + +/// A complete snapshot of one immutable root. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TreeFacts { + root: PathBuf, + entries: BTreeMap, +} + +/// What a tree scan records. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScanPolicy { + /// Hash files, require `nlink == 1` and check aliases. + Immutable, + /// Require `nlink == 1` and check aliases without hashing (writable + /// roots before a run). + SingleLinks, + /// Check aliases and file types only (writable roots after a run). + AliasesOnly, +} + +fn nanos(seconds: i64, nanoseconds: i64) -> i128 { + i128::from(seconds) * 1_000_000_000 + i128::from(nanoseconds) +} + +fn facts(kind: EntryKind, metadata: &Metadata) -> EntryFacts { + EntryFacts { + kind, + mode: metadata.mode(), + nlink: metadata.nlink(), + device: metadata.dev(), + inode: metadata.ino(), + mtime_ns: nanos(metadata.mtime(), metadata.mtime_nsec()), + ctime_ns: nanos(metadata.ctime(), metadata.ctime_nsec()), + } +} + +/// Hashes a regular file opened without following links, rejecting hard +/// links and a file swapped between `lstat` and `open`. +fn hash_file(path: &Path, listed: &Metadata) -> Result<[u8; 32], Report> { + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|error| boundary_error(format!("cannot open {}: {error}", path.display())))?; + let opened = file + .metadata() + .map_err(|error| boundary_error(format!("cannot stat {}: {error}", path.display())))?; + if opened.dev() != listed.dev() || opened.ino() != listed.ino() { + return Err(boundary_error(format!( + "{} changed while it was scanned", + path.display() + ))); + } + if opened.nlink() != 1 { + return Err(boundary_error(format!( + "file {} has {} hard links; a link outside the root would bypass write denial", + path.display(), + opened.nlink() + ))); + } + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; HASH_CHUNK]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| boundary_error(format!("cannot read {}: {error}", path.display())))?; + let Some(chunk) = buffer.get(..read).filter(|chunk| !chunk.is_empty()) else { + return Ok(hasher.finalize().into()); + }; + hasher.update(chunk); + } +} + +/// Lexically resolves a relative link target from the link's directory, +/// returning `None` when it climbs above the root. +fn lexical_target(link_relative: &Path, target: &Path) -> Option { + let mut resolved: Vec> = link_relative.parent()?.components().collect(); + for component in target.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + resolved.pop()?; + } + Component::Normal(_) => resolved.push(component), + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(resolved.iter().collect()) +} + +/// Rejects an alias that is absolute, escapes the root or dangles. +fn check_alias(root: &Path, relative: &Path) -> Result> { + let path = root.join(relative); + let target = fs::read_link(&path) + .map_err(|error| boundary_error(format!("cannot read link {}: {error}", path.display())))?; + if target.is_absolute() { + return Err(boundary_error(format!( + "link {} is absolute and resolves differently inside the container namespace", + path.display() + ))); + } + if lexical_target(relative, &target).is_none() { + return Err(boundary_error(format!( + "link {} escapes its root", + path.display() + ))); + } + let resolved = fs::canonicalize(&path) + .map_err(|error| boundary_error(format!("link {} dangles: {error}", path.display())))?; + if !resolved.starts_with(root) { + return Err(boundary_error(format!( + "link {} resolves outside its root", + path.display() + ))); + } + Ok(target) +} + +fn entry_kind( + root: &Path, + relative: &Path, + metadata: &Metadata, + policy: ScanPolicy, +) -> Result> { + let file_type = metadata.file_type(); + if file_type.is_dir() { + return Ok(EntryKind::Directory); + } + if file_type.is_symlink() { + return Ok(EntryKind::Symlink { + target: check_alias(root, relative)?, + }); + } + if !file_type.is_file() { + return Err(boundary_error(format!( + "{} is a socket, FIFO or device; only files, directories and links are allowed", + entry_path(root, relative).display() + ))); + } + let path = entry_path(root, relative); + let sha256 = match policy { + ScanPolicy::Immutable => hash_file(&path, metadata)?, + ScanPolicy::SingleLinks if metadata.nlink() != 1 => { + return Err(boundary_error(format!( + "file {} has {} hard links; a link outside the root would bypass write denial", + path.display(), + metadata.nlink() + ))); + } + ScanPolicy::SingleLinks | ScanPolicy::AliasesOnly => [0; 32], + }; + Ok(EntryKind::File { + size: metadata.len(), + sha256, + }) +} + +/// Returns `root` itself for the empty relative path (a file root) and the +/// joined path otherwise. +fn entry_path(root: &Path, relative: &Path) -> PathBuf { + if relative.as_os_str().is_empty() { + root.to_path_buf() + } else { + root.join(relative) + } +} + +fn child_names(directory: &Path) -> Result, Report> { + let read_error = |error: std::io::Error| { + boundary_error(format!("cannot list {}: {error}", directory.display())) + }; + let mut names = Vec::new(); + for entry in fs::read_dir(directory).map_err(read_error)? { + names.push(PathBuf::from(entry.map_err(read_error)?.file_name())); + } + Ok(names) +} + +impl TreeFacts { + /// Scans `root` (canonical) under `policy`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for an unreadable entry, a + /// non-regular special file, a hard-linked regular file (immutable and + /// single-link policies), or an absolute, escaping or dangling symlink. + pub(crate) fn scan(root: &Path, policy: ScanPolicy) -> Result> { + let mut entries = BTreeMap::new(); + let mut pending = vec![PathBuf::new()]; + while let Some(relative) = pending.pop() { + let path = entry_path(root, &relative); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + boundary_error(format!("cannot inspect {}: {error}", path.display())) + })?; + let kind = entry_kind(root, &relative, &metadata, policy)?; + if kind == EntryKind::Directory { + pending.extend( + child_names(&path)? + .into_iter() + .map(|name| relative.join(name)), + ); + } + entries.insert(relative, facts(kind, &metadata)); + } + Ok(Self { + root: root.to_path_buf(), + entries, + }) + } + + /// Requires `after` to match this snapshot exactly. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] naming the first added, removed or + /// changed entry. + pub(crate) fn ensure_unchanged(&self, after: &Self) -> Result<(), Report> { + if self.root != after.root { + return Err(boundary_error("tree snapshots describe different roots")); + } + let changed = self + .entries + .iter() + .find(|(path, facts)| after.entries.get(*path) != Some(facts)) + .map(|(path, _)| path) + .or_else(|| { + after + .entries + .keys() + .find(|path| !self.entries.contains_key(*path)) + }); + match changed { + None => Ok(()), + Some(path) => Err(boundary_error(format!( + "immutable root {} changed at {:?}", + self.root.display(), + path + ))), + } + } +} diff --git a/crates/trusted-server-release/src/boundary/seccomp.rs b/crates/trusted-server-release/src/boundary/seccomp.rs new file mode 100644 index 0000000000..c557d8ec14 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/seccomp.rs @@ -0,0 +1,112 @@ +//! Effective Docker seccomp profile derived from the source-owned baseline. +//! +//! The baseline is the reviewed `moby/profiles` `seccomp/v0.2.1` default +//! profile retained in `scripts/release/qualification/`. Its SHA-256 is +//! checked before use. The effective profile preserves the baseline's default +//! `SCMP_ACT_ERRNO`/`EPERM` action, native architectures and every other +//! syscall rule. It empties every `subArchitectures` list, so non-native ABIs +//! (x86 and x32 on `x86_64`, where sockets go through `socketcall`; 32-bit +//! ARM on `aarch64`) are not admitted by the filter at all and fail with the +//! filter's bad-architecture action. It removes every `socket`, `socketpair` +//! and `socketcall` allowance and adds exactly one rule allowing +//! `socket`/`socketpair` with argument zero equal to `AF_UNIX`, so INET, +//! INET6, netlink and packet sockets fail with `EPERM`. `--network none` alone still permits loopback INET +//! sockets, so this layer is what turns them into `EPERM`. + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +/// The reviewed baseline bytes, compiled in from the source tree. +const BASELINE: &[u8] = + include_bytes!("../../../../scripts/release/qualification/seccomp-default.json"); +/// SHA-256 of the reviewed baseline recorded in the qualification README. +pub(crate) const BASELINE_SHA256: &str = + "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74"; +/// Socket-creating syscalls whose baseline allowances are removed. +const SOCKET_SYSCALLS: [&str; 3] = ["socket", "socketpair", "socketcall"]; +/// `AF_UNIX` on every Linux architecture. +const AF_UNIX: u64 = 1; + +fn seccomp_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Returns the hex SHA-256 of `bytes`. +#[must_use] +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Returns the baseline after checking its reviewed digest. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the compiled-in bytes do not match +/// [`BASELINE_SHA256`]. +pub(crate) fn baseline() -> Result<&'static [u8], Report> { + if sha256_hex(BASELINE) != BASELINE_SHA256 { + return Err(seccomp_error( + "seccomp baseline digest does not match review", + )); + } + Ok(BASELINE) +} + +fn strip_socket_names(rule: &mut Value) -> Result> { + let names = rule + .get_mut("names") + .and_then(Value::as_array_mut) + .ok_or_else(|| seccomp_error("seccomp rule has no names array"))?; + names.retain(|name| { + name.as_str() + .is_none_or(|name| !SOCKET_SYSCALLS.contains(&name)) + }); + Ok(!names.is_empty()) +} + +/// Derives the effective profile from `baseline`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the baseline is not JSON, does not +/// default to `SCMP_ACT_ERRNO` with errno `1`, or has a malformed rule. +pub(crate) fn effective(baseline: &[u8]) -> Result, Report> { + let mut profile: Value = serde_json::from_slice(baseline) + .map_err(|error| seccomp_error(format!("seccomp baseline is not JSON: {error}")))?; + let default_denies = profile.get("defaultAction") == Some(&json!("SCMP_ACT_ERRNO")) + && profile.get("defaultErrnoRet") == Some(&json!(1)); + if !default_denies { + return Err(seccomp_error("seccomp baseline does not default to EPERM")); + } + let architectures = profile + .get_mut("archMap") + .and_then(Value::as_array_mut) + .ok_or_else(|| seccomp_error("seccomp baseline has no archMap"))?; + for architecture in architectures { + let entry = architecture + .as_object_mut() + .ok_or_else(|| seccomp_error("seccomp archMap entry is not an object"))?; + entry.insert(String::from("subArchitectures"), json!([])); + } + let rules = profile + .get_mut("syscalls") + .and_then(Value::as_array_mut) + .ok_or_else(|| seccomp_error("seccomp baseline has no syscalls array"))?; + let mut retained = Vec::with_capacity(rules.len() + 1); + for mut rule in rules.drain(..) { + if strip_socket_names(&mut rule)? { + retained.push(rule); + } + } + retained.push(json!({ + "names": ["socket", "socketpair"], + "action": "SCMP_ACT_ALLOW", + "args": [{"index": 0, "value": AF_UNIX, "op": "SCMP_CMP_EQ"}], + })); + *rules = retained; + serde_json::to_vec_pretty(&profile) + .map_err(|error| seccomp_error(format!("cannot serialize seccomp profile: {error}"))) +} diff --git a/crates/trusted-server-release/src/boundary/survivors.rs b/crates/trusted-server-release/src/boundary/survivors.rs new file mode 100644 index 0000000000..27ea37fd1f --- /dev/null +++ b/crates/trusted-server-release/src/boundary/survivors.rs @@ -0,0 +1,148 @@ +//! Fail-closed detection of confined descendants that outlived a macOS run. +//! +//! macOS has no subreaper, and `posix_spawn` with `POSIX_SPAWN_SETPGROUP` or +//! `POSIX_SPAWN_SETSID` leaves the supervised process group even though the +//! profile denies the `setsid`/`setpgid` system calls. Each confined run +//! therefore carries `NAME=` in its environment. After the run, +//! every process whose exec-time arguments and environment +//! (`KERN_PROCARGS2`, readable only for same-user processes) contain that +//! exact entry is sent `SIGKILL`, and the run is rejected. +//! +//! Residual: a descendant that execs with the variable removed is not found; +//! it stays in the same sandbox instance and remains network-denied. + +use std::time::{Duration, Instant}; + +use error_stack::Report; + +use crate::error::ReleaseError; + +/// Bound for killing survivors and waiting for them to disappear. +const REAP_GRACE: Duration = Duration::from_secs(2); +/// Polling interval while waiting for killed survivors to vanish. +const POLL_INTERVAL: Duration = Duration::from_millis(10); +/// Extra slots when listing pids, for processes started between calls. +const PID_SLACK: usize = 256; + +fn survivor_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Boundary).attach(message.to_string()) +} + +/// Lists every process ID on the system. +fn all_pids() -> Result, Report> { + // SAFETY: a null buffer of size zero asks only for the current count. + let count = unsafe { libc::proc_listallpids(core::ptr::null_mut(), 0) }; + let count = + usize::try_from(count).map_err(|_| survivor_error("cannot count processes"))? + PID_SLACK; + let mut pids: Vec = vec![0; count]; + let bytes = libc::c_int::try_from(count * size_of::()) + .map_err(|_| survivor_error("process list is too large"))?; + // SAFETY: `pids` has room for exactly `bytes` bytes of pid entries. + let listed = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) }; + let listed = usize::try_from(listed).map_err(|_| survivor_error("cannot list processes"))?; + pids.truncate(listed.min(count)); + Ok(pids) +} + +/// Returns the kernel's maximum argument-area size (`KERN_ARGMAX`). +fn argument_area_size() -> Result> { + let mut maximum: libc::c_int = 0; + let mut size = size_of::(); + let mut mib = [libc::CTL_KERN, libc::KERN_ARGMAX]; + // SAFETY: `maximum` and `size` describe a valid c_int output buffer. + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 2, + (&raw mut maximum).cast(), + &raw mut size, + core::ptr::null_mut(), + 0, + ) + }; + usize::try_from(maximum) + .ok() + .filter(|maximum| rc == 0 && *maximum > 0) + .ok_or_else(|| survivor_error("cannot read KERN_ARGMAX")) +} + +/// Reads `pid`'s exec-time argument and environment area into `buffer`, +/// returning the filled prefix when readable (same-user processes only). +fn process_arguments(pid: libc::pid_t, buffer: &mut [u8]) -> Option<&[u8]> { + let mut size = buffer.len(); + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; + // SAFETY: `buffer` and `size` describe a valid output buffer. + let rc = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + 3, + buffer.as_mut_ptr().cast(), + &raw mut size, + core::ptr::null_mut(), + 0, + ) + }; + if rc == 0 { buffer.get(..size) } else { None } +} + +/// Returns whether `area` holds `entry` as a whole NUL-delimited string. +fn holds_entry(area: &[u8], entry: &[u8]) -> bool { + area.split(|byte| *byte == 0).any(|item| item == entry) +} + +/// Returns every other process carrying `entry`. +fn carriers(entry: &[u8]) -> Result, Report> { + // SAFETY: `getpid` has no preconditions. + let own = unsafe { libc::getpid() }; + let mut buffer = vec![0_u8; argument_area_size()?]; + let mut found = Vec::new(); + for pid in all_pids()? { + let carries = pid > 1 + && pid != own + && process_arguments(pid, &mut buffer).is_some_and(|area| holds_entry(area, entry)); + if carries { + found.push(pid); + } + } + Ok(found) +} + +/// Kills every surviving carrier of `name=nonce` and rejects the run if any +/// existed. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] naming the survivors when any were +/// found (after killing them), or when they did not vanish within the grace. +pub(crate) fn reap(name: &str, nonce: &str) -> Result<(), Report> { + let entry = format!("{name}={nonce}").into_bytes(); + let deadline = Instant::now() + REAP_GRACE; + let mut killed = Vec::new(); + loop { + let found = carriers(&entry)?; + if found.is_empty() { + break; + } + for pid in &found { + // SAFETY: `kill` has no memory-safety preconditions; `pid > 1`. + unsafe { + libc::kill(*pid, libc::SIGKILL); + } + } + killed.extend(found); + if Instant::now() >= deadline { + return Err(survivor_error(format!( + "confined descendants {killed:?} still exist after SIGKILL" + ))); + } + std::thread::sleep(POLL_INTERVAL); + } + if killed.is_empty() { + return Ok(()); + } + killed.sort_unstable(); + killed.dedup(); + Err(survivor_error(format!( + "confined descendants {killed:?} escaped the process group and were killed" + ))) +} diff --git a/crates/trusted-server-release/src/boundary/tests.rs b/crates/trusted-server-release/src/boundary/tests.rs new file mode 100644 index 0000000000..5689787d7d --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests.rs @@ -0,0 +1,235 @@ +//! Boundary tests. +//! +//! Fixtures are host-independent: confined children re-execute this test +//! binary in a hidden, nonce-gated helper mode (see [`helper_entry`] and +//! [`modes`]) built from std and libc only — no shells, interpreters or +//! coreutils. The helper speaks the same probe protocol as +//! `boundary-probe connect`, sharing +//! [`connect_probe`](super::probe::connect_probe), after a sentinel that +//! skips the libtest preamble. +//! +//! Tool roots require `nlink == 1`, and Cargo hard-links its test binaries, +//! so every boundary fixture runs a private staged copy of this binary. + +mod container; +mod modes; +mod unit; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command as StdCommand; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use super::*; + +/// Environment selector `:`; absent in normal test runs. +const HELPER_SELECTOR: &str = "TS_RELEASE_BOUNDARY_HELPER"; +/// Environment variable carrying the nonce the selector must match. +const HELPER_NONCE: &str = "TS_RELEASE_BOUNDARY_NONCE"; +/// Exact libtest name of [`helper_entry`]. +const HELPER_TEST: &str = "boundary::tests::helper_entry"; +/// Marks the helper's report after the libtest preamble. +const SENTINEL: &[u8] = b"\x1eTS-BOUNDARY-HELPER\x1e"; +/// libtest arguments selecting only [`helper_entry`]; mode arguments follow. +const HELPER_ARGUMENTS: [&str; 4] = ["--exact", HELPER_TEST, "--nocapture", "--test-threads=1"]; +/// Exit status of a helper whose setup failed. +const HELPER_SETUP_FAILED: i32 = 90; +/// Generous per-run bound for every fixture child. +const SLACK: Duration = Duration::from_secs(60); + +/// Returns limits generous enough for any fixture child. +fn limits() -> ProcessLimits { + ProcessLimits { + wall: SLACK, + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +/// Returns this run's helper nonce. +fn nonce() -> &'static str { + static NONCE: OnceLock = OnceLock::new(); + NONCE.get_or_init(|| { + hex::encode(super::random_bytes::<16>().expect("should read nonce entropy")) + }) +} + +/// Returns the canonical (Cargo hard-linked) test binary path. +fn test_binary() -> PathBuf { + std::env::current_exe() + .and_then(std::fs::canonicalize) + .expect("should locate the test binary") +} + +/// Returns the helper environment selecting `mode`. +fn helper_environment(mode: &str) -> Vec<(OsString, OsString)> { + vec![ + ( + OsString::from(HELPER_SELECTOR), + OsString::from(format!("{mode}:{}", nonce())), + ), + (OsString::from(HELPER_NONCE), OsString::from(nonce())), + ] +} + +/// Returns the full helper argument vector for `mode_arguments`. +fn helper_arguments(mode_arguments: &[&str]) -> Vec { + HELPER_ARGUMENTS + .iter() + .chain(mode_arguments) + .map(OsString::from) + .collect() +} + +/// Returns the test-binary probe at `program`. +fn helper_probe(program: &Path) -> ProbeProgram { + ProbeProgram::test_helper( + program.to_path_buf(), + helper_arguments(&[]), + helper_environment("connect"), + SENTINEL, + ) +} + +/// Stages a private single-link copy of the test binary into `tools` and +/// returns its probe. +fn staged_probe(tools: &Path) -> ProbeProgram { + helper_probe(&test_binary()) + .staged(tools) + .expect("should stage the helper binary") +} + +/// Builds a confined helper command running `mode` from `working_directory`. +fn helper_command( + program: &Path, + working_directory: &Path, + mode: &str, + mode_arguments: &[&str], +) -> ConfinedCommand { + let mut command = ConfinedCommand::new(program, working_directory, limits()) + .expect("should describe helper command") + .arguments(helper_arguments(mode_arguments)); + for (name, value) in helper_environment(mode) { + command = command + .environment(name, value) + .expect("should accept helper environment"); + } + command +} + +/// Returns the helper's report text after the sentinel. +fn helper_report(stdout: &[u8]) -> String { + let start = stdout + .windows(SENTINEL.len()) + .rposition(|window| window == SENTINEL) + .expect("should find helper sentinel in stdout"); + String::from_utf8_lossy(&stdout[start + SENTINEL.len()..]) + .trim_end() + .to_owned() +} + +/// Spawns an unconfined same-user sibling helper that sleeps. +fn spawn_sibling() -> std::process::Child { + let mut command = StdCommand::new(test_binary()); + command + .args(helper_arguments(&["30000"])) + .env_clear() + .stdout(std::process::Stdio::null()); + for (name, value) in helper_environment("sleep") { + command.env(name, value); + } + command.spawn().expect("should spawn sibling") +} + +/// Returns whether `pid` still exists (zombies count until reaped). +fn process_exists(pid: libc::pid_t) -> bool { + // SAFETY: signal 0 only checks existence; callers pass a pid > 1. + unsafe { libc::kill(pid, 0) == 0 } +} + +/// Waits up to five seconds for `pid` to disappear. +fn wait_gone(pid: libc::pid_t) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if !process_exists(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + false +} + +/// Reads a pid recorded by a helper. +fn read_pid(path: &Path) -> libc::pid_t { + let pid: libc::pid_t = std::fs::read_to_string(path) + .expect("should read recorded pid") + .trim() + .parse() + .expect("should parse recorded pid"); + assert!(pid > 1, "should record a real pid"); + pid +} + +/// Returns the helper mode when the selector, nonce and argv all match a +/// fixture-launched helper run. +fn authorized_mode(selector: &str, nonce: Option<&str>, arguments: &[String]) -> Option { + let (mode, selector_nonce) = selector.rsplit_once(':')?; + let exact = arguments.iter().any(|argument| argument == "--exact"); + let filtered = arguments.iter().any(|argument| argument == HELPER_TEST); + let matched = nonce.is_some_and(|nonce| !nonce.is_empty() && nonce == selector_nonce); + (exact && filtered && matched).then(|| mode.to_owned()) +} + +/// Hidden re-exec entry point; returns immediately in normal test runs. +/// +/// A selector present without the fixture's exact filter and nonce fails the +/// run rather than silently acting or passing. +#[test] +fn helper_entry() { + let Some(selector) = std::env::var_os(HELPER_SELECTOR) else { + return; + }; + let nonce = std::env::var(HELPER_NONCE).ok(); + let arguments: Vec = std::env::args().collect(); + let mode = authorized_mode(&selector.to_string_lossy(), nonce.as_deref(), &arguments); + assert!( + mode.is_some(), + "should run helper mode only from a fixture-launched exact helper invocation" + ); + let rest: Vec = arguments + .iter() + .skip_while(|argument| argument.as_str() != "--test-threads=1") + .skip(1) + .cloned() + .collect(); + let code = mode.map_or(HELPER_SETUP_FAILED, |mode| modes::run(&mode, &rest)); + // SAFETY: `_exit` ends the helper without returning to the harness, so no + // harness summary is appended to the captured output. + unsafe { libc::_exit(code) } +} + +#[test] +fn helper_requires_exact_filter_and_matching_nonce() { + let exact: Vec = ["binary", "--exact", HELPER_TEST] + .iter() + .map(|part| (*part).to_owned()) + .collect(); + assert_eq!( + authorized_mode("connect:abc", Some("abc"), &exact), + Some(String::from("connect")), + "should authorize a fixture-launched helper" + ); + assert_eq!(authorized_mode("connect:abc", Some("xyz"), &exact), None); + assert_eq!(authorized_mode("connect:abc", None, &exact), None); + assert_eq!(authorized_mode("connect:", Some(""), &exact), None); + assert_eq!( + authorized_mode("connect:abc", Some("abc"), &exact[..2]), + None + ); +} diff --git a/crates/trusted-server-release/src/boundary/tests/container.rs b/crates/trusted-server-release/src/boundary/tests/container.rs new file mode 100644 index 0000000000..2519aaeee8 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/container.rs @@ -0,0 +1,479 @@ +//! Platform-independent seccomp, Docker argv, daemon-socket and host-input +//! tests on real files. + +use std::ffi::CString; +use std::fs::{self, File}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _, symlink}; +use std::os::unix::net::UnixListener; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use serde_json::Value; +use tempfile::TempDir; + +use super::super::daemon::DockerClient; +use super::super::daemon::check_daemon_socket; +use super::super::docker::{ + DockerSettings, RunPlan, container_path, mounts, run_arguments, validate_image_id, +}; +use super::super::host_inputs::HostInputs; +use super::super::seccomp; +use super::unit::{canonical_tempdir, sample_roots}; +use super::*; + +#[test] +fn effective_seccomp_denies_inet_sockets_and_preserves_baseline() { + let baseline = seccomp::baseline().expect("should match reviewed digest"); + let original: Value = serde_json::from_slice(baseline).expect("should parse baseline"); + let effective: Value = + serde_json::from_slice(&seccomp::effective(baseline).expect("should derive profile")) + .expect("should parse effective profile"); + for key in ["defaultAction", "defaultErrnoRet"] { + assert_eq!( + original.get(key), + effective.get(key), + "should preserve {key}" + ); + } + let rules = effective["syscalls"] + .as_array() + .expect("should have syscalls"); + let socket_rules: Vec<&Value> = rules + .iter() + .filter(|rule| { + rule["names"].as_array().is_some_and(|names| { + names.iter().any(|name| { + matches!(name.as_str(), Some("socket" | "socketpair" | "socketcall")) + }) + }) + }) + .collect(); + assert_eq!(socket_rules.len(), 1, "should keep exactly one socket rule"); + assert_eq!( + socket_rules[0]["args"], + serde_json::json!([{"index": 0, "value": 1, "op": "SCMP_CMP_EQ"}]), + "should allow only AF_UNIX" + ); + let original_rules = original["syscalls"] + .as_array() + .expect("should have syscalls"); + assert_eq!( + rules.len(), + original_rules.len() - 3 + 1, + "should drop only the three socket-only rules and add one" + ); + let mut allowing = original.clone(); + allowing["defaultAction"] = Value::from("SCMP_ACT_ALLOW"); + let bytes = serde_json::to_vec(&allowing).expect("should serialize"); + assert!( + seccomp::effective(&bytes).is_err(), + "should reject a permissive baseline" + ); +} + +fn docker_settings(client: &Path) -> DockerSettings { + DockerSettings { + client: client.to_path_buf(), + cpus: 1, + memory_bytes: 256 * 1024 * 1024, + pids_limit: 128, + tmpfs_bytes: 16 * 1024 * 1024, + user: (1001, 1001), + stop_timeout_seconds: 5, + host_limits: limits(), + } +} + +#[test] +fn docker_run_arguments_are_exact() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let mounts = mounts(&roots).expect("should build mounts"); + let image = format!("sha256:{}", "b".repeat(64)); + let environment = [( + OsString::from("HOME"), + OsString::from("/boundary/writable/0"), + )]; + let arguments = [OsString::from("--flag")]; + let plan = RunPlan { + config: Path::new("/host/config"), + seccomp: Path::new("/host/seccomp.json"), + name: "ts-release-boundary-fixture", + image: &image, + mounts: &mounts, + environment: &environment, + working_directory: Path::new("/boundary/writable/0"), + program: Path::new("/boundary/tool/0"), + arguments: &arguments, + interactive: false, + }; + let actual: Vec = run_arguments(&docker_settings(Path::new("/usr/bin/docker")), &plan) + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect(); + let root = base.to_str().expect("should be UTF-8"); + assert_eq!( + actual, + expected_run_arguments(root, &image), + "should build the exact fixed argv" + ); + assert!( + !actual.iter().any(|part| part == "--pid" + || part.starts_with("--pid=") + || part == "-t" + || part == "--tty"), + "should never pass --pid or a TTY" + ); +} + +/// The exact argv expected for the fixture plan rooted at `root`. +/// Fixed argv prefix expected before the mounts. +const EXPECTED_PREFIX: [&str; 33] = [ + "--config", + "/host/config", + "--host", + "unix:///var/run/docker.sock", + "run", + "--name", + "ts-release-boundary-fixture", + "--rm", + "--init", + "--pull", + "never", + "--network", + "none", + "--ipc", + "private", + "--read-only", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", + "--security-opt", + "seccomp=/host/seccomp.json", + "--pids-limit", + "128", + "--cpus", + "1", + "--memory", + "268435456b", + "--user", + "1001:1001", + "--tmpfs", + "/tmp:rw,noexec,nosuid,nodev,size=16777216", + "--mount", +]; + +fn expected_run_arguments(root: &str, image: &str) -> Vec { + EXPECTED_PREFIX + .iter() + .map(|part| (*part).to_owned()) + .chain([ + format!("type=bind,source={root}/immutable,target=/boundary/immutable/0,readonly,bind-recursive=readonly"), + String::from("--mount"), + format!("type=bind,source={root}/tool,target=/boundary/tool/0,readonly,bind-recursive=readonly"), + String::from("--mount"), + format!("type=bind,source={root}/writable,target=/boundary/writable/0"), + ]) + .chain( + [ + "--env", + "HOME=/boundary/writable/0", + "--workdir", + "/boundary/writable/0", + image, + "/boundary/tool/0", + "--flag", + ] + .iter() + .map(|part| (*part).to_owned()), + ) + .collect() +} + +#[test] +fn docker_paths_translate_and_reject_mount_metacharacters() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + assert_eq!( + container_path(&roots, &base.join("writable/out/x")).expect("should translate"), + PathBuf::from("/boundary/writable/0/out/x"), + "should map into the writable mount" + ); + assert!( + container_path(&roots, Path::new("/elsewhere")).is_err(), + "should reject paths outside every root" + ); + let comma = base.join("with,comma"); + fs::create_dir(&comma).expect("should create directory"); + let roots = BoundaryRoots::new(&[], &[], &[comma]).expect("should accept roots"); + assert!( + mounts(&roots).is_err(), + "should reject ',' in mount sources" + ); +} + +#[test] +fn docker_settings_fit_host_capacity() { + let client = Path::new("/usr/bin/docker"); + assert!( + docker_settings(client).validate().is_ok(), + "should accept modest limits" + ); + let parallelism = std::thread::available_parallelism().expect("should read parallelism"); + let mut settings = docker_settings(client); + settings.cpus = u32::try_from(parallelism.get()).expect("should fit") + 1; + assert!( + settings.validate().is_err(), + "should reject CPUs above host capacity" + ); + for mutate in [ + (|settings: &mut DockerSettings| settings.cpus = 0) as fn(&mut DockerSettings), + |settings| settings.memory_bytes = u64::MAX, + |settings| settings.user = (0, 0), + |settings| settings.stop_timeout_seconds = 0, + |settings| settings.stop_timeout_seconds = 3600, + ] { + let mut settings = docker_settings(client); + mutate(&mut settings); + assert!(settings.validate().is_err(), "should reject {settings:?}"); + } +} + +#[test] +fn image_must_be_pinned_by_content_id() { + assert!(validate_image_id(&format!("sha256:{}", "c".repeat(64))).is_ok()); + for rejected in [ + "ubuntu:24.04", + "sha256:abc", + &format!("sha256:{}", "C".repeat(64)), + ] { + assert!( + validate_image_id(rejected).is_err(), + "should reject {rejected}" + ); + } +} + +fn host_inputs_fixture() -> (TempDir, PathBuf, HostInputs) { + let (directory, base) = canonical_tempdir(); + let client = base.join("docker-client"); + fs::write(&client, b"fictional client").expect("should write client"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state parent"); + let inputs = HostInputs::create(&state, &client, &[]).expect("should create inputs"); + (directory, client, inputs) +} + +fn make_writable(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("should chmod"); +} + +#[test] +fn host_inputs_rehash_detects_tampering_between_commands() { + let (_directory, _client, inputs) = host_inputs_fixture(); + inputs.verify().expect("should verify fresh inputs"); + let effective = inputs.effective_profile().to_path_buf(); + make_writable(&effective, 0o644); + fs::write(&effective, b"{}").expect("should tamper profile"); + assert!( + inputs.verify().is_err(), + "should detect a changed effective profile" + ); + + let (_directory, _client, inputs) = host_inputs_fixture(); + let baseline = inputs.directory().join("seccomp-baseline.json"); + make_writable(&baseline, 0o644); + fs::write(&baseline, b"{}").expect("should tamper baseline"); + assert!( + inputs.verify().is_err(), + "should detect a changed baseline profile" + ); + + let (_directory, _client, inputs) = host_inputs_fixture(); + make_writable(inputs.config(), 0o700); + fs::write(inputs.config().join("config.json"), b"{}").expect("should add config"); + assert!(inputs.verify().is_err(), "should detect a non-empty config"); + + let (_directory, client, inputs) = host_inputs_fixture(); + fs::write(&client, b"substituted client").expect("should replace client"); + assert!(inputs.verify().is_err(), "should detect a changed client"); + + let (_directory, _client, inputs) = host_inputs_fixture(); + fs::write(inputs.directory().join("extra"), b"x").expect("should add entry"); + assert!(inputs.verify().is_err(), "should detect an added entry"); +} + +#[test] +fn host_inputs_must_be_disjoint_from_mounted_roots() { + let (_directory, base) = canonical_tempdir(); + let client = base.join("docker-client"); + fs::write(&client, b"fictional client").expect("should write client"); + let mounted = base.join("mounted"); + fs::create_dir_all(mounted.join("inner")).expect("should create mount"); + for parent in [base.clone(), mounted.clone(), mounted.join("inner")] { + assert!( + HostInputs::create(&parent, &client, &[&mounted]).is_err(), + "should reject state parent {parent:?} overlapping a mount" + ); + } + let alias = base.join("client-alias"); + symlink(&client, &alias).expect("should create client alias"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state"); + assert!( + HostInputs::create(&state, &alias, &[&mounted]).is_err(), + "should reject an aliased client path" + ); +} + +#[test] +fn docker_client_must_be_outside_every_mounted_root() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state directory"); + let mounted: Vec<&Path> = roots.entries().map(|(_, _, path)| path).collect(); + for root in &mounted { + let client = if root.is_dir() { + root.join("docker-client") + } else { + root.to_path_buf() + }; + fs::write(&client, b"fictional client").expect("should create fixture client"); + assert!( + HostInputs::create(&state, &client, &mounted).is_err(), + "should reject a host executable exposed through a mounted root" + ); + } +} + +#[test] +fn host_input_fifo_is_rejected_without_waiting_for_a_writer() { + let (_directory, base) = canonical_tempdir(); + let client = base.join("docker-client"); + let state = base.join("state"); + fs::create_dir(&state).expect("should create state directory"); + let name = CString::new(client.as_os_str().as_bytes()).expect("should encode FIFO path"); + // SAFETY: The path is NUL-terminated and remains valid during mkfifo. + assert_eq!( + unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, + 0, + "should create FIFO" + ); + let (sender, receiver) = mpsc::channel(); + let reader_path = client.clone(); + let reader = thread::spawn(move || { + let rejected = HostInputs::create(&state, &reader_path, &[]).is_err(); + let _ = sender.send(rejected); + }); + let result = receiver.recv_timeout(Duration::from_millis(200)); + // Release a regressed blocking open before joining, so failure cannot hang CI. + let _keeper = File::options() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&client) + .expect("should open nonblocking FIFO cleanup handle"); + reader.join().expect("should reap input reader"); + assert_eq!(result, Ok(true), "should reject FIFO without a writer"); +} + +#[test] +fn cleanup_never_retries_through_changed_host_inputs() { + let (_directory, base) = canonical_tempdir(); + let client_path = base.join("docker-client"); + fs::write(&client_path, b"fictional client").expect("should create fixture client"); + let client = + DockerClient::new(&client_path, &base, &[], limits()).expect("should create pinned client"); + fs::write(&client_path, b"substituted client").expect("should replace fixture client"); + let mut failures = Vec::new(); + assert!( + client.cleanup_host(&["version"], &mut failures).is_none(), + "should reject cleanup after integrity failure" + ); + assert_eq!( + failures.len(), + 1, + "should stop at integrity failure before endpoint inspection or execution" + ); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn container_boundary_is_refused_off_linux() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let settings = docker_settings(Path::new("/usr/bin/docker")); + let image = super::super::docker::ImageSelection::Test { + id: format!("sha256:{}", "a".repeat(64)), + }; + assert!( + Boundary::linux_container(roots, settings, image, &base).is_err(), + "should refuse the container boundary off Linux" + ); +} + +#[test] +fn daemon_socket_rejects_user_owned_aliased_and_shared_paths() { + let (_directory, base) = canonical_tempdir(); + let socket = base.join("d.sock"); + let _listener = UnixListener::bind(&socket).expect("should bind fixture socket"); + let error = check_daemon_socket(&socket).expect_err("should reject a user-owned path"); + assert!( + format!("{error:?}").contains("root-owned"), + "should explain ownership" + ); + let alias = base.join("alias.sock"); + symlink(&socket, &alias).expect("should alias socket"); + assert!( + check_daemon_socket(&alias).is_err(), + "should reject an alias" + ); + assert!( + check_daemon_socket(&base.join("missing.sock")).is_err(), + "should reject a missing socket" + ); + if cfg!(target_os = "macos") { + // Root-owned socket under the root-owned but group-writable + // /private/var/run: rejected for its shared-writable ancestor. + assert!( + check_daemon_socket(Path::new("/var/run/mDNSResponder")).is_err(), + "should reject a socket under a group-writable directory" + ); + } +} + +#[test] +fn effective_seccomp_admits_only_native_architectures() { + let baseline = seccomp::baseline().expect("should match reviewed digest"); + let original: Value = serde_json::from_slice(baseline).expect("should parse baseline"); + let effective: Value = + serde_json::from_slice(&seccomp::effective(baseline).expect("should derive profile")) + .expect("should parse effective profile"); + let native = |profile: &Value| -> Vec { + profile["archMap"] + .as_array() + .expect("should have archMap") + .iter() + .map(|entry| entry["architecture"].clone()) + .collect() + }; + assert_eq!( + native(&original), + native(&effective), + "should keep native ABIs" + ); + for entry in effective["archMap"] + .as_array() + .expect("should have archMap") + { + assert_eq!( + entry["subArchitectures"], + serde_json::json!([]), + "should admit no non-native ABI (x86/x32 socketcall)" + ); + } +} diff --git a/crates/trusted-server-release/src/boundary/tests/linux.rs b/crates/trusted-server-release/src/boundary/tests/linux.rs new file mode 100644 index 0000000000..0fc6b1b765 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/linux.rs @@ -0,0 +1,337 @@ +//! Real Docker container boundary tests (Linux only). +//! +//! On Linux these REQUIRE a reachable Docker daemon at the fixed socket and +//! fail otherwise; they never skip. The image comes from +//! `TS_RELEASE_TEST_IMAGE` (default `ubuntu:24.04`, whose glibc matches the +//! `ubuntu-24.04` runner that builds this test binary). A missing image is +//! pulled once under a bounded deadline; a failed pull fails the test. +//! +//! What the confined probe shows: a staged copy of the test binary, +//! bind-mounted read-only, attempts a TCP connect to `127.0.0.1:` +//! inside the container. Under `--network none` that is the container's own +//! loopback, so without the effective seccomp profile the result would be +//! `ECONNREFUSED`; the proof requires `EPERM`, which only the seccomp socket +//! denial produces. It shows nothing about routes to other hosts. + +use std::fs; +use std::os::unix::fs::symlink; + +use tempfile::{TempDir, tempdir}; + +use super::super::daemon::DockerClient; +use super::super::docker::{DockerSettings, ImageSelection, container_path}; +use super::super::probe::LoopbackListener; +use super::*; +use crate::process::{ProcessOutcome, ProcessResult}; + +/// The fixed Docker client on GitHub-hosted Ubuntu runners. +const DOCKER_CLIENT: &str = "/usr/bin/docker"; +const TEST_IMAGE_VARIABLE: &str = "TS_RELEASE_TEST_IMAGE"; +const DEFAULT_TEST_IMAGE: &str = "ubuntu:24.04"; +/// `EROFS`: a write into a read-only bind mount. +const READ_ONLY_FILESYSTEM: &str = "denied errno=30"; +/// `ESRCH`: the host pid does not exist in the container's PID namespace. +const NO_SUCH_PROCESS: &str = "denied errno=3"; + +fn host_limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(300), + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +fn settings() -> DockerSettings { + // SAFETY: `getuid`/`getgid` have no preconditions. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + DockerSettings { + client: PathBuf::from(DOCKER_CLIENT), + cpus: 1, + memory_bytes: 256 * 1024 * 1024, + pids_limit: 128, + tmpfs_bytes: 16 * 1024 * 1024, + user, + stop_timeout_seconds: 5, + host_limits: host_limits(), + } +} + +fn succeeded(result: &ProcessResult) -> bool { + result.outcome() == (ProcessOutcome::Exited { code: 0 }) +} + +/// Resolves the test image to its local content ID, pulling it if absent. +fn test_image(client: &DockerClient) -> ImageSelection { + let reference = + std::env::var(TEST_IMAGE_VARIABLE).unwrap_or_else(|_| String::from(DEFAULT_TEST_IMAGE)); + let inspect = [ + "image", + "inspect", + "--format", + "{{.Id}}", + reference.as_str(), + ]; + let mut result = client + .host(&inspect) + .expect("should run docker image inspect"); + if !succeeded(&result) { + let pull = client + .host(&["pull", reference.as_str()]) + .expect("should run docker pull"); + assert!( + succeeded(&pull), + "should pull test image {reference}: {pull:?}" + ); + result = client + .host(&inspect) + .expect("should rerun docker image inspect"); + } + assert!(succeeded(&result), "should inspect test image {reference}"); + let id = String::from_utf8_lossy(result.stdout()).trim().to_owned(); + ImageSelection::Test { id } +} + +struct Fixture { + _roots: TempDir, + _state: TempDir, + immutable: PathBuf, + tools: PathBuf, + writable: PathBuf, + probe: ProbeProgram, + boundary: Boundary, +} + +/// Creates fixture roots under `base` and returns them. +fn create_roots(base: &Path) -> (PathBuf, PathBuf, PathBuf) { + let immutable = base.join("immutable"); + let tools = base.join("tools"); + let writable = base.join("writable"); + fs::create_dir_all(immutable.join("src")).expect("should create immutable root"); + fs::create_dir(&tools).expect("should create tool root"); + fs::create_dir(&writable).expect("should create writable root"); + fs::write(immutable.join("src/a.txt"), b"original").expect("should write source"); + (immutable, tools, writable) +} + +/// Requires the daemon and resolves the test image. +fn reachable_image(state: &Path) -> ImageSelection { + let client = DockerClient::new(Path::new(DOCKER_CLIENT), state, &[], host_limits()) + .expect("should pin docker client inputs"); + let version = client + .host(&["version", "--format", "{{.Server.Version}}"]) + .expect("should run docker version"); + assert!( + succeeded(&version), + "should reach the Docker daemon at the fixed socket: {version:?}" + ); + test_image(&client) +} + +impl Fixture { + fn new() -> Self { + let roots_directory = tempdir().expect("should create roots directory"); + let state = tempdir().expect("should create state directory"); + let base = fs::canonicalize(roots_directory.path()).expect("should canonicalize"); + let (immutable, tools, writable) = create_roots(&base); + let probe = staged_probe(&tools); + let roots = BoundaryRoots::new( + std::slice::from_ref(&immutable), + std::slice::from_ref(&tools), + std::slice::from_ref(&writable), + ) + .expect("should accept roots"); + let image = reachable_image(state.path()); + let boundary = Boundary::linux_container(roots, settings(), image, state.path()) + .expect("should configure container boundary"); + Self { + _roots: roots_directory, + _state: state, + immutable, + tools, + writable, + probe, + boundary, + } + } + + fn prove(&self) -> DenialProof { + self.boundary + .establish_denial(&self.probe, limits()) + .expect("should prove denial with a real control and denied container probe") + } + + fn container(&self, host: &Path) -> String { + container_path(&self.boundary.roots, host) + .expect("should translate path") + .to_string_lossy() + .into_owned() + } + + fn command(&self, mode: &str, arguments: &[&str]) -> ConfinedCommand { + helper_command(self.probe.program(), &self.writable, mode, arguments) + } + + fn run(&self, proof: &DenialProof, mode: &str, arguments: &[&str]) -> String { + let result = self + .boundary + .run_confined(&self.command(mode, arguments), proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + } +} + +#[test] +fn container_denial_proof_and_confined_execution() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + proof.denied_errno(), + libc::EPERM, + "should observe seccomp EPERM" + ); + + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + let marker = fixture.container(&fixture.writable); + let report = fixture.run(&proof, "nest", &["2", &address, &marker]); + assert_eq!( + report, "denied errno=1", + "should deny the grandchild's connect" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should see no accept" + ); + for depth in 0..=2 { + assert!( + fixture.writable.join(format!("nest-{depth}")).exists(), + "should record nesting level {depth}" + ); + } + + let source = fixture.container(&fixture.immutable.join("src/a.txt")); + assert_eq!( + fixture.run(&proof, "write", &[&source]), + READ_ONLY_FILESYSTEM + ); + assert_eq!( + fs::read(fixture.immutable.join("src/a.txt")).expect("should read"), + b"original", + "should keep the immutable source" + ); + let private = fixture.container(&fixture.writable.join("out.txt")); + assert_eq!(fixture.run(&proof, "write", &[&private]), "written"); + assert_eq!( + fs::read(fixture.writable.join("out.txt")).expect("should read"), + b"tampered", + "should allow private writes" + ); +} + +#[test] +fn container_denies_every_non_unix_socket_family() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + for domain in ["inet", "inet6", "netlink", "packet"] { + assert_eq!( + fixture.run(&proof, "socket", &[domain]), + "denied errno=1", + "should deny {domain} sockets with seccomp EPERM" + ); + } + assert_eq!(fixture.run(&proof, "socket", &["unix"]), "created"); + let socket = fixture.container(&fixture.writable.join("x.sock")); + assert_eq!( + fixture.run(&proof, "unix-exchange", &[&socket]), + "exchanged" + ); +} + +#[test] +fn container_cannot_signal_a_host_sibling() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let mut sibling = spawn_sibling(); + let pid = sibling.id().to_string(); + let kill = libc::SIGKILL.to_string(); + let report = fixture.run(&proof, "signal", &[&pid, &kill]); + let alive = sibling.try_wait().expect("should poll sibling").is_none(); + sibling.kill().expect("should stop sibling"); + sibling.wait().expect("should reap sibling"); + assert_eq!(report, NO_SUCH_PROCESS, "should not see host pids"); + assert!(alive, "should leave the host sibling running"); +} + +#[test] +fn container_is_removed_after_a_timeout() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let mut command = fixture.command("sleep", &["30000"]); + command.limits.wall = Duration::from_secs(3); + let result = fixture + .boundary + .run_confined(&command, &proof) + .expect("should confirm the container removed after the timeout"); + assert_eq!( + result.outcome(), + ProcessOutcome::TimedOut, + "should time out" + ); +} + +#[test] +fn container_rejects_unsafe_aliases_and_hard_links_in_mounts() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let cases: [(&Path, &str, &str); 4] = [ + (&fixture.writable, "absolute", "/etc"), + (&fixture.immutable, "escaping", "../../outside"), + (&fixture.immutable, "dangling", "src/missing"), + (&fixture.tools, "dangling", "missing-tool"), + ]; + for (root, name, target) in cases { + let link = root.join(name); + symlink(target, &link).expect("should create alias"); + assert!( + fixture + .boundary + .run_confined(&fixture.command("sleep", &["0"]), &proof) + .is_err(), + "should reject {name} alias {target} in {root:?}" + ); + fs::remove_file(&link).expect("should remove alias"); + } + let alias = fixture.writable.join("prelink"); + fs::hard_link(fixture.immutable.join("src/a.txt"), &alias).expect("should hard link"); + let error = fixture + .boundary + .run_confined(&fixture.command("sleep", &["0"]), &proof) + .expect_err("should reject a pre-existing hard link"); + assert!( + format!("{error:?}").contains("hard links"), + "should name the hard link" + ); +} + +#[test] +fn docker_client_rehash_rejects_tampering_between_commands() { + let state = tempdir().expect("should create state directory"); + let client = DockerClient::new(Path::new(DOCKER_CLIENT), state.path(), &[], host_limits()) + .expect("should pin inputs"); + let first = client + .host(&["version", "--format", "{{.Client.Version}}"]) + .expect("should run first host command"); + assert!(succeeded(&first), "should run the pinned client"); + let profile = client.inputs().effective_profile().to_path_buf(); + fs::set_permissions( + &profile, + std::os::unix::fs::PermissionsExt::from_mode(0o644), + ) + .expect("should chmod profile"); + fs::write(&profile, b"{}").expect("should tamper profile"); + assert!( + client.host(&["version"]).is_err(), + "should refuse the next host command after tampering" + ); +} diff --git a/crates/trusted-server-release/src/boundary/tests/macos.rs b/crates/trusted-server-release/src/boundary/tests/macos.rs new file mode 100644 index 0000000000..447456330f --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/macos.rs @@ -0,0 +1,492 @@ +//! Real `sandbox-exec` boundary tests (macOS only). +//! +//! These must run outside an enclosing application sandbox: the unconfined +//! control needs local socket permission, and without it no proof is +//! constructed and the tests fail instead of passing vacuously. + +use std::fs; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::time::SystemTime; + +use tempfile::{TempDir, tempdir}; + +use super::super::macos::MacProfile; +use super::super::probe::LoopbackListener; +use super::*; +use crate::process::ProcessOutcome; + +/// A sandbox fixture with one immutable root, a tool root holding a staged +/// copy of the test binary and one private writable root. +struct Fixture { + _directory: TempDir, + immutable: PathBuf, + writable: PathBuf, + probe: ProbeProgram, + boundary: Boundary, +} + +impl Fixture { + fn new() -> Self { + let directory = tempdir().expect("should create fixture directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + let immutable = base.join("immutable"); + let writable = base.join("writable"); + let tools = base.join("tools"); + fs::create_dir_all(immutable.join("src")).expect("should create immutable root"); + fs::create_dir(&writable).expect("should create writable root"); + fs::create_dir(&tools).expect("should create tool root"); + fs::write(immutable.join("src/a.txt"), b"original").expect("should write source"); + let probe = staged_probe(&tools); + let roots = BoundaryRoots::new( + std::slice::from_ref(&immutable), + &[tools], + std::slice::from_ref(&writable), + ) + .expect("should accept fixture roots"); + let boundary = Boundary::mac_sandbox_for_tests(roots).expect("should configure sandbox"); + Self { + _directory: directory, + immutable, + writable, + probe, + boundary, + } + } + + fn prove(&self) -> DenialProof { + self.boundary + .establish_denial(&self.probe, limits()) + .expect("should prove denial with a real control and denied probe") + } + + fn command(&self, mode: &str, arguments: &[&str]) -> ConfinedCommand { + helper_command(self.probe.program(), &self.writable, mode, arguments) + } + + fn run(&self, proof: &DenialProof, mode: &str, arguments: &[&str]) -> String { + let result = self + .boundary + .run_confined(&self.command(mode, arguments), proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + } +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("should be UTF-8") +} + +#[test] +fn confined_probe_is_denied_with_eperm_and_no_accept() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + proof.denied_errno(), + libc::EPERM, + "should observe real EPERM" + ); + + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + let report = fixture.run(&proof, "connect", &[&address]); + assert_eq!(report, "denied errno=1", "should deny confined connect"); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should observe no accept from the confined probe" + ); +} + +#[test] +fn grandchild_inherits_denial() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + let report = fixture.run( + &proof, + "nest", + &["2", &address, path_text(&fixture.writable)], + ); + assert_eq!( + report, "denied errno=1", + "should deny the grandchild's connect" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should see no accept" + ); + let pids: Vec = (0..=2) + .map(|depth| { + fs::read_to_string(fixture.writable.join(format!("nest-{depth}"))) + .expect("should record every nesting level") + }) + .collect(); + assert!( + pids[0] != pids[1] && pids[1] != pids[2] && pids[0] != pids[2], + "should run each level as a distinct process: {pids:?}" + ); +} + +#[test] +fn private_unix_exchange_succeeds_while_inet_stays_denied() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let socket = fixture.writable.join("x.sock"); + assert_eq!( + fixture.run(&proof, "unix-exchange", &[path_text(&socket)]), + "exchanged" + ); + let outside = fixture.immutable.join("x.sock"); + assert_eq!( + fixture.run(&proof, "unix-exchange", &[path_text(&outside)]), + "denied errno=1", + "should deny Unix sockets outside the writable roots" + ); + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + assert_eq!( + fixture.run(&proof, "connect", &[&address]), + "denied errno=1" + ); +} + +#[test] +fn writes_are_denied_in_read_only_roots_and_allowed_in_private_roots() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let denied = fixture.immutable.join("src/new.txt"); + let existing = fixture.immutable.join("src/a.txt"); + for target in [&denied, &existing, &fixture.probe.program().to_path_buf()] { + assert_eq!( + fixture.run(&proof, "write", &[path_text(target)]), + "denied errno=1", + "should deny writing {target:?}" + ); + } + assert!( + !denied.exists(), + "should not create files in the immutable root" + ); + assert_eq!( + fs::read(&existing).expect("should read"), + b"original", + "should keep source" + ); + + let private = fixture.writable.join("out.txt"); + assert_eq!( + fixture.run(&proof, "write", &[path_text(&private)]), + "written" + ); + assert_eq!( + fs::read(&private).expect("should read"), + b"tampered", + "should allow private writes" + ); +} + +#[test] +fn signals_are_limited_to_the_same_sandbox() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + fixture.run(&proof, "signal-own-child", &[]), + "signalled", + "should signal its own child" + ); + + let mut sibling = spawn_sibling(); + let sibling_pid = sibling.id().to_string(); + let kill = libc::SIGKILL.to_string(); + assert_eq!( + fixture.run(&proof, "signal", &[&sibling_pid, &kill]), + "denied errno=1", + "should not signal an outside same-user sibling" + ); + let alive = sibling.try_wait().expect("should poll sibling").is_none(); + sibling.kill().expect("should stop sibling"); + sibling.wait().expect("should reap sibling"); + assert!(alive, "should leave the sibling running"); + + let orchestrator = std::process::id().to_string(); + assert_eq!( + fixture.run(&proof, "signal", &[&orchestrator, "0"]), + "denied errno=1", + "should not signal the orchestrator" + ); +} + +#[test] +fn setsid_and_setpgid_are_denied() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + for mode in ["setsid", "setpgid"] { + assert_eq!( + fixture.run(&proof, mode, &[]), + "denied errno=1", + "should deny {mode} inside the sandbox" + ); + } +} + +#[test] +fn same_group_orphan_is_killed_with_the_run() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let pid_file = fixture.writable.join("orphan.pid"); + assert_eq!( + fixture.run(&proof, "orphan", &[path_text(&pid_file)]), + "spawned" + ); + let pid = read_pid(&pid_file); + assert!(wait_gone(pid), "should kill the same-group orphan {pid}"); +} + +#[test] +fn group_escape_keeping_the_run_nonce_is_killed_and_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let pid_file = fixture.writable.join("escape.pid"); + let error = fixture + .boundary + .run_confined( + &fixture.command("escape", &[path_text(&pid_file), "keep"]), + &proof, + ) + .expect_err("should reject a run whose descendant escaped the group"); + assert!( + format!("{error:?}").contains("escaped"), + "should name the escape: {error:?}" + ); + let pid = read_pid(&pid_file); + assert!(wait_gone(pid), "should kill the escaped descendant {pid}"); +} + +/// Documents the residual: an escape that also scrubs its environment is not +/// found. The test kills the survivor itself. +#[test] +fn group_escape_with_scrubbed_environment_is_the_documented_residual() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let pid_file = fixture.writable.join("residual.pid"); + let outcome = fixture.boundary.run_confined( + &fixture.command("escape", &[path_text(&pid_file), "scrub"]), + &proof, + ); + let pid = read_pid(&pid_file); + let survived = process_exists(pid); + // SAFETY: `pid` is the fixture's own escaped helper (> 1). + unsafe { + libc::kill(pid, libc::SIGKILL); + } + assert!( + outcome.is_ok(), + "should not detect a scrubbed escape: {outcome:?}" + ); + assert!( + survived, + "should leave the scrubbed escapee alive (documented residual)" + ); + assert!(wait_gone(pid), "should be killed by the test"); +} + +#[test] +fn pre_existing_hard_link_is_rejected_before_execution() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let source = fixture.immutable.join("src/a.txt"); + let alias = fixture.writable.join("prelink"); + fs::hard_link(&source, &alias).expect("should hard link into the private root"); + + let rejected = fixture + .boundary + .run_confined(&fixture.command("write", &[path_text(&alias)]), &proof) + .expect_err("should reject the hard-linked immutable file"); + assert!( + format!("{rejected:?}").contains("hard links"), + "should name the hard link" + ); + assert_eq!( + fs::read(&source).expect("should read"), + b"original", + "should not run the helper" + ); +} + +#[test] +fn hard_linked_tool_is_rejected_before_execution() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + fs::hard_link(fixture.probe.program(), fixture.writable.join("tool-alias")) + .expect("should hard link the tool"); + let rejected = fixture + .boundary + .run_confined(&fixture.command("sleep", &["0"]), &proof) + .expect_err("should reject a hard-linked tool"); + assert!( + format!("{rejected:?}").contains("hard links"), + "should name the hard link" + ); +} + +#[test] +fn hard_link_write_through_bypasses_path_denial_and_restoration_hides_it() { + let fixture = Fixture::new(); + let source = fixture.immutable.join("src/a.txt"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o444)).expect("should chmod"); + let before = fs::metadata(&source).expect("should stat source"); + let alias = fixture.writable.join("prelink"); + fs::hard_link(&source, &alias).expect("should hard link"); + fs::set_permissions(&alias, fs::Permissions::from_mode(0o644)).expect("should chmod alias"); + + // Deliberately skip the boundary's nlink scan to show what it prevents. + let profile = MacProfile::generate(&fixture.boundary.roots).expect("should generate"); + let result = profile + .wrap( + &fixture.command("write", &[path_text(&alias)]), + "fixture-run", + ) + .expect("should wrap") + .run() + .expect("should run unscanned helper"); + assert_eq!( + helper_report(result.stdout()), + "written", + "should write through the link" + ); + assert_eq!( + fs::read(&source).expect("should read"), + b"tampered", + "should reach the source" + ); + + restore_and_compare(&source, &alias, &before); +} + +/// Restores content, mtime and mode through `alias` and shows the source +/// then matches its pre-tamper content, mode and mtime. +fn restore_and_compare(source: &Path, alias: &Path, before: &fs::Metadata) { + fs::write(alias, b"original").expect("should restore content"); + let modified = before.modified().expect("should read mtime"); + fs::File::options() + .write(true) + .open(alias) + .and_then(|file| file.set_modified(modified)) + .expect("should restore mtime"); + fs::set_permissions(source, fs::Permissions::from_mode(0o444)).expect("should restore mode"); + let after = fs::metadata(source).expect("should stat source"); + assert_eq!( + fs::read(source).expect("should read"), + b"original", + "should match content" + ); + assert_eq!( + after.permissions(), + before.permissions(), + "should match mode" + ); + assert_eq!(after.modified().ok(), Some(modified), "should match mtime"); +} + +/// Mutates `path` from an unconfined thread while a confined helper sleeps. +fn mutate_during_run(fixture: &Fixture, proof: &DenialProof, path: PathBuf) -> String { + let started = SystemTime::now(); + let mutator = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); + fs::write(&path, b"mutated").expect("should mutate outside the sandbox"); + }); + let outcome = fixture + .boundary + .run_confined(&fixture.command("sleep", &["1500"]), proof); + mutator.join().expect("should join mutator"); + let elapsed = started.elapsed().expect("should measure"); + assert!( + elapsed >= Duration::from_millis(300), + "should overlap the mutation" + ); + format!("{:?}", outcome.expect_err("should reject a changed root")) +} + +#[test] +fn immutable_and_tool_mutation_during_run_is_detected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let error = mutate_during_run(&fixture, &proof, fixture.immutable.join("src/a.txt")); + assert!( + error.contains("changed"), + "should report the immutable change" + ); + + let fixture = Fixture::new(); + let proof = fixture.prove(); + let tool = fixture + .probe + .program() + .parent() + .expect("should have tool root") + .join("extra-tool"); + let error = mutate_during_run(&fixture, &proof, tool); + assert!( + error.contains("changed"), + "should report the tool-root change" + ); +} + +#[test] +fn unsafe_writable_alias_left_by_a_run_is_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let writable = fixture.writable.clone(); + let creator = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + symlink("/etc", writable.join("absolute-alias")).expect("should create alias"); + }); + let outcome = fixture + .boundary + .run_confined(&fixture.command("sleep", &["1000"]), &proof); + creator.join().expect("should join creator"); + let error = outcome.expect_err("should reject an unsafe alias after the run"); + assert!( + format!("{error:?}").contains("absolute"), + "should name the alias" + ); +} + +#[test] +fn proof_is_bound_to_its_instance() { + let first = Fixture::new(); + let proof = first.prove(); + let twin = Boundary::mac_sandbox_for_tests(first.boundary.roots.clone()) + .expect("should configure an identical twin"); + let error = twin + .run_confined(&first.command("sleep", &["0"]), &proof) + .expect_err("should reject a proof from another instance"); + assert!( + format!("{error:?}").contains("different boundary"), + "should explain" + ); + let result = first + .boundary + .run_confined(&first.command("sleep", &["0"]), &proof) + .expect("should accept its own proof"); + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should run" + ); +} + +#[test] +fn programs_outside_the_roots_are_rejected() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let command = ConfinedCommand::new(Path::new("/usr/bin/true"), &fixture.writable, limits()) + .expect("should describe command"); + assert!( + fixture.boundary.run_confined(&command, &proof).is_err(), + "should reject a program outside every root" + ); +} diff --git a/crates/trusted-server-release/src/boundary/tests/modes.rs b/crates/trusted-server-release/src/boundary/tests/modes.rs new file mode 100644 index 0000000000..6330cb7b64 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/modes.rs @@ -0,0 +1,269 @@ +//! Helper child modes run by [`helper_entry`](super::helper_entry). +//! +//! Every mode prints one report line after the sentinel: a success word, or +//! `denied errno=` with the operating system's real errno (exit 3). + +use std::io::Write as _; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::os::unix::process::CommandExt as _; +use std::process::Stdio; + +use super::*; + +/// Prints `line` after the sentinel and returns `code`. +fn report(line: &str, code: i32) -> i32 { + let mut stdout = std::io::stdout(); + let mut bytes = SENTINEL.to_vec(); + bytes.extend_from_slice(line.as_bytes()); + bytes.push(b'\n'); + if stdout + .write_all(&bytes) + .and_then(|()| stdout.flush()) + .is_err() + { + return HELPER_SETUP_FAILED; + } + code +} + +/// Reports `success`, or the real errno with the denied status. +fn report_errno(success: &str, errno: Option) -> i32 { + match errno { + None => report(success, 0), + Some(errno) => report(&format!("denied errno={errno}"), i32::from(DENIED_STATUS)), + } +} + +/// Returns the errno of a failed libc call (`rc < 0`). +fn call_errno(rc: libc::c_int) -> Option { + (rc < 0).then(|| std::io::Error::last_os_error().raw_os_error().unwrap_or(-1)) +} + +/// Runs `mode` with its `arguments`, returning the exit status. +pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { + let argument = |index: usize| arguments.get(index).map(String::as_str).unwrap_or_default(); + match mode { + "connect" => connect(argument(0)), + "nest" => nest(argument(0), argument(1), argument(2)), + "write" => report_errno( + "written", + std::fs::write(argument(0), b"tampered") + .err() + .map(|error| error.raw_os_error().unwrap_or(-1)), + ), + "signal" => signal(argument(0), argument(1)), + "signal-own-child" => signal_own_child(), + "sleep" => { + std::thread::sleep(Duration::from_millis(argument(0).parse().unwrap_or(0))); + 0 + } + // SAFETY: `setsid` and `setpgid` have no memory-safety preconditions. + "setsid" => report_errno("detached", call_errno(unsafe { libc::setsid() })), + // SAFETY: as above. + "setpgid" => report_errno("detached", call_errno(unsafe { libc::setpgid(0, 0) })), + "orphan" => leave_child(argument(0), Departure::SameGroup), + "escape" => leave_child( + argument(0), + Departure::NewGroup { + scrub: argument(1) == "scrub", + }, + ), + "unix-exchange" => unix_exchange(argument(0)), + "socket" => socket(argument(0)), + _ => HELPER_SETUP_FAILED, + } +} + +fn connect(address: &str) -> i32 { + let Ok(address) = parse_loopback(address) else { + return HELPER_SETUP_FAILED; + }; + match connect_probe(address) { + Ok(ProbeReport::Connected) => report("connected", 0), + Ok(ProbeReport::Denied { errno }) => report_errno("", Some(errno)), + Err(_) => HELPER_SETUP_FAILED, + } +} + +/// Like [`helper_environment`] but reusing the inherited nonce. +fn inherited_helper_environment(mode: &str) -> Vec<(OsString, OsString)> { + let inherited = std::env::var(HELPER_NONCE).unwrap_or_default(); + vec![ + ( + OsString::from(HELPER_SELECTOR), + OsString::from(format!("{mode}:{inherited}")), + ), + (OsString::from(HELPER_NONCE), OsString::from(inherited)), + ] +} + +/// Returns a command re-running this helper in `mode`. +fn self_command(mode: &str, mode_arguments: &[&str]) -> Option { + let mut command = StdCommand::new(std::env::current_exe().ok()?); + command.args(helper_arguments(mode_arguments)); + for (name, value) in inherited_helper_environment(mode) { + command.env(name, value); + } + Some(command) +} + +/// Records a marker in `marker_directory`, then connects directly at depth +/// zero or through a child helper at depth `n - 1`. +fn nest(depth: &str, address: &str, marker_directory: &str) -> i32 { + let Ok(depth) = depth.parse::() else { + return HELPER_SETUP_FAILED; + }; + let marker = Path::new(marker_directory).join(format!("nest-{depth}")); + if std::fs::write(marker, std::process::id().to_string()).is_err() { + return HELPER_SETUP_FAILED; + } + if depth == 0 { + return connect(address); + } + let next = (depth - 1).to_string(); + let Some(mut child) = self_command("nest", &[&next, address, marker_directory]) + .and_then(|mut command| command.spawn().ok()) + else { + return HELPER_SETUP_FAILED; + }; + child + .wait() + .ok() + .and_then(|status| status.code()) + .unwrap_or(HELPER_SETUP_FAILED) +} + +fn kill_errno(pid: libc::pid_t, signal: libc::c_int) -> Option { + // SAFETY: `kill` has no memory-safety preconditions; callers never pass + // a pid <= 1. + call_errno(unsafe { libc::kill(pid, signal) }) +} + +fn signal(pid: &str, signal: &str) -> i32 { + let (Ok(pid), Ok(signal)) = (pid.parse::(), signal.parse::()) else { + return HELPER_SETUP_FAILED; + }; + if pid <= 1 { + return HELPER_SETUP_FAILED; + } + report_errno("signalled", kill_errno(pid, signal)) +} + +fn signal_own_child() -> i32 { + let Some(mut child) = + self_command("sleep", &["30000"]).and_then(|mut command| command.spawn().ok()) + else { + return HELPER_SETUP_FAILED; + }; + let Ok(pid) = libc::pid_t::try_from(child.id()) else { + return HELPER_SETUP_FAILED; + }; + let error = kill_errno(pid, libc::SIGKILL); + if error.is_some() { + let _ = child.kill(); + } + let _ = child.wait(); + report_errno("signalled", error) +} + +/// How a helper's child leaves it. +#[derive(Clone, Copy)] +enum Departure { + /// A daemon-style orphan that stays in the supervised process group. + SameGroup, + /// A `posix_spawn(POSIX_SPAWN_SETPGROUP)` child in a new group, keeping + /// or scrubbing the inherited environment. + NewGroup { scrub: bool }, +} + +/// Spawns a sleeping child with null stdio, records its pid and exits +/// without waiting for it. +fn leave_child(pid_file: &str, departure: Departure) -> i32 { + let Some(mut command) = self_command("sleep", &["30000"]) else { + return HELPER_SETUP_FAILED; + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Departure::NewGroup { scrub } = departure { + command.process_group(0); + if scrub { + command.env_clear(); + for (name, value) in inherited_helper_environment("sleep") { + command.env(name, value); + } + } + } + let Ok(child) = command.spawn() else { + return HELPER_SETUP_FAILED; + }; + if std::fs::write(pid_file, child.id().to_string()).is_err() { + return HELPER_SETUP_FAILED; + } + report("spawned", 0) +} + +/// Binds a Unix socket at `path`, connects to it and echoes four bytes. +fn unix_exchange(path: &str) -> i32 { + let errno = |error: std::io::Error| Some(error.raw_os_error().unwrap_or(-1)); + let listener = match UnixListener::bind(path) { + Ok(listener) => listener, + Err(error) => return report_errno("", errno(error)), + }; + let server = std::thread::spawn(move || -> std::io::Result<()> { + let (mut stream, _) = listener.accept()?; + let mut bytes = [0_u8; 4]; + std::io::Read::read_exact(&mut stream, &mut bytes)?; + stream.write_all(&bytes) + }); + let exchanged = UnixStream::connect(path).and_then(|mut stream| { + stream.write_all(b"ping")?; + let mut bytes = [0_u8; 4]; + std::io::Read::read_exact(&mut stream, &mut bytes)?; + Ok(bytes) + }); + // The socket file is removed so the run leaves no special file behind + // in the writable root; the helper `_exit`s, so an unjoined server + // thread blocked in `accept` cannot hang it. + let removed = std::fs::remove_file(path).is_ok(); + let bytes = match exchanged { + Ok(bytes) => bytes, + Err(error) => return report_errno("", errno(error)), + }; + match server.join() { + Ok(Ok(())) if removed && &bytes == b"ping" => report("exchanged", 0), + _ => HELPER_SETUP_FAILED, + } +} + +/// Maps a fixture domain name to `(domain, type)`. +fn socket_kind(domain: &str) -> Option<(libc::c_int, libc::c_int)> { + match domain { + "unix" => Some((libc::AF_UNIX, libc::SOCK_STREAM)), + "inet" => Some((libc::AF_INET, libc::SOCK_STREAM)), + "inet6" => Some((libc::AF_INET6, libc::SOCK_STREAM)), + #[cfg(target_os = "linux")] + "netlink" => Some((libc::AF_NETLINK, libc::SOCK_RAW)), + #[cfg(target_os = "linux")] + "packet" => Some((libc::AF_PACKET, libc::SOCK_RAW)), + _ => None, + } +} + +/// Creates (and closes) one socket of `domain`. +fn socket(domain: &str) -> i32 { + let Some((domain, kind)) = socket_kind(domain) else { + return HELPER_SETUP_FAILED; + }; + // SAFETY: `socket` has no memory-safety preconditions. + let fd = unsafe { libc::socket(domain, kind, 0) }; + let errno = call_errno(fd); + if errno.is_none() { + // SAFETY: `fd` is a descriptor this helper just created. + unsafe { + libc::close(fd); + } + } + report_errno("created", errno) +} diff --git a/crates/trusted-server-release/src/boundary/tests/unit.rs b/crates/trusted-server-release/src/boundary/tests/unit.rs new file mode 100644 index 0000000000..323305c3e6 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/tests/unit.rs @@ -0,0 +1,481 @@ +//! Platform-independent boundary tests on real files and processes. + +use std::fs; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; + +use tempfile::{TempDir, tempdir}; + +use super::super::macos::{MacProfile, SYS_SETPGID, SYS_SETSID, define}; +use super::super::probe::LoopbackListener; +use super::super::roots::validate_path_text; +use super::*; +use crate::process::{BoundedCommand, ProcessOutcome}; + +pub(super) fn canonical_tempdir() -> (TempDir, PathBuf) { + let directory = tempdir().expect("should create temporary directory"); + let canonical = fs::canonicalize(directory.path()).expect("should canonicalize tempdir"); + (directory, canonical) +} + +fn error_text(report: &Report) -> String { + format!("{report:?}") +} + +// --------------------------------------------------------------------------- +// Probe protocol +// --------------------------------------------------------------------------- + +#[test] +fn probe_report_round_trips_and_rejects_malformed_lines() { + for report in [ProbeReport::Connected, ProbeReport::Denied { errno: 1 }] { + let line = format!("{report}\n"); + assert_eq!( + ProbeReport::parse_line(line.as_bytes()), + Some(report), + "should round-trip {report}" + ); + } + for malformed in [ + &b"connected"[..], + b"denied errno=\n", + b"denied errno=-1\n", + b"denied errno=1 \n", + b"connected\nconnected\n", + b"refused\n", + ] { + assert_eq!( + ProbeReport::parse_line(malformed), + None, + "should reject {malformed:?}" + ); + } +} + +#[test] +fn probe_address_must_be_literal_loopback() { + for accepted in ["127.0.0.1:80", "[::1]:443"] { + assert!(parse_loopback(accepted).is_ok(), "should accept {accepted}"); + } + for rejected in [ + "10.0.0.1:80", + "192.0.2.1:80", + "localhost:80", + "127.0.0.1:0", + "127.0.0.1", + ] { + assert!( + parse_loopback(rejected).is_err(), + "should reject {rejected}" + ); + } +} + +#[test] +fn unconfined_control_connects_and_listener_observes_accept() { + let listener = LoopbackListener::bind().expect("should bind listener"); + let probe = helper_probe(&test_binary()); + let mut command = BoundedCommand::new(probe.program(), Path::new("/"), limits()) + .expect("should describe control") + .arguments(probe.arguments(listener.address())); + for (name, value) in probe.environment() { + command = command.environment(name, value).expect("should set env"); + } + let result = command.run().expect("should run unconfined control"); + assert_eq!( + result.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should exit 0 when connected" + ); + assert_eq!( + probe.parse(result.stdout()), + Some(ProbeReport::Connected), + "should report connected" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 1, + "should observe exactly one accept" + ); +} + +// --------------------------------------------------------------------------- +// Roots, SBPL template and tree facts +// --------------------------------------------------------------------------- + +#[test] +fn path_text_rejects_profile_metacharacters() { + assert!(validate_path_text(Path::new("/private/tmp/ok-root")).is_ok()); + let nul = PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/a\0b")); + for rejected in [ + PathBuf::from("/tmp/quote\"root"), + PathBuf::from("/tmp/back\\slash"), + PathBuf::from("/tmp/new\nline"), + PathBuf::from("/tmp/tab\tchar"), + nul, + PathBuf::from("relative/root"), + PathBuf::from("/"), + ] { + assert!( + validate_path_text(&rejected).is_err(), + "should reject {rejected:?}" + ); + assert!( + define("WRITABLE_0", &rejected).is_err(), + "should reject {rejected:?} as an SBPL define" + ); + } +} + +#[test] +fn roots_reject_metacharacter_directories_and_overlap() { + let (_directory, base) = canonical_tempdir(); + for name in ["quote\"dir", "back\\dir", "new\ndir"] { + let path = base.join(name); + fs::create_dir(&path).expect("should create odd directory"); + assert!( + BoundaryRoots::new(&[], &[], &[path]).is_err(), + "should reject root named {name:?}" + ); + } + let outer = base.join("outer"); + let inner = outer.join("inner"); + fs::create_dir_all(&inner).expect("should create nested roots"); + assert!( + BoundaryRoots::new( + std::slice::from_ref(&inner), + &[], + std::slice::from_ref(&outer) + ) + .is_err(), + "should reject nested roots" + ); + assert!( + BoundaryRoots::new( + std::slice::from_ref(&outer), + &[], + std::slice::from_ref(&outer) + ) + .is_err(), + "should reject duplicate roots" + ); + assert!( + BoundaryRoots::new(&[outer], &[], &[]).is_err(), + "should require a writable root" + ); + let many: Vec = (0..=super::super::roots::MAX_ROOTS) + .map(|index| { + let path = base.join(format!("many-{index}")); + fs::create_dir(&path).expect("should create root"); + path + }) + .collect(); + assert!( + BoundaryRoots::new(&[], &[], &many).is_err(), + "should reject more roots than profile slots" + ); +} + +pub(super) fn sample_roots(base: &Path) -> BoundaryRoots { + let immutable = base.join("immutable"); + let writable = base.join("writable"); + let tool = base.join("tool"); + for path in [&immutable, &writable] { + fs::create_dir(path).expect("should create root"); + } + fs::write(&tool, b"tool").expect("should create tool"); + BoundaryRoots::new(&[immutable], &[tool], &[writable]).expect("should accept roots") +} + +#[test] +fn sbpl_profile_carries_paths_only_as_defines() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let profile = MacProfile::generate(&roots).expect("should generate profile"); + let base_text = base.to_str().expect("should be UTF-8"); + assert!( + !profile.text().contains(base_text), + "should never splice root paths into profile text" + ); + for required in [ + "(deny default)", + "(deny network*)", + "(allow signal (target same-sandbox))", + "(deny file-write* (subpath (param \"IMMUTABLE_0\")))", + "(allow file-read* file-write* process-exec (subpath (param \"WRITABLE_0\")))", + ] { + assert!( + profile.text().contains(required), + "should contain {required}" + ); + } + let names: Vec<&str> = profile + .defines() + .iter() + .map(|(name, _)| name.as_str()) + .collect(); + assert_eq!( + names, + ["IMMUTABLE_0", "TOOL_0", "WRITABLE_0"], + "should bind every slot" + ); + let arguments = profile.arguments(); + assert_eq!( + arguments.first(), + Some(&OsString::from("-p")), + "should pass text with -p" + ); + assert_eq!( + arguments + .iter() + .filter(|argument| *argument == "-D") + .count(), + 3, + "should pass one define per root" + ); +} + +#[test] +fn sbpl_fingerprint_depends_on_roots() { + let (_first_directory, first) = canonical_tempdir(); + let (_second_directory, second) = canonical_tempdir(); + let first = MacProfile::generate(&sample_roots(&first)).expect("should generate"); + let second = MacProfile::generate(&sample_roots(&second)).expect("should generate"); + assert_ne!( + first.fingerprint(), + second.fingerprint(), + "should bind root paths" + ); + assert_eq!( + first.fingerprint(), + first.clone().fingerprint(), + "should be stable" + ); +} + +#[test] +fn tree_scan_rejects_hard_links_and_unsafe_aliases() { + let (_directory, base) = canonical_tempdir(); + let root = base.join("root"); + fs::create_dir_all(root.join("sub")).expect("should create root"); + fs::write(root.join("sub/file.txt"), b"source").expect("should write file"); + symlink("sub/file.txt", root.join("inside")).expect("should create safe link"); + TreeFacts::scan(&root, ScanPolicy::Immutable).expect("should accept a safe tree"); + + let hard = base.join("outside-link"); + fs::hard_link(root.join("sub/file.txt"), &hard).expect("should hard link"); + let rejected = + TreeFacts::scan(&root, ScanPolicy::Immutable).expect_err("should reject nlink 2"); + assert!( + error_text(&rejected).contains("hard links"), + "should name the hard link" + ); + fs::remove_file(&hard).expect("should remove link"); + + for (name, target) in [ + ("absolute", "/etc/hosts"), + ("escaping", "../../outside"), + ("dangling", "sub/missing"), + ] { + let link = root.join(name); + symlink(target, &link).expect("should create link"); + for policy in [ScanPolicy::Immutable, ScanPolicy::AliasesOnly] { + assert!( + TreeFacts::scan(&root, policy).is_err(), + "should reject {name} alias under {policy:?}" + ); + } + fs::remove_file(&link).expect("should remove link"); + } + let fifo = root.join("fifo"); + let fifo_text = + std::ffi::CString::new(fifo.as_os_str().as_bytes()).expect("should be C string"); + // SAFETY: `fifo_text` is a valid NUL-terminated path. + assert_eq!( + unsafe { libc::mkfifo(fifo_text.as_ptr(), 0o600) }, + 0, + "should create fifo" + ); + assert!( + TreeFacts::scan(&root, ScanPolicy::AliasesOnly).is_err(), + "should reject special files" + ); +} + +#[test] +fn tree_facts_detect_content_mode_and_membership_changes() { + let (_directory, base) = canonical_tempdir(); + let file = base.join("file.txt"); + fs::write(&file, b"one").expect("should write file"); + let before = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan"); + assert!( + before + .ensure_unchanged(&TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan")) + .is_ok(), + "should accept an unchanged tree" + ); + let mutations: [&dyn Fn(); 3] = [ + &|| fs::write(&file, b"two").expect("should rewrite"), + &|| { + fs::set_permissions(&file, fs::Permissions::from_mode(0o400)).expect("should chmod"); + }, + &|| fs::write(base.join("added"), b"x").expect("should add"), + ]; + for mutate in mutations { + let snapshot = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan"); + mutate(); + let after = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should rescan"); + assert!( + snapshot.ensure_unchanged(&after).is_err(), + "should detect mutation" + ); + } + let snapshot = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should scan"); + fs::remove_file(base.join("added")).expect("should remove"); + let after = TreeFacts::scan(&base, ScanPolicy::Immutable).expect("should rescan"); + assert!( + snapshot.ensure_unchanged(&after).is_err(), + "should detect removal" + ); +} + +#[test] +fn confined_command_environment_rejects_injection_and_duplicates() { + let command = ConfinedCommand::new(Path::new("/bin/tool"), Path::new("/work"), limits()) + .expect("should describe command"); + let command = command + .environment("HOME", "/work") + .expect("should accept HOME"); + assert!( + ConfinedCommand::new(Path::new("/bin/tool"), Path::new("/work"), limits()) + .expect("should describe command") + .environment("LD_PRELOAD", "/x.so") + .is_err(), + "should reject injection variables" + ); + assert!( + command.environment("HOME", "/other").is_err(), + "should reject duplicates" + ); + assert!( + ConfinedCommand::new(Path::new("relative"), Path::new("/work"), limits()).is_err(), + "should reject a relative program" + ); +} + +#[test] +fn macos_boundary_is_test_only_until_qualified() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + assert_eq!( + Boundary::mac_sandbox_for_tests(roots).is_ok(), + cfg!(target_os = "macos"), + "should allow sandbox-exec only on macOS" + ); +} + +#[test] +fn instance_fingerprints_differ_for_identical_configurations() { + let platform = [9; 32]; + let first = instance_fingerprint(&platform).expect("should draw a nonce"); + let second = instance_fingerprint(&platform).expect("should draw a nonce"); + assert_ne!( + first, second, + "should bind a fresh nonce into every instance" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn boundary_instances_never_share_a_fingerprint() { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let first = Boundary::mac_sandbox_for_tests(roots.clone()).expect("should configure"); + let second = Boundary::mac_sandbox_for_tests(roots).expect("should configure"); + assert_ne!( + first.fingerprint, second.fingerprint, + "should bind a fresh nonce into every instance" + ); +} + +#[test] +fn confined_paths_reject_dot_components() { + for (program, working_directory) in [ + ("/tools/../bin/tool", "/work"), + ("/tools/./tool", "/work"), + ("/tools/tool", "/work/../etc"), + ("/tools/tool", "/work/."), + ("tools/tool", "/work"), + ] { + assert!( + ConfinedCommand::new(Path::new(program), Path::new(working_directory), limits()) + .is_err(), + "should reject {program} in {working_directory}" + ); + } +} + +#[test] +fn tool_roots_require_single_link_files_and_staging_provides_them() { + let (_directory, base) = canonical_tempdir(); + let tools = base.join("tools"); + fs::create_dir(&tools).expect("should create tools"); + let staged = staged_probe(&tools); + let metadata = fs::metadata(staged.program()).expect("should stat staged probe"); + assert_eq!(metadata.nlink(), 1, "should stage a single-link copy"); + assert_eq!(metadata.mode() & 0o777, 0o555, "should stage read-only"); + TreeFacts::scan(&tools, ScanPolicy::Immutable).expect("should accept the staged tool"); + TreeFacts::scan(staged.program(), ScanPolicy::Immutable).expect("should scan a file root"); + assert!( + helper_probe(&test_binary()).staged(&tools).is_err(), + "should refuse to overwrite a staged tool" + ); + fs::hard_link(staged.program(), base.join("alias")).expect("should hard link"); + assert!( + TreeFacts::scan(&tools, ScanPolicy::Immutable).is_err(), + "should reject a hard-linked tool" + ); + assert!( + TreeFacts::scan(staged.program(), ScanPolicy::Immutable).is_err(), + "should reject a hard-linked file root" + ); +} + +#[test] +fn writable_scans_reject_hard_links_before_runs() { + let (_directory, base) = canonical_tempdir(); + let writable = base.join("writable"); + fs::create_dir(&writable).expect("should create writable root"); + fs::write(writable.join("file"), b"x").expect("should write"); + TreeFacts::scan(&writable, ScanPolicy::SingleLinks).expect("should accept single links"); + fs::hard_link(writable.join("file"), base.join("outside")).expect("should hard link"); + assert!( + TreeFacts::scan(&writable, ScanPolicy::SingleLinks).is_err(), + "should reject a hard link before a run" + ); + TreeFacts::scan(&writable, ScanPolicy::AliasesOnly).expect("should allow links after a run"); +} + +#[test] +fn sbpl_profile_denies_group_escape_and_scopes_unix_sockets() { + let (_directory, base) = canonical_tempdir(); + let profile = MacProfile::generate(&sample_roots(&base)).expect("should generate"); + let text = profile.text(); + let syscalls = format!("(deny syscall-unix (syscall-number {SYS_SETSID} {SYS_SETPGID}))"); + assert!(text.contains(&syscalls), "should deny setsid and setpgid"); + let network = text.find("(deny network*)").expect("should deny network"); + let unix = text + .find("(local unix-socket (subpath (param \"WRITABLE_0\")))") + .expect("should allow Unix sockets under the writable root"); + assert!( + unix > network, + "should re-allow only Unix sockets after the denial" + ); + assert!( + !text.contains("unix-socket (subpath (param \"IMMUTABLE_0\"))"), + "should not allow Unix sockets in immutable roots" + ); + for absent in ["(subpath \"/usr\")", "/usr/local"] { + assert!(!text.contains(absent), "should not allow {absent}"); + } +} diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index cc697b4275..7f59ef7df7 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -18,8 +18,14 @@ pub(crate) enum ReleaseError { Unqualified, #[display("Release inputs are incomplete")] Incomplete, + #[display("Final artifact inspection rejected the executable")] + Inspect, + #[display("Original release material is not verified")] + Material, #[display("Bounded release child process failed")] Process, + #[display("Release network-denial boundary rejected the execution")] + Boundary, } impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/inspect.rs b/crates/trusted-server-release/src/inspect.rs new file mode 100644 index 0000000000..8f1b7051e2 --- /dev/null +++ b/crates/trusted-server-release/src/inspect.rs @@ -0,0 +1,378 @@ +//! Pure inspection of final release executables. +//! +//! Each inspector reads only the final bytes of one artifact: it never runs +//! the artifact, rebuilds it or consults the build environment. A successful +//! inspection returns facts that record what was observed — loader, libraries, +//! version floors, Wasm import modules and the SHA-256 of the inspected bytes — +//! for later build evidence. Facts make no claim beyond that observation; +//! startup and runtime behaviour belong to the recipe and qualification +//! fixtures. +//! +//! Every inspector fails closed: an unsupported container, a truncated or +//! malformed structure, or any value outside the compiled-in policy rejects +//! the artifact with [`ReleaseError::Inspect`] and an attached [`Violation`] +//! naming the rule. +//! +//! - [`inspect_elf`]: `x86_64-unknown-linux-gnu` executables against the +//! glibc 2.34 loader floor. +//! - [`inspect_macho`]: thin 64-bit macOS executables against the macOS 13.0 +//! deployment target. +//! - [`inspect_wasm`]: Fastly Compute core modules. + +mod elf; +mod macho; +mod wasm; + +use std::fmt; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; + +// Only the tests consume the inspectors until the Task 9 recipes call them. +#[allow(unused_imports, reason = "consumed by Task 9 recipes")] +pub(crate) use self::{ + elf::{ElfPolicy, inspect_elf}, + macho::{MachoCpu, MachoPolicy, inspect_macho}, + wasm::inspect_wasm, +}; + +/// Absolute path prefixes that only exist on a build host or runner. +/// +/// A final executable embedding any of them leaks the build environment +/// (unremapped source, home, temporary or workspace paths). The scan is +/// deliberately conservative: it matches anywhere in the file, not only in +/// loader strings, and a reviewed exception requires changing this list. +/// +/// Matches are substrings, not path starts: Rust panic locations sit in +/// unterminated rodata, so the byte before a path is arbitrary. Entries are +/// therefore specific enough not to match ordinary crate-relative paths; a +/// bare `/private/` would match serde's `src/private/de.rs`. +pub(crate) const RUNNER_PATH_PREFIXES: &[&str] = &[ + "/home/", + "/Users/", + "/root/", + "/private/var/", + "/private/tmp/", + "/var/folders/", + "/tmp/", + "/github/workspace/", + "/__w/", +]; + +/// Maximum number of embedded-path offsets reported in a rejection. +const REPORTED_PATH_HITS: usize = 16; + +/// The inspection rule an artifact violated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum Violation { + /// The bytes are truncated, malformed or not the expected container. + #[display("malformed or unsupported executable container")] + Malformed, + /// The container is valid but the class, CPU or file type is not the + /// selected target's. + #[display("executable architecture or file type does not match the target")] + Architecture, + /// The program interpreter or dynamic linker is missing or unexpected. + #[display("program interpreter is missing or unexpected")] + Interpreter, + /// A linked library is outside the reviewed allowlist. + #[display("linked library is outside the reviewed allowlist")] + Library, + /// A library link uses a kind other than a plain required load. + #[display("library link kind is not a plain required load")] + LinkKind, + /// A symbol version requirement is unparsable, unexpected or above the + /// compatibility ceiling. + #[display("symbol version requirement is unparsable or exceeds the ceiling")] + SymbolVersion, + /// The executable carries a runtime library search path or loader + /// environment. + #[display("executable carries a runtime library search path")] + SearchPath, + /// The executable embeds a runner-only absolute path. + #[display("executable embeds a runner-only absolute path")] + EmbeddedPath, + /// The deployment target is missing, ambiguous or not the selected floor. + #[display("deployment target is missing or does not match the floor")] + DeploymentTarget, + /// The bytes are a Wasm component or not Wasm at all. + #[display("artifact is not a core WebAssembly module")] + WasmEncoding, + /// The core module fails validation under the pinned feature set. + #[display("core WebAssembly module fails validation")] + WasmValidation, + /// The module imports from an unreviewed module or a non-function item. + #[display("WebAssembly import is outside the reviewed host ABI")] + WasmImport, +} + +/// Builds the inspection rejection carrying `violation` and a detail line. +fn rejected(violation: Violation, detail: impl fmt::Display) -> Report { + Report::new(ReleaseError::Inspect) + .attach(violation) + .attach(detail.to_string()) +} + +/// Maps a parser error onto [`Violation::Malformed`]. +fn malformed(context: &str, error: impl fmt::Display) -> Report { + rejected(Violation::Malformed, format!("{context}: {error}")) +} + +/// Returns the lowercase hex SHA-256 of `bytes`. +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Decodes a loader string, rejecting non-UTF-8 bytes. +fn utf8<'a>(bytes: &'a [u8], context: &str) -> Result<&'a str, Report> { + std::str::from_utf8(bytes).map_err(|error| malformed(context, error)) +} + +/// One occurrence of a denied path prefix in the inspected bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PathHit { + prefix: &'static str, + offset: usize, +} + +/// Finds every occurrence of any `prefixes` entry anywhere in `bytes`. +/// +/// # Performance +/// +/// Candidates are found by scanning for `/` only, so the cost is one pass +/// plus a short comparison per slash rather than one window per prefix. +fn find_denied_paths(bytes: &[u8], prefixes: &[&'static str]) -> Vec { + bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'/') + .filter_map(|(offset, _)| { + prefixes + .iter() + .find(|prefix| bytes[offset..].starts_with(prefix.as_bytes())) + .map(|prefix| PathHit { prefix, offset }) + }) + .collect() +} + +/// Rejects `bytes` if they embed any denied path prefix. +/// +/// # Errors +/// +/// Returns [`Violation::EmbeddedPath`] listing the match count and the first +/// [`REPORTED_PATH_HITS`] prefix/offset pairs. +fn reject_denied_paths( + bytes: &[u8], + prefixes: &[&'static str], +) -> Result<(), Report> { + let hits = find_denied_paths(bytes, prefixes); + if hits.is_empty() { + return Ok(()); + } + let shown = hits + .iter() + .take(REPORTED_PATH_HITS) + .map(|hit| format!("{}@{:#x}", hit.prefix, hit.offset)) + .collect::>() + .join(", "); + Err(rejected( + Violation::EmbeddedPath, + format!("{} runner-only path occurrence(s): {shown}", hits.len()), + )) +} + +/// A numeric `GLIBC_x.y[.z]` symbol version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct GlibcVersion { + major: u32, + minor: u32, + patch: u32, +} + +impl GlibcVersion { + /// Creates `major.minor`. + pub(crate) const fn new(major: u32, minor: u32) -> Self { + Self { + major, + minor, + patch: 0, + } + } + + /// Parses `x.y` or `x.y.z` made of plain decimal components. + /// + /// Signs, empty components, leading zeros and more than three + /// components are rejected, so names such as `GLIBC_PRIVATE` or + /// `GLIBC_ABI_DT_RELR` never compare as versions. + pub(crate) fn parse(text: &str) -> Option { + let parts = text.split('.').map(decimal).collect::>>()?; + match parts.as_slice() { + [major, minor] => Some(Self::new(*major, *minor)), + [major, minor, patch] => Some(Self { + major: *major, + minor: *minor, + patch: *patch, + }), + _ => None, + } + } +} + +impl fmt::Display for GlibcVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}.{}", self.major, self.minor)?; + if self.patch != 0 { + write!(formatter, ".{}", self.patch)?; + } + Ok(()) + } +} + +/// Parses one canonical unsigned decimal component. +fn decimal(text: &str) -> Option { + let canonical = !text.is_empty() + && text.bytes().all(|byte| byte.is_ascii_digit()) + && (text == "0" || !text.starts_with('0')); + if canonical { text.parse().ok() } else { None } +} + +/// One `(library, version name)` pair from `.gnu.version_r`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct VersionRequirement { + file: String, + name: String, +} + +/// Facts observed in an accepted ELF executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ElfFacts { + sha256: String, + interpreter: String, + needed: Vec, + version_requirements: Vec, + max_glibc: GlibcVersion, +} + +impl ElfFacts { + /// Lowercase hex SHA-256 of the inspected bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Observed `PT_INTERP` path. + pub(crate) fn interpreter(&self) -> &str { + &self.interpreter + } + + /// Observed `DT_NEEDED` entries in file order. + pub(crate) fn needed(&self) -> &[String] { + &self.needed + } + + /// Observed `.gnu.version_r` requirements, sorted. + pub(crate) fn version_requirements(&self) -> &[VersionRequirement] { + &self.version_requirements + } + + /// Highest observed `GLIBC_` requirement. + pub(crate) fn max_glibc(&self) -> GlibcVersion { + self.max_glibc + } +} + +/// A macOS `X.Y.Z` version as encoded in `LC_BUILD_VERSION`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, derive_more::Display)] +#[display("{major}.{minor}.{patch}")] +pub(crate) struct MacosVersion { + major: u16, + minor: u8, + patch: u8, +} + +impl MacosVersion { + /// Creates `major.minor.0`. + pub(crate) const fn new(major: u16, minor: u8) -> Self { + Self { + major, + minor, + patch: 0, + } + } + + /// Decodes the `xxxx.yy.zz` nibble encoding. + fn from_encoded(encoded: u32) -> Self { + let [high, low, minor, patch] = encoded.to_be_bytes(); + Self { + major: u16::from_be_bytes([high, low]), + minor, + patch, + } + } +} + +/// Facts observed in an accepted Mach-O executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MachoFacts { + sha256: String, + cpu: MachoCpu, + minos: MacosVersion, + sdk: MacosVersion, + dylibs: Vec, +} + +impl MachoFacts { + /// Lowercase hex SHA-256 of the inspected bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Observed CPU type. + pub(crate) fn cpu(&self) -> MachoCpu { + self.cpu + } + + /// Observed `LC_BUILD_VERSION` minimum OS version. + pub(crate) fn minos(&self) -> MacosVersion { + self.minos + } + + /// Observed `LC_BUILD_VERSION` SDK version. + pub(crate) fn sdk(&self) -> MacosVersion { + self.sdk + } + + /// Observed `LC_LOAD_DYLIB` install names in load-command order. + pub(crate) fn dylibs(&self) -> &[String] { + &self.dylibs + } +} + +/// Facts observed in an accepted core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmFacts { + sha256: String, + import_modules: Vec, + function_imports: usize, +} + +impl WasmFacts { + /// Lowercase hex SHA-256 of the inspected bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Distinct import module names, sorted. + pub(crate) fn import_modules(&self) -> &[String] { + &self.import_modules + } + + /// Total number of imported functions. + pub(crate) fn function_imports(&self) -> usize { + self.function_imports + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/inspect/elf.rs b/crates/trusted-server-release/src/inspect/elf.rs new file mode 100644 index 0000000000..0d042d1c29 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/elf.rs @@ -0,0 +1,404 @@ +//! ELF inspection for `x86_64-unknown-linux-gnu` executables. +//! +//! Section observations are accepted only after matching the loader's +//! dynamic addresses to their actual file-backed `PT_LOAD` ranges. + +mod loader; + +use std::collections::BTreeSet; + +use error_stack::Report; +use object::Endianness; +use object::elf::{self as format, FileHeader64}; +use object::read::elf::{Dyn as _, FileHeader as _, ProgramHeader as _, SectionHeader as _}; +use object::read::{SectionIndex, StringTable}; + +use super::{ + ElfFacts, GlibcVersion, RUNNER_PATH_PREFIXES, VersionRequirement, Violation, malformed, + reject_denied_paths, rejected, sha256_hex, utf8, +}; +use crate::error::ReleaseError; + +type Header = FileHeader64; +type Sections<'data> = object::read::elf::SectionTable<'data, Header, &'data [u8]>; + +/// Version-name prefix whose suffix is compared to the glibc ceiling. +const GLIBC_PREFIX: &str = "GLIBC_"; + +/// Reviewed `x86_64-unknown-linux-gnu` loader. +const LINUX_X86_64_INTERPRETER: &str = "/lib64/ld-linux-x86-64.so.2"; + +/// Reviewed `DT_NEEDED` set for the `ts` CLI. +/// +/// Rust std on `linux-gnu` links libc, libm, `libgcc_s` (unwinding) and the +/// loader (`__tls_get_addr`). From glibc 2.34 `libdl`, `libpthread` and +/// `librt` are merged into libc, so they are deliberately absent: a builder +/// that still emits them is not the qualified floor. +const LINUX_X86_64_NEEDED: &[&str] = &[ + "ld-linux-x86-64.so.2", + "libc.so.6", + "libgcc_s.so.1", + "libm.so.6", +]; + +/// Version-name prefixes accepted per library, besides `GLIBC_`. +/// +/// `libgcc_s` versions its unwinder as `GCC_x.y[.z]`; those carry no glibc +/// floor and are recorded only. +const OTHER_VERSION_PREFIXES: &[(&str, &str)] = &[("libgcc_s.so.1", "GCC_")]; + +/// Compiled-in ELF acceptance policy. +#[derive(Debug, Clone)] +pub(crate) struct ElfPolicy { + pub(crate) interpreter: &'static str, + pub(crate) allowed_needed: &'static [&'static str], + pub(crate) glibc_maximum: GlibcVersion, + pub(crate) denied_path_prefixes: &'static [&'static str], +} + +impl ElfPolicy { + /// The reviewed `x86_64-unknown-linux-gnu` policy: glibc 2.34 ceiling. + pub(crate) const fn linux_x86_64_gnu() -> Self { + Self { + interpreter: LINUX_X86_64_INTERPRETER, + allowed_needed: LINUX_X86_64_NEEDED, + glibc_maximum: GlibcVersion::new(2, 34), + denied_path_prefixes: RUNNER_PATH_PREFIXES, + } + } +} + +/// Inspects a final ELF executable against `policy`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] with a [`Violation`] when the bytes are +/// not a little-endian 64-bit `x86_64` executable, the interpreter differs, +/// a `DT_NEEDED` entry is outside the allowlist, `DT_RPATH`/`DT_RUNPATH` (or +/// a filter) is present, a version requirement is unparsable or above the +/// glibc ceiling, or a runner-only path is embedded anywhere in the file. +pub(crate) fn inspect_elf( + bytes: &[u8], + policy: &ElfPolicy, +) -> Result> { + let header = Header::parse(bytes).map_err(|error| malformed("ELF header", error))?; + let endian = header + .endian() + .map_err(|error| malformed("ELF endianness", error))?; + check_identity(header, endian)?; + let interpreter = interpreter(header, endian, bytes)?; + if interpreter != policy.interpreter { + return Err(rejected( + Violation::Interpreter, + format!( + "PT_INTERP is {interpreter:?}, expected {:?}", + policy.interpreter + ), + )); + } + let sections = header + .sections(endian, bytes) + .map_err(|error| malformed("ELF section table", error))?; + loader::validate(header, endian, bytes, §ions)?; + let needed = dynamic_needed(header, endian, bytes, §ions)?; + if let Some(name) = needed + .iter() + .find(|name| !policy.allowed_needed.contains(&name.as_str())) + { + return Err(rejected(Violation::Library, format!("DT_NEEDED {name:?}"))); + } + let version_requirements = version_requirements(endian, bytes, §ions)?; + let max_glibc = check_versions(&version_requirements, &needed, policy.glibc_maximum)?; + reject_denied_paths(bytes, policy.denied_path_prefixes)?; + Ok(ElfFacts { + sha256: sha256_hex(bytes), + interpreter: interpreter.to_owned(), + needed, + version_requirements, + max_glibc, + }) +} + +/// Requires a little-endian `x86_64` `ET_EXEC`/`ET_DYN` file. +fn check_identity(header: &Header, endian: Endianness) -> Result<(), Report> { + let ident = header.e_ident(); + let machine = header.e_machine(endian); + let kind = header.e_type(endian); + if ident.data != format::ELFDATA2LSB + || machine != format::EM_X86_64 + || !matches!(kind, format::ET_EXEC | format::ET_DYN) + { + return Err(rejected( + Violation::Architecture, + format!( + "ELF data {} machine {machine} type {kind}; expected little-endian x86_64 \ + executable", + ident.data + ), + )); + } + Ok(()) +} + +/// Returns the single `PT_INTERP` path. +fn interpreter<'data>( + header: &Header, + endian: Endianness, + bytes: &'data [u8], +) -> Result<&'data str, Report> { + let headers = header + .program_headers(endian, bytes) + .map_err(|error| malformed("ELF program headers", error))?; + let mut found = Vec::new(); + for segment in headers { + if let Some(path) = segment + .interpreter(endian, bytes) + .map_err(|error| malformed("ELF PT_INTERP", error))? + { + found.push(path); + } + } + match found.as_slice() { + [path] => utf8(path, "ELF PT_INTERP"), + _ => Err(rejected( + Violation::Interpreter, + format!("expected exactly one PT_INTERP, found {}", found.len()), + )), + } +} + +/// Returns `DT_NEEDED` names after cross-checking the dynamic section. +fn dynamic_needed( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let segment = single_dynamic_segment(header, endian, bytes)?; + let mut dynamic_sections = sections + .iter() + .filter(|section| section.sh_type(endian) == format::SHT_DYNAMIC); + let (Some(section), None) = (dynamic_sections.next(), dynamic_sections.next()) else { + return Err(malformed( + "ELF dynamic section", + "expected exactly one SHT_DYNAMIC", + )); + }; + if (section.sh_offset(endian), section.sh_size(endian)) != segment { + return Err(malformed( + "ELF dynamic section", + "does not match PT_DYNAMIC", + )); + } + let (entries, link) = section + .dynamic(endian, bytes) + .map_err(|error| malformed("ELF dynamic entries", error))? + .ok_or_else(|| malformed("ELF dynamic entries", "missing"))?; + let strings = linked_strings(endian, bytes, sections, link)?; + let strtab_address = sections + .section(link) + .map_err(|error| malformed("ELF dynamic string table", error))? + .sh_addr(endian); + read_dynamic_entries(entries, endian, strings, strtab_address) +} + +/// Returns `(p_offset, p_filesz)` of the single `PT_DYNAMIC` segment. +fn single_dynamic_segment( + header: &Header, + endian: Endianness, + bytes: &[u8], +) -> Result<(u64, u64), Report> { + let headers = header + .program_headers(endian, bytes) + .map_err(|error| malformed("ELF program headers", error))?; + let mut dynamic = headers + .iter() + .filter(|segment| segment.p_type(endian) == format::PT_DYNAMIC); + match (dynamic.next(), dynamic.next()) { + (Some(segment), None) => Ok((segment.p_offset(endian), segment.p_filesz(endian))), + _ => Err(rejected( + Violation::Interpreter, + "expected exactly one PT_DYNAMIC: not a dynamically linked executable", + )), + } +} + +/// Reads the dynamic entries up to `DT_NULL`. +fn read_dynamic_entries( + entries: &[format::Dyn64], + endian: Endianness, + strings: StringTable<'_>, + strtab_address: u64, +) -> Result, Report> { + let mut needed = Vec::new(); + let mut strtab = None; + let mut terminated = false; + for entry in entries { + let tag = entry.tag32(endian); + match tag { + Some(format::DT_NULL) => { + terminated = true; + break; + } + Some(format::DT_STRTAB) => strtab = Some(entry.d_val(endian)), + Some(format::DT_NEEDED) => needed.push(dynamic_string(entry, endian, strings)?), + Some(format::DT_RPATH | format::DT_RUNPATH) => { + let path = dynamic_string(entry, endian, strings)?; + return Err(rejected( + Violation::SearchPath, + format!("DT_RPATH/DT_RUNPATH {path:?}"), + )); + } + Some( + format::DT_AUXILIARY | format::DT_FILTER | format::DT_AUDIT | format::DT_DEPAUDIT, + ) => { + return Err(rejected( + Violation::Library, + "additional loader library mechanism present", + )); + } + _ => {} + } + } + if !terminated || strtab != Some(strtab_address) { + return Err(malformed( + "ELF dynamic string table", + "DT_STRTAB does not match the linked section", + )); + } + Ok(needed) +} + +/// Decodes a string-valued dynamic entry. +fn dynamic_string( + entry: &format::Dyn64, + endian: Endianness, + strings: StringTable<'_>, +) -> Result> { + let raw = entry + .string(endian, strings) + .map_err(|error| malformed("ELF dynamic string", error))?; + utf8(raw, "ELF dynamic string").map(str::to_owned) +} + +/// Returns the string table linked from a section. +fn linked_strings<'data>( + endian: Endianness, + bytes: &'data [u8], + sections: &Sections<'data>, + link: SectionIndex, +) -> Result, Report> { + if link == SectionIndex(0) { + return Err(malformed("ELF string table", "missing link")); + } + sections + .strings(endian, bytes, link) + .map_err(|error| malformed("ELF string table", error)) +} + +/// Returns every `.gnu.version_r` requirement, sorted and de-duplicated. +fn version_requirements( + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let (mut needs, link) = sections + .gnu_verneed(endian, bytes) + .map_err(|error| malformed("ELF version requirements", error))? + .ok_or_else(|| { + rejected( + Violation::SymbolVersion, + "no .gnu.version_r: the glibc ceiling cannot be checked", + ) + })?; + let strings = linked_strings(endian, bytes, sections, link)?; + let mut found = BTreeSet::new(); + let parse_error = |error| malformed("ELF version requirement", error); + while let Some((need, mut auxiliaries)) = needs.next().map_err(parse_error)? { + let file = utf8( + need.file(endian, strings).map_err(parse_error)?, + "ELF vn_file", + )?; + while let Some(auxiliary) = auxiliaries.next().map_err(parse_error)? { + let name = utf8( + auxiliary.name(endian, strings).map_err(parse_error)?, + "ELF vna_name", + )?; + found.insert(VersionRequirement { + file: file.to_owned(), + name: name.to_owned(), + }); + } + } + Ok(found.into_iter().collect()) +} + +/// Checks every requirement and returns the highest `GLIBC_` version. +fn check_versions( + requirements: &[VersionRequirement], + needed: &[String], + maximum: GlibcVersion, +) -> Result> { + let mut highest = None; + for requirement in requirements { + if !needed.contains(&requirement.file) { + return Err(rejected( + Violation::SymbolVersion, + format!( + "version requirement on {:?}, which is not DT_NEEDED", + requirement.file + ), + )); + } + if let Some(version) = glibc_requirement(requirement)? { + if version > maximum { + return Err(rejected( + Violation::SymbolVersion, + format!( + "{} requires GLIBC_{version} > ceiling {maximum}", + requirement.file + ), + )); + } + highest = highest.max(Some(version)); + } + } + highest.ok_or_else(|| { + rejected( + Violation::SymbolVersion, + "no GLIBC_ requirement was observed", + ) + }) +} + +/// Classifies one requirement: a parsed `GLIBC_` version, an accepted +/// non-glibc name (`None`), or a rejection. +fn glibc_requirement( + requirement: &VersionRequirement, +) -> Result, Report> { + if let Some(suffix) = requirement.name.strip_prefix(GLIBC_PREFIX) { + return GlibcVersion::parse(suffix).map(Some).ok_or_else(|| { + rejected( + Violation::SymbolVersion, + format!( + "unparsable version {:?} from {:?}", + requirement.name, requirement.file + ), + ) + }); + } + let accepted = OTHER_VERSION_PREFIXES + .iter() + .any(|(file, prefix)| requirement.file == *file && requirement.name.starts_with(prefix)); + if accepted { + Ok(None) + } else { + Err(rejected( + Violation::SymbolVersion, + format!( + "unexpected version {:?} from {:?}", + requirement.name, requirement.file + ), + )) + } +} diff --git a/crates/trusted-server-release/src/inspect/elf/loader.rs b/crates/trusted-server-release/src/inspect/elf/loader.rs new file mode 100644 index 0000000000..024cf086ff --- /dev/null +++ b/crates/trusted-server-release/src/inspect/elf/loader.rs @@ -0,0 +1,299 @@ +//! Binds section observations to the bytes selected by the ELF loader. + +use std::collections::{BTreeMap, BTreeSet}; + +use error_stack::Report; +use object::Endianness; +use object::elf as format; +use object::read::elf::{Dyn as _, FileHeader as _, ProgramHeader as _, SectionHeader as _}; + +use super::{Header, Sections}; +use crate::error::ReleaseError; +use crate::inspect::{Violation, malformed, rejected}; + +type Section = format::SectionHeader64; + +fn invalid(detail: impl std::fmt::Display) -> Report { + malformed("ELF loader metadata", detail) +} + +fn unique_section<'a>( + sections: &'a Sections<'_>, + endian: Endianness, + kind: u32, +) -> Result<(usize, &'a Section), Report> { + let mut found = sections + .iter() + .enumerate() + .filter(|(_, section)| section.sh_type(endian) == kind); + match (found.next(), found.next()) { + (Some(section), None) => Ok(section), + _ => Err(invalid(format!("expected one section of type {kind}"))), + } +} + +/// Resolves an entire virtual range through exactly one file-backed load. +fn mapped_offset( + header: &Header, + endian: Endianness, + bytes: &[u8], + address: u64, + size: u64, +) -> Result> { + let end = address + .checked_add(size) + .ok_or_else(|| invalid("address overflow"))?; + let programs = header.program_headers(endian, bytes).map_err(invalid)?; + let mut offsets = Vec::new(); + for program in programs { + if program.p_type(endian) != format::PT_LOAD { + continue; + } + let start = program.p_vaddr(endian); + let limit = start + .checked_add(program.p_filesz(endian)) + .ok_or_else(|| invalid("segment overflow"))?; + let memory_end = start + .checked_add(program.p_memsz(endian)) + .ok_or_else(|| invalid("memory range overflow"))?; + // Linux x86_64 mappings include whole 4 KiB pages. A second segment + // can replace an earlier segment's bytes even before its nominal + // start, or zero-fill them after its file-backed portion. + let page_start = start & !4095; + let page_end = memory_end + .checked_add(4095) + .ok_or_else(|| invalid("page range overflow"))? + & !4095; + if program.p_filesz(endian) > program.p_memsz(endian) { + return Err(invalid("load file size exceeds its memory size")); + } + if page_start < end && page_end > address { + if address < start || end > limit { + return Err(invalid("load mapping partially overlaps observed metadata")); + } + let offset = program + .p_offset(endian) + .checked_add(address - start) + .ok_or_else(|| invalid("file offset overflow"))?; + let file_end = offset + .checked_add(size) + .ok_or_else(|| invalid("file range overflow"))?; + if file_end > bytes.len() as u64 || program.p_filesz(endian) > program.p_memsz(endian) { + return Err(invalid("load range exceeds file or memory")); + } + offsets.push(offset); + } + } + match offsets.as_slice() { + [offset] => Ok(*offset), + _ => Err(invalid( + "virtual range has no unique file-backed load mapping", + )), + } +} + +fn required(tags: &BTreeMap, tag: u32) -> Result> { + tags.get(&tag) + .copied() + .ok_or_else(|| invalid(format!("missing dynamic tag {tag}"))) +} + +fn dynamic_tags( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result, Report> { + let (_, section) = unique_section(sections, endian, format::SHT_DYNAMIC)?; + let programs = header.program_headers(endian, bytes).map_err(invalid)?; + let mut segments = programs + .iter() + .filter(|program| program.p_type(endian) == format::PT_DYNAMIC); + let (Some(segment), None) = (segments.next(), segments.next()) else { + return Err(invalid("expected one dynamic segment")); + }; + if mapped_offset( + header, + endian, + bytes, + segment.p_vaddr(endian), + segment.p_filesz(endian), + )? != section.sh_offset(endian) + || segment.p_offset(endian) != section.sh_offset(endian) + || segment.p_filesz(endian) != section.sh_size(endian) + { + return Err(invalid("dynamic section differs from loader range")); + } + let (entries, _) = section + .dynamic(endian, bytes) + .map_err(invalid)? + .ok_or_else(|| invalid("missing dynamic entries"))?; + let mut tags = BTreeMap::new(); + for entry in entries { + let tag = entry + .tag32(endian) + .ok_or_else(|| invalid("unsupported dynamic tag width"))?; + if tag == format::DT_NULL { + return Ok(tags); + } + if matches!( + tag, + format::DT_AUDIT | format::DT_DEPAUDIT | format::DT_AUXILIARY | format::DT_FILTER + ) { + return Err(rejected( + Violation::Library, + "additional loader library mechanism", + )); + } + if tag != format::DT_NEEDED && tags.insert(tag, entry.d_val(endian)).is_some() { + return Err(invalid(format!("duplicate dynamic singleton {tag}"))); + } + } + Err(invalid("dynamic table has no terminator")) +} + +fn check_section( + header: &Header, + endian: Endianness, + bytes: &[u8], + section: &Section, + address: u64, +) -> Result<(), Report> { + if section.sh_addr(endian) != address + || mapped_offset(header, endian, bytes, address, section.sh_size(endian))? + != section.sh_offset(endian) + { + return Err(invalid("section differs from loader-selected bytes")); + } + Ok(()) +} + +pub(super) fn validate( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, +) -> Result<(), Report> { + let tags = dynamic_tags(header, endian, bytes, sections)?; + let (_, dynamic) = unique_section(sections, endian, format::SHT_DYNAMIC)?; + let string_index = dynamic.sh_link(endian) as usize; + let strings = sections + .iter() + .nth(string_index) + .ok_or_else(|| invalid("missing dynamic strings"))?; + if strings.sh_type(endian) != format::SHT_STRTAB + || strings.sh_size(endian) != required(&tags, format::DT_STRSZ)? + { + return Err(invalid("dynamic string table type or length mismatch")); + } + check_section( + header, + endian, + bytes, + strings, + required(&tags, format::DT_STRTAB)?, + )?; + let (_, needs) = unique_section(sections, endian, format::SHT_GNU_VERNEED)?; + check_section( + header, + endian, + bytes, + needs, + required(&tags, format::DT_VERNEED)?, + )?; + if u64::from(needs.sh_info(endian)) != required(&tags, format::DT_VERNEEDNUM)? + || needs.sh_link(endian) as usize != string_index + { + return Err(invalid("version requirement count or strings mismatch")); + } + validate_symbols(header, endian, bytes, sections, &tags, string_index) +} + +fn validate_symbols( + header: &Header, + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, + tags: &BTreeMap, + string_index: usize, +) -> Result<(), Report> { + let (symbol_index, symbols) = unique_section(sections, endian, format::SHT_DYNSYM)?; + let (_, versions) = unique_section(sections, endian, format::SHT_GNU_VERSYM)?; + check_section( + header, + endian, + bytes, + symbols, + required(tags, format::DT_SYMTAB)?, + )?; + check_section( + header, + endian, + bytes, + versions, + required(tags, format::DT_VERSYM)?, + )?; + if required(tags, format::DT_SYMENT)? != 24 + || symbols.sh_entsize(endian) != 24 + || !symbols.sh_size(endian).is_multiple_of(24) + || symbols.sh_size(endian) < 24 + || symbols.sh_link(endian) as usize != string_index + || versions.sh_link(endian) as usize != symbol_index + || versions.sh_size(endian) != symbols.sh_size(endian) / 12 + { + return Err(invalid("dynamic symbol/version table shape mismatch")); + } + let indices = requirement_indices( + endian, + bytes, + sections, + required(tags, format::DT_VERNEEDNUM)?, + )?; + let symbol_bytes = symbols.data(endian, bytes).map_err(invalid)?; + let version_bytes = versions.data(endian, bytes).map_err(invalid)?; + for (symbol, version) in symbol_bytes + .chunks_exact(24) + .zip(version_bytes.chunks_exact(2)) + { + let index = u16::from_le_bytes([version[0], version[1]]) & 0x7fff; + let undefined = u16::from_le_bytes([symbol[6], symbol[7]]) == format::SHN_UNDEF; + if undefined && index > 1 && !indices.contains(&index) { + return Err(invalid( + "undefined symbol references an unaccounted version", + )); + } + } + Ok(()) +} + +fn requirement_indices( + endian: Endianness, + bytes: &[u8], + sections: &Sections<'_>, + expected: u64, +) -> Result, Report> { + let (mut needs, _) = sections + .gnu_verneed(endian, bytes) + .map_err(invalid)? + .ok_or_else(|| invalid("missing version requirements"))?; + let mut count = 0; + let mut indices = BTreeSet::new(); + while let Some((need, mut auxiliary)) = needs.next().map_err(invalid)? { + count += 1; + let mut auxiliary_count = 0; + while let Some(version) = auxiliary.next().map_err(invalid)? { + auxiliary_count += 1; + let index = version.vna_other.get(endian) & 0x7fff; + if index <= 1 || !indices.insert(index) { + return Err(invalid("ambiguous version requirement index")); + } + } + if auxiliary_count != need.vn_cnt.get(endian) || auxiliary_count == 0 { + return Err(invalid("version auxiliary count mismatch")); + } + } + if count != expected { + return Err(invalid("version requirement count mismatch")); + } + Ok(indices) +} diff --git a/crates/trusted-server-release/src/inspect/macho.rs b/crates/trusted-server-release/src/inspect/macho.rs new file mode 100644 index 0000000000..03bd81cf1c --- /dev/null +++ b/crates/trusted-server-release/src/inspect/macho.rs @@ -0,0 +1,282 @@ +//! Mach-O inspection for thin 64-bit macOS executables. + +use error_stack::Report; +use object::macho::{self as format, MachHeader64}; +use object::read::macho::{LoadCommandData, LoadCommandVariant, MachHeader as _}; +use object::{Endianness, FileKind}; + +use super::{ + MachoFacts, MacosVersion, RUNNER_PATH_PREFIXES, Violation, malformed, reject_denied_paths, + rejected, sha256_hex, utf8, +}; +use crate::error::ReleaseError; + +type Header = MachHeader64; + +/// The only dynamic linker a macOS executable may name. +const MACOS_DYLINKER: &str = "/usr/lib/dyld"; + +/// Reviewed `LC_LOAD_DYLIB` set for the `ts` CLI. +/// +/// Observed with `otool -L` on a local `aarch64-apple-darwin` dev build of +/// `trusted-server-cli`: Security and CoreFoundation (via +/// `security-framework`), libiconv and libSystem. +const MACOS_DYLIBS: &[&str] = &[ + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + "/usr/lib/libSystem.B.dylib", + "/usr/lib/libiconv.2.dylib", +]; + +/// Selected macOS CPU type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum MachoCpu { + /// `aarch64-apple-darwin` (`CPU_SUBTYPE_ARM64_ALL`; arm64e is rejected). + #[display("arm64")] + Arm64, + /// `x86_64-apple-darwin` (`CPU_SUBTYPE_X86_64_ALL`). + #[display("x86_64")] + X86_64, +} + +impl MachoCpu { + /// Returns the expected `(cputype, cpusubtype & !CPU_SUBTYPE_MASK)`. + const fn expected(self) -> (u32, u32) { + match self { + Self::Arm64 => (format::CPU_TYPE_ARM64, format::CPU_SUBTYPE_ARM64_ALL), + Self::X86_64 => (format::CPU_TYPE_X86_64, format::CPU_SUBTYPE_X86_64_ALL), + } + } +} + +/// Compiled-in Mach-O acceptance policy. +#[derive(Debug, Clone)] +pub(crate) struct MachoPolicy { + pub(crate) cpu: MachoCpu, + pub(crate) minimum: MacosVersion, + pub(crate) allowed_dylibs: &'static [&'static str], + pub(crate) denied_path_prefixes: &'static [&'static str], +} + +impl MachoPolicy { + /// The reviewed macOS policy for `cpu`: deployment target exactly 13.0. + pub(crate) const fn macos(cpu: MachoCpu) -> Self { + Self { + cpu, + minimum: MacosVersion::new(13, 0), + allowed_dylibs: MACOS_DYLIBS, + denied_path_prefixes: RUNNER_PATH_PREFIXES, + } + } +} + +/// Load-command observations accumulated before policy checks. +#[derive(Default)] +struct Observed { + build_versions: Vec<(u32, MacosVersion, MacosVersion)>, + dylinkers: Vec, + dylibs: Vec, +} + +/// Inspects a final Mach-O executable against `policy`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] with a [`Violation`] when the bytes are +/// fat, 32-bit, big-endian or not `MH_EXECUTE`; the CPU differs; there is +/// not exactly one macOS `LC_BUILD_VERSION` with `minos` equal to the floor +/// (or an `LC_VERSION_MIN_*` is present); the dynamic linker is not +/// `/usr/lib/dyld`; a dylib is outside the allowlist or loaded weak, +/// re-exported, lazy or upward; `LC_RPATH`/`LC_DYLD_ENVIRONMENT` is present; +/// or a runner-only path is embedded anywhere in the file. +pub(crate) fn inspect_macho( + bytes: &[u8], + policy: &MachoPolicy, +) -> Result> { + check_container(bytes)?; + let header = Header::parse(bytes, 0).map_err(|error| malformed("Mach-O header", error))?; + let endian = header + .endian() + .map_err(|error| malformed("Mach-O endianness", error))?; + check_identity(header, endian, policy.cpu)?; + let observed = load_commands(header, endian, bytes)?; + let (minos, sdk) = deployment_target(&observed, policy.minimum)?; + if observed.dylinkers.as_slice() != [MACOS_DYLINKER] { + return Err(rejected( + Violation::Interpreter, + format!( + "LC_LOAD_DYLINKER {:?}, expected [{MACOS_DYLINKER:?}]", + observed.dylinkers + ), + )); + } + if let Some(name) = observed + .dylibs + .iter() + .find(|name| !policy.allowed_dylibs.contains(&name.as_str())) + { + return Err(rejected( + Violation::Library, + format!("LC_LOAD_DYLIB {name:?}"), + )); + } + reject_denied_paths(bytes, policy.denied_path_prefixes)?; + Ok(MachoFacts { + sha256: sha256_hex(bytes), + cpu: policy.cpu, + minos, + sdk, + dylibs: observed.dylibs, + }) +} + +/// Requires a thin 64-bit Mach-O container. +fn check_container(bytes: &[u8]) -> Result<(), Report> { + match FileKind::parse(bytes) { + Ok(FileKind::MachO64) => Ok(()), + Ok(kind @ (FileKind::MachO32 | FileKind::MachOFat32 | FileKind::MachOFat64)) => { + Err(rejected( + Violation::Architecture, + format!("{kind:?}; expected thin 64-bit Mach-O"), + )) + } + Ok(kind) => Err(malformed("Mach-O container", format!("{kind:?}"))), + Err(error) => Err(malformed("Mach-O container", error)), + } +} + +/// Requires a little-endian `MH_EXECUTE` for the selected CPU. +fn check_identity( + header: &Header, + endian: Endianness, + cpu: MachoCpu, +) -> Result<(), Report> { + let observed = ( + header.cputype(endian), + header.cpusubtype(endian) & !format::CPU_SUBTYPE_MASK, + ); + let file_type = header.filetype(endian); + if endian != Endianness::Little || observed != cpu.expected() || file_type != format::MH_EXECUTE + { + return Err(rejected( + Violation::Architecture, + format!( + "Mach-O {endian:?} cpu {:#x}/{:#x} filetype {file_type}; expected little-endian \ + {cpu} MH_EXECUTE", + observed.0, observed.1 + ), + )); + } + Ok(()) +} + +/// Walks every load command, rejecting forbidden kinds immediately. +fn load_commands( + header: &Header, + endian: Endianness, + bytes: &[u8], +) -> Result> { + let parse_error = |error| malformed("Mach-O load command", error); + let mut commands = header + .load_commands(endian, bytes, 0) + .map_err(parse_error)?; + let mut observed = Observed::default(); + while let Some(command) = commands.next().map_err(parse_error)? { + record_command(&command, endian, &mut observed)?; + } + Ok(observed) +} + +/// Records or rejects one load command. +fn record_command( + command: &LoadCommandData<'_, Endianness>, + endian: Endianness, + observed: &mut Observed, +) -> Result<(), Report> { + let parse_error = |error| malformed("Mach-O load command", error); + match command.variant().map_err(parse_error)? { + LoadCommandVariant::BuildVersion(build) => observed.build_versions.push(( + build.platform.get(endian), + MacosVersion::from_encoded(build.minos.get(endian)), + MacosVersion::from_encoded(build.sdk.get(endian)), + )), + LoadCommandVariant::LoadDylinker(dylinker) => { + let name = command.string(endian, dylinker.name).map_err(parse_error)?; + observed + .dylinkers + .push(utf8(name, "Mach-O dylinker")?.to_owned()); + } + LoadCommandVariant::Dylib(dylib) => { + let name = command + .string(endian, dylib.dylib.name) + .map_err(parse_error)?; + observed.dylibs.push(plain_dylib(command.cmd(), name)?); + } + LoadCommandVariant::Rpath(rpath) => { + let path = command.string(endian, rpath.path).map_err(parse_error)?; + return Err(rejected( + Violation::SearchPath, + format!("LC_RPATH {:?}", String::from_utf8_lossy(path)), + )); + } + other => reject_forbidden(&other)?, + } + Ok(()) +} + +/// Rejects load commands that carry no string but are never acceptable in a +/// release executable. +fn reject_forbidden( + variant: &LoadCommandVariant<'_, Endianness>, +) -> Result<(), Report> { + let (violation, detail) = match variant { + LoadCommandVariant::VersionMin(_) => { + (Violation::DeploymentTarget, "LC_VERSION_MIN_* present") + } + LoadCommandVariant::IdDylib(_) | LoadCommandVariant::PreboundDylib(_) => { + (Violation::LinkKind, "dylib identity in an executable") + } + LoadCommandVariant::DyldEnvironment(_) => { + (Violation::SearchPath, "LC_DYLD_ENVIRONMENT present") + } + _ => return Ok(()), + }; + Err(rejected(violation, detail)) +} + +/// Returns the install name of a plain `LC_LOAD_DYLIB`; weak, re-exported, +/// lazy and upward loads are rejected. +fn plain_dylib(cmd: u32, name: &[u8]) -> Result> { + let name = utf8(name, "Mach-O dylib")?; + if cmd != format::LC_LOAD_DYLIB { + return Err(rejected( + Violation::LinkKind, + format!("load command {cmd:#x} for {name:?}"), + )); + } + Ok(name.to_owned()) +} + +/// Requires exactly one macOS `LC_BUILD_VERSION` whose `minos` equals the +/// floor; returns `(minos, sdk)`. +fn deployment_target( + observed: &Observed, + minimum: MacosVersion, +) -> Result<(MacosVersion, MacosVersion), Report> { + let [(platform, minos, sdk)] = observed.build_versions.as_slice() else { + return Err(rejected( + Violation::DeploymentTarget, + format!( + "expected exactly one LC_BUILD_VERSION, found {}", + observed.build_versions.len() + ), + )); + }; + if *platform != format::PLATFORM_MACOS || *minos != minimum { + return Err(rejected( + Violation::DeploymentTarget, + format!("platform {platform} minos {minos}; expected macOS minos {minimum}"), + )); + } + Ok((*minos, *sdk)) +} diff --git a/crates/trusted-server-release/src/inspect/tests.rs b/crates/trusted-server-release/src/inspect/tests.rs new file mode 100644 index 0000000000..8d4798e6ec --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests.rs @@ -0,0 +1,111 @@ +//! Final-artifact inspection tests over hand-built images. +//! +//! Real-binary observations (dev `ts` builds and the Fastly Wasm) are manual +//! evidence, not tests, because they depend on host build outputs. + +mod builders; +mod elf; +mod macho; +mod wasm; + +use error_stack::Report; + +use super::*; +use crate::error::ReleaseError; + +/// Returns the [`Violation`] attached to an inspection rejection. +fn violation(result: Result>) -> Violation { + let report = result.expect_err("should reject the artifact"); + assert!( + matches!(report.current_context(), ReleaseError::Inspect), + "should report an inspection rejection: {report:?}" + ); + *report + .downcast_ref::() + .expect("should attach the violated rule") +} + +/// Asserts every strict prefix of `bytes` is rejected without panicking. +fn assert_truncations_rejected( + bytes: &[u8], + inspect: impl Fn(&[u8]) -> Result>, +) { + for length in 0..bytes.len() { + assert!( + inspect(&bytes[..length]).is_err(), + "should reject the input truncated to {length} of {} bytes", + bytes.len() + ); + } +} + +#[test] +fn glibc_versions_parse_numerically() { + assert_eq!(GlibcVersion::parse("2.34"), Some(GlibcVersion::new(2, 34))); + assert!( + GlibcVersion::parse("2.2.5").expect("should parse three components") + < GlibcVersion::new(2, 3), + "should compare 2.2.5 below 2.3" + ); + assert!( + GlibcVersion::new(2, 9) < GlibcVersion::new(2, 34), + "should compare minors numerically, not lexically" + ); + for text in [ + "", + "2", + "PRIVATE", + "ABI_DT_RELR", + "2.34.x", + "2.+4", + "2.034", + "2..34", + "1.2.3.4", + ] { + assert_eq!(GlibcVersion::parse(text), None, "should reject {text:?}"); + } +} + +#[test] +fn macos_versions_decode_nibbles() { + let version = MacosVersion::from_encoded(0x000d_0100); + assert_eq!(version.to_string(), "13.1.0", "should decode xxxx.yy.zz"); + assert_eq!( + MacosVersion::from_encoded(0x000d_0000), + MacosVersion::new(13, 0) + ); +} + +#[test] +fn denied_path_scan_reports_offsets() { + let bytes = b"ok /usr/lib x /home/runner/work y /tmp/abc /tmpfoo"; + let hits = find_denied_paths(bytes, RUNNER_PATH_PREFIXES); + assert_eq!( + hits, + vec![ + PathHit { + prefix: "/home/", + offset: 14 + }, + PathHit { + prefix: "/tmp/", + offset: 34 + }, + ], + "should report each runner-only prefix with its offset" + ); + assert!( + find_denied_paths( + b"serde/src/private/de.rs /rust/deps/x/src/lib.rs", + RUNNER_PATH_PREFIXES + ) + .is_empty(), + "should not match crate-relative or remapped paths" + ); + let report = reject_denied_paths(bytes, RUNNER_PATH_PREFIXES) + .expect_err("should reject embedded runner paths"); + assert!( + format!("{report:?}").contains("/home/@0xe"), + "should report the hit offset: {report:?}" + ); +} diff --git a/crates/trusted-server-release/src/inspect/tests/builders.rs b/crates/trusted-server-release/src/inspect/tests/builders.rs new file mode 100644 index 0000000000..e7b02f6881 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/builders.rs @@ -0,0 +1,493 @@ +//! Minimal hand-built ELF, Mach-O and Wasm images. +//! +//! The images contain only the structures the inspectors read; they are not +//! runnable. Building them in-process keeps the fixtures host-independent. + +use object::elf as elf_format; +use object::macho as macho_format; + +/// Parameters of a synthetic `x86_64` ELF executable. +pub(super) struct ElfSpec { + pub(super) machine: u16, + pub(super) interpreter: Option<&'static str>, + pub(super) needed: Vec<&'static str>, + pub(super) runpath: Option<&'static str>, + pub(super) requirements: Vec<(&'static str, Vec<&'static str>)>, + pub(super) payload: &'static [u8], +} + +impl ElfSpec { + /// A conforming `ts`-like executable at the glibc 2.34 ceiling. + pub(super) fn conforming() -> Self { + Self { + machine: elf_format::EM_X86_64, + interpreter: Some("/lib64/ld-linux-x86-64.so.2"), + needed: vec!["libgcc_s.so.1", "libm.so.6", "libc.so.6"], + runpath: None, + requirements: vec![ + ("libgcc_s.so.1", vec!["GCC_3.0", "GCC_4.2.0"]), + ("libc.so.6", vec!["GLIBC_2.2.5", "GLIBC_2.34", "GLIBC_2.18"]), + ], + payload: b"fictional rodata /opt/example/src/main.rs", + } + } +} + +/// Little-endian byte writer. +#[derive(Default)] +struct Writer(Vec); + +impl Writer { + fn u16(&mut self, value: u16) { + self.0.extend_from_slice(&value.to_le_bytes()); + } + + fn u32(&mut self, value: u32) { + self.0.extend_from_slice(&value.to_le_bytes()); + } + + fn u64(&mut self, value: u64) { + self.0.extend_from_slice(&value.to_le_bytes()); + } + + fn align(&mut self, alignment: usize) { + while !self.0.len().is_multiple_of(alignment) { + self.0.push(0); + } + } + + fn offset(&self) -> u64 { + u64::try_from(self.0.len()).expect("should fit u64") + } + + /// Appends `bytes` and returns their range. + fn append(&mut self, bytes: &[u8]) -> Range { + let offset = self.offset(); + self.0.extend_from_slice(bytes); + (offset, self.offset() - offset) + } +} + +/// A NUL-separated string table that returns each string's offset. +#[derive(Default)] +struct Strings(Vec); + +impl Strings { + fn new() -> Self { + Self(vec![0]) + } + + fn add(&mut self, text: &str) -> u32 { + let offset = u32::try_from(self.0.len()).expect("should fit u32"); + self.0.extend_from_slice(text.as_bytes()); + self.0.push(0); + offset + } +} + +/// A `(file offset, size)` range; addresses equal offsets in these images. +type Range = (u64, u64); + +/// One ELF section header record. +struct Section { + name: u32, + kind: u32, + range: Range, + link: u32, + info: u32, + entry_size: u64, +} + +const ELF_HEADER_SIZE: u64 = 64; +const PROGRAM_HEADER_SIZE: u64 = 56; +/// Section index of `.dynstr`, linked from `.dynamic` and `.gnu.version_r`. +const DYNSTR_INDEX: u32 = 1; + +/// Builds a synthetic ELF64 executable from `spec`. +pub(super) fn elf(spec: &ElfSpec) -> Vec { + let mut dynstr = Strings::new(); + let mut dynamic = spec + .needed + .iter() + .map(|name| (elf_format::DT_NEEDED, u64::from(dynstr.add(name)))) + .collect::>(); + if let Some(path) = spec.runpath { + dynamic.push((elf_format::DT_RUNPATH, u64::from(dynstr.add(path)))); + } + let verneed = verneed(&spec.requirements, &mut dynstr); + let mut symbols = Writer::default(); + symbols.0.resize(24, 0); + let mut versions = Writer::default(); + versions.u16(0); + for index in 0..spec.requirements.iter().map(|(_, names)| names.len()).sum() { + symbols.u32(dynstr.add(&format!("fictional_symbol_{index}"))); + symbols.0.extend_from_slice(&[0x12, 0]); + symbols.u16(0); + symbols.u64(0); + symbols.u64(0); + versions.u16(u16::try_from(index + 2).expect("should fit version index")); + } + let segments = 3 + u64::from(spec.interpreter.is_some()); + let mut body = Writer::default(); + body.0.resize( + usize::try_from(ELF_HEADER_SIZE + segments * PROGRAM_HEADER_SIZE).expect("should fit"), + 0, + ); + let interp = body.append(&spec.interpreter.map(nul_terminated).unwrap_or_default()); + let dynstr_range = body.append(&dynstr.0); + body.align(4); + let verneed_range = body.append(&verneed); + body.align(8); + let symbols_range = body.append(&symbols.0); + let versions_range = body.append(&versions.0); + body.align(8); + dynamic.push((elf_format::DT_STRTAB, dynstr_range.0)); + dynamic.push((elf_format::DT_STRSZ, dynstr_range.1)); + dynamic.push((elf_format::DT_VERNEED, verneed_range.0)); + dynamic.push((elf_format::DT_VERNEEDNUM, spec.requirements.len() as u64)); + dynamic.push((elf_format::DT_SYMTAB, symbols_range.0)); + dynamic.push((elf_format::DT_SYMENT, 24)); + dynamic.push((elf_format::DT_VERSYM, versions_range.0)); + dynamic.push((elf_format::DT_NULL, 0)); + dynamic.push((elf_format::DT_NULL, 0)); + let dynamic_range = body.append(&encode_dynamic(&dynamic)); + body.0.extend_from_slice(spec.payload); + let count = u32::try_from(spec.requirements.len()).expect("should fit"); + let table = section_table( + &mut body, + [ + dynstr_range, + dynamic_range, + verneed_range, + symbols_range, + versions_range, + ], + count, + ); + let mut header = Writer::default(); + write_elf_header(&mut header, spec.machine, segments, body.offset(), &table); + write_sections(&mut body, &table); + if spec.interpreter.is_some() { + program_header(&mut header, elf_format::PT_INTERP, interp); + } + program_header(&mut header, elf_format::PT_DYNAMIC, dynamic_range); + program_header(&mut header, elf_format::PT_NOTE, (0, 0)); + program_header(&mut header, elf_format::PT_LOAD, (0, body.offset())); + body.0[..header.0.len()].copy_from_slice(&header.0); + body.0 +} + +fn nul_terminated(text: &str) -> Vec { + let mut bytes = text.as_bytes().to_vec(); + bytes.push(0); + bytes +} + +fn encode_dynamic(entries: &[(u32, u64)]) -> Vec { + let mut out = Writer::default(); + for (tag, value) in entries { + out.u64(u64::from(*tag)); + out.u64(*value); + } + out.0 +} + +/// Appends `.shstrtab` to `body` and returns the non-null section headers +/// for `[.dynstr, .dynamic, .gnu.version_r]` plus `.shstrtab`. +fn section_table(body: &mut Writer, ranges: [Range; 5], requirements: u32) -> Vec
{ + let mut names = Strings::new(); + let [dynstr, dynamic, verneed, symbols, versions] = ranges; + let mut table = vec![ + Section { + name: names.add(".dynstr"), + kind: elf_format::SHT_STRTAB, + range: dynstr, + link: 0, + info: 0, + entry_size: 0, + }, + Section { + name: names.add(".dynamic"), + kind: elf_format::SHT_DYNAMIC, + range: dynamic, + link: DYNSTR_INDEX, + info: 0, + entry_size: 16, + }, + Section { + name: names.add(".gnu.version_r"), + kind: elf_format::SHT_GNU_VERNEED, + range: verneed, + link: DYNSTR_INDEX, + info: requirements, + entry_size: 0, + }, + Section { + name: names.add(".dynsym"), + kind: elf_format::SHT_DYNSYM, + range: symbols, + link: DYNSTR_INDEX, + info: 1, + entry_size: 24, + }, + Section { + name: names.add(".gnu.version"), + kind: elf_format::SHT_GNU_VERSYM, + range: versions, + link: 4, + info: 0, + entry_size: 2, + }, + ]; + let name = names.add(".shstrtab"); + let range = body.append(&names.0); + body.align(8); + table.push(Section { + name, + kind: elf_format::SHT_STRTAB, + range, + link: 0, + info: 0, + entry_size: 0, + }); + table +} + +/// Encodes `.gnu.version_r` entries against `strings`. +fn verneed(requirements: &[(&str, Vec<&str>)], strings: &mut Strings) -> Vec { + let mut out = Writer::default(); + let mut version_index = 2; + for (index, (file, names)) in requirements.iter().enumerate() { + let last_need = index + 1 == requirements.len(); + let need_size = 16 + 16 * names.len(); + out.u16(1); + out.u16(u16::try_from(names.len()).expect("should fit")); + out.u32(strings.add(file)); + out.u32(16); + out.u32(if last_need { + 0 + } else { + u32::try_from(need_size).expect("should fit") + }); + for (aux, name) in names.iter().enumerate() { + out.u32(0); + out.u16(0); + out.u16(version_index); + version_index += 1; + out.u32(strings.add(name)); + out.u32(if aux + 1 == names.len() { 0 } else { 16 }); + } + } + out.0 +} + +fn write_elf_header( + out: &mut Writer, + machine: u16, + segments: u64, + section_offset: u64, + table: &[Section], +) { + out.0.extend_from_slice(&elf_format::ELFMAG); + out.0.extend_from_slice(&[ + elf_format::ELFCLASS64, + elf_format::ELFDATA2LSB, + elf_format::EV_CURRENT, + ]); + out.0.resize(16, 0); + out.u16(elf_format::ET_DYN); + out.u16(machine); + out.u32(u32::from(elf_format::EV_CURRENT)); + out.u64(0); + out.u64(ELF_HEADER_SIZE); + out.u64(section_offset); + out.u32(0); + out.u16(64); + out.u16(56); + out.u16(u16::try_from(segments).expect("should fit")); + out.u16(64); + out.u16(u16::try_from(table.len() + 1).expect("should fit")); + out.u16(u16::try_from(table.len()).expect("should fit")); +} + +fn program_header(out: &mut Writer, kind: u32, (offset, size): Range) { + out.u32(kind); + out.u32(elf_format::PF_R); + out.u64(offset); + out.u64(offset); + out.u64(offset); + out.u64(size); + out.u64(size); + out.u64(8); +} + +fn write_sections(out: &mut Writer, table: &[Section]) { + out.0.extend_from_slice(&[0; 64]); + for section in table { + out.u32(section.name); + out.u32(section.kind); + out.u64(0); + out.u64(section.range.0); + out.u64(section.range.0); + out.u64(section.range.1); + out.u32(section.link); + out.u32(section.info); + out.u64(1); + out.u64(section.entry_size); + } +} + +/// One synthetic Mach-O load command. +pub(super) enum Command { + BuildVersion { platform: u32, minos: u32 }, + VersionMin { version: u32 }, + Dylinker(&'static str), + Dylib { cmd: u32, name: &'static str }, + Rpath(&'static str), +} + +/// Parameters of a synthetic thin 64-bit Mach-O executable. +pub(super) struct MachoSpec { + pub(super) cputype: u32, + pub(super) cpusubtype: u32, + pub(super) commands: Vec, + pub(super) payload: &'static [u8], +} + +/// Encodes `major.minor` in the Mach-O nibble form. +pub(super) const fn macos(major: u32, minor: u32) -> u32 { + (major << 16) | (minor << 8) +} + +impl MachoSpec { + /// A conforming arm64 `ts`-like executable targeting macOS 13.0. + pub(super) fn conforming() -> Self { + Self { + cputype: macho_format::CPU_TYPE_ARM64, + cpusubtype: macho_format::CPU_SUBTYPE_ARM64_ALL, + commands: vec![ + Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(13, 0), + }, + Command::Dylinker("/usr/lib/dyld"), + Command::Dylib { + cmd: macho_format::LC_LOAD_DYLIB, + name: "/System/Library/Frameworks/Security.framework/Versions/A/Security", + }, + Command::Dylib { + cmd: macho_format::LC_LOAD_DYLIB, + name: "/usr/lib/libSystem.B.dylib", + }, + ], + payload: b"fictional cstring /opt/example/src/main.rs", + } + } +} + +/// Builds a synthetic thin Mach-O executable from `spec`. +pub(super) fn macho(spec: &MachoSpec) -> Vec { + let mut commands = Writer::default(); + for command in &spec.commands { + encode_command(&mut commands, command); + } + let mut out = Writer::default(); + out.u32(macho_format::MH_MAGIC_64); + out.u32(spec.cputype); + out.u32(spec.cpusubtype); + out.u32(macho_format::MH_EXECUTE); + out.u32(u32::try_from(spec.commands.len()).expect("should fit")); + out.u32(u32::try_from(commands.0.len()).expect("should fit")); + out.u32(0); + out.u32(0); + out.0.extend_from_slice(&commands.0); + out.0.extend_from_slice(spec.payload); + out.0 +} + +fn encode_command(out: &mut Writer, command: &Command) { + match command { + Command::BuildVersion { platform, minos } => { + out.u32(macho_format::LC_BUILD_VERSION); + out.u32(24); + out.u32(*platform); + out.u32(*minos); + out.u32(macos(15, 5)); + out.u32(0); + } + Command::VersionMin { version } => { + out.u32(macho_format::LC_VERSION_MIN_MACOSX); + out.u32(16); + out.u32(*version); + out.u32(macos(15, 5)); + } + Command::Dylinker(name) => string_command(out, macho_format::LC_LOAD_DYLINKER, &[], name), + Command::Dylib { cmd, name } => { + string_command(out, *cmd, &[2, 0x0001_0000, 0x0001_0000], name); + } + Command::Rpath(path) => string_command(out, macho_format::LC_RPATH, &[], path), + } +} + +/// Encodes a command whose string follows `fields` after the name offset. +fn string_command(out: &mut Writer, cmd: u32, fields: &[u32], text: &str) { + let fixed = 12 + 4 * fields.len(); + let size = (fixed + text.len() + 1).div_ceil(8) * 8; + out.u32(cmd); + out.u32(u32::try_from(size).expect("should fit")); + out.u32(u32::try_from(fixed).expect("should fit")); + for field in fields { + out.u32(*field); + } + out.0.extend_from_slice(text.as_bytes()); + out.0.resize(out.0.len() + size - fixed - text.len(), 0); +} + +/// Builds a two-architecture fat header around `thin`. +pub(super) fn fat(thin: &[u8]) -> Vec { + let mut out = Vec::new(); + for value in [ + macho_format::FAT_MAGIC, + 1, + macho_format::CPU_TYPE_ARM64, + 0, + 4096, + ] { + out.extend_from_slice(&value.to_be_bytes()); + } + for value in [u32::try_from(thin.len()).expect("should fit"), 12] { + out.extend_from_slice(&value.to_be_bytes()); + } + out.resize(4096, 0); + out.extend_from_slice(thin); + out +} + +/// Builds a core Wasm module importing `() -> ()` functions. +pub(super) fn wasm_module(imports: &[(&str, &str)]) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + if imports.is_empty() { + return out; + } + section(&mut out, 1, &[1, 0x60, 0, 0]); + let mut body = vec![u8::try_from(imports.len()).expect("should fit")]; + for (module, name) in imports { + name_bytes(&mut body, module); + name_bytes(&mut body, name); + body.extend_from_slice(&[0, 0]); + } + section(&mut out, 2, &body); + out +} + +/// Appends a section with a single-byte LEB128 length. +pub(super) fn section(out: &mut Vec, id: u8, body: &[u8]) { + out.push(id); + out.push(u8::try_from(body.len()).expect("should fit one LEB128 byte")); + out.extend_from_slice(body); +} + +fn name_bytes(out: &mut Vec, text: &str) { + out.push(u8::try_from(text.len()).expect("should fit one LEB128 byte")); + out.extend_from_slice(text.as_bytes()); +} diff --git a/crates/trusted-server-release/src/inspect/tests/elf.rs b/crates/trusted-server-release/src/inspect/tests/elf.rs new file mode 100644 index 0000000000..0822974de6 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/elf.rs @@ -0,0 +1,309 @@ +//! ELF inspection tests. + +use object::elf as elf_format; + +use super::builders::{ElfSpec, MachoSpec, elf, macho}; +use super::*; + +fn inspect(spec: &ElfSpec) -> Result> { + inspect_elf(&elf(spec), &ElfPolicy::linux_x86_64_gnu()) +} + +#[test] +fn conforming_executable_is_accepted_with_observed_facts() { + let bytes = elf(&ElfSpec::conforming()); + + let facts = inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()) + .expect("should accept a conforming executable"); + + assert_eq!(facts.interpreter(), "/lib64/ld-linux-x86-64.so.2"); + assert_eq!(facts.needed(), ["libgcc_s.so.1", "libm.so.6", "libc.so.6"]); + assert_eq!( + facts.max_glibc(), + GlibcVersion::new(2, 34), + "should record the highest GLIBC requirement numerically" + ); + assert_eq!( + facts.version_requirements().len(), + 5, + "should record every requirement" + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should hash the inspected bytes" + ); +} + +#[test] +fn glibc_above_ceiling_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.requirements[1].1.push("GLIBC_2.35"); + + assert_eq!(violation(inspect(&spec)), Violation::SymbolVersion); +} + +#[test] +fn unparsable_glibc_version_is_rejected() { + for name in ["GLIBC_PRIVATE", "GLIBC_ABI_DT_RELR"] { + let mut spec = ElfSpec::conforming(); + spec.requirements[1].1.push(name); + + assert_eq!( + violation(inspect(&spec)), + Violation::SymbolVersion, + "should reject {name}" + ); + } +} + +#[test] +fn unexpected_version_namespace_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.requirements[1].1.push("GCC_3.0"); + + assert_eq!( + violation(inspect(&spec)), + Violation::SymbolVersion, + "should accept GCC_ names only from libgcc_s" + ); +} + +#[test] +fn missing_glibc_requirements_are_rejected() { + let mut spec = ElfSpec::conforming(); + spec.requirements.truncate(1); + + assert_eq!(violation(inspect(&spec)), Violation::SymbolVersion); +} + +#[test] +fn wrong_architecture_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.machine = elf_format::EM_AARCH64; + + assert_eq!(violation(inspect(&spec)), Violation::Architecture); +} + +#[test] +fn missing_interpreter_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.interpreter = None; + + assert_eq!(violation(inspect(&spec)), Violation::Interpreter); +} + +#[test] +fn unexpected_interpreter_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.interpreter = Some("/opt/example/ld.so"); + + assert_eq!(violation(inspect(&spec)), Violation::Interpreter); +} + +#[test] +fn unexpected_needed_library_is_rejected() { + for name in ["libssl.so.3", "libpthread.so.0", "/opt/example/libc.so.6"] { + let mut spec = ElfSpec::conforming(); + spec.needed.push(name); + + assert_eq!( + violation(inspect(&spec)), + Violation::Library, + "should reject {name}" + ); + } +} + +#[test] +fn runpath_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.runpath = Some("$ORIGIN/../lib"); + + assert_eq!(violation(inspect(&spec)), Violation::SearchPath); +} + +fn replace_dynamic_terminator(bytes: &mut [u8], tag: u32, value: u64) { + let section_table = u64::from_le_bytes(bytes[40..48].try_into().expect("should read offset")); + let section = usize::try_from(section_table).expect("should fit") + 2 * 64; + let offset = u64::from_le_bytes( + bytes[section + 24..section + 32] + .try_into() + .expect("should read offset"), + ); + let size = u64::from_le_bytes( + bytes[section + 32..section + 40] + .try_into() + .expect("should read size"), + ); + let start = usize::try_from(offset).expect("should fit"); + let end = usize::try_from(offset + size).expect("should fit"); + let entry = (start..end) + .step_by(16) + .find(|entry| read_u64(bytes, *entry) == 0) + .expect("should find terminator"); + bytes[entry..entry + 8].copy_from_slice(&u64::from(tag).to_le_bytes()); + bytes[entry + 8..entry + 16].copy_from_slice(&value.to_le_bytes()); +} + +#[test] +fn loader_auditors_are_rejected() { + for tag in [elf_format::DT_AUDIT, elf_format::DT_DEPAUDIT] { + let mut bytes = elf(&ElfSpec::conforming()); + replace_dynamic_terminator(&mut bytes, tag, 0); + assert_eq!( + violation(inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu())), + Violation::Library, + "should reject loader auditor tags" + ); + } +} + +#[test] +fn unterminated_dynamic_table_is_rejected() { + let mut bytes = elf(&ElfSpec::conforming()); + replace_dynamic_terminator(&mut bytes, elf_format::DT_DEBUG, 0); + replace_dynamic_terminator(&mut bytes, elf_format::DT_DEBUG, 0); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should require a dynamic table terminator" + ); +} + +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes( + bytes[offset..offset + 8] + .try_into() + .expect("should read field"), + ) +} + +fn section_offset(bytes: &[u8], index: usize) -> usize { + usize::try_from(read_u64(bytes, 40)).expect("should fit section offset") + index * 64 +} + +fn change_dynamic_value(bytes: &mut [u8], tag: u32) { + let section = section_offset(bytes, 2); + let start = usize::try_from(read_u64(bytes, section + 24)).expect("should fit"); + let end = start + usize::try_from(read_u64(bytes, section + 32)).expect("should fit"); + let entry = (start..end) + .step_by(16) + .find(|offset| read_u64(bytes, *offset) == u64::from(tag)) + .expect("should find dynamic tag"); + let changed = read_u64(bytes, entry + 8) + 1; + bytes[entry + 8..entry + 16].copy_from_slice(&changed.to_le_bytes()); +} + +#[test] +fn loader_metadata_must_match_all_observed_sections() { + for tag in [ + elf_format::DT_STRSZ, + elf_format::DT_VERNEED, + elf_format::DT_VERNEEDNUM, + elf_format::DT_SYMTAB, + elf_format::DT_SYMENT, + elf_format::DT_VERSYM, + ] { + let mut bytes = elf(&ElfSpec::conforming()); + change_dynamic_value(&mut bytes, tag); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should reject section/dynamic disagreement for tag {tag}" + ); + } +} + +#[test] +fn shadow_string_table_cannot_hide_loader_libraries() { + let mut bytes = elf(&ElfSpec::conforming()); + let section = section_offset(&bytes, 1); + let start = usize::try_from(read_u64(&bytes, section + 24)).expect("should fit"); + let size = usize::try_from(read_u64(&bytes, section + 32)).expect("should fit"); + let shadow = bytes[start..start + size].to_vec(); + let shadow_offset = bytes.len() as u64; + bytes.extend_from_slice(&shadow); + bytes[section + 24..section + 32].copy_from_slice(&shadow_offset.to_le_bytes()); + let name = bytes[start..start + size] + .windows(9) + .position(|name| name == b"libc.so.6") + .expect("should find real loader library"); + bytes[start + name..start + name + 9].copy_from_slice(b"evil.so.6"); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should inspect the loader's actual strings rather than a shadow section" + ); +} + +#[test] +fn undefined_symbol_cannot_hide_an_unaccounted_version() { + let mut bytes = elf(&ElfSpec::conforming()); + let section = section_offset(&bytes, 5); + let start = usize::try_from(read_u64(&bytes, section + 24)).expect("should fit"); + bytes[start + 2..start + 4].copy_from_slice(&999_u16.to_le_bytes()); + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should bind undefined symbol versions to checked requirements" + ); +} + +#[test] +fn partially_overlapping_loads_cannot_replace_observed_bytes() { + for file_size in [0_u64, 1] { + let mut bytes = elf(&ElfSpec::conforming()); + let strings = section_offset(&bytes, 1); + let address = read_u64(&bytes, strings + 16) + 4; + let note = (64..64 + 4 * 56) + .step_by(56) + .find(|offset| bytes[*offset..*offset + 4] == elf_format::PT_NOTE.to_le_bytes()) + .expect("should find spare program header"); + bytes[note..note + 4].copy_from_slice(&elf_format::PT_LOAD.to_le_bytes()); + for (field, value) in [(8, address), (16, address), (32, file_size), (40, 1)] { + bytes[note + field..note + field + 8].copy_from_slice(&value.to_le_bytes()); + } + assert!( + inspect_elf(&bytes, &ElfPolicy::linux_x86_64_gnu()).is_err(), + "should reject partial file-backed and zero-fill overlaps" + ); + } +} + +#[test] +fn embedded_runner_path_is_rejected() { + let mut spec = ElfSpec::conforming(); + spec.payload = b"panic at /home/runner/work/example/src/lib.rs"; + + assert_eq!(violation(inspect(&spec)), Violation::EmbeddedPath); +} + +#[test] +fn denied_prefixes_are_configurable() { + let policy = ElfPolicy { + denied_path_prefixes: &["/opt/example/"], + ..ElfPolicy::linux_x86_64_gnu() + }; + + let result = inspect_elf(&elf(&ElfSpec::conforming()), &policy); + + assert_eq!(violation(result), Violation::EmbeddedPath); +} + +#[test] +fn non_elf_inputs_are_rejected() { + let policy = ElfPolicy::linux_x86_64_gnu(); + let thin = macho(&MachoSpec::conforming()); + + assert_eq!(violation(inspect_elf(&thin, &policy)), Violation::Malformed); + assert_eq!( + violation(inspect_elf(b"\0asm\x01\0\0\0", &policy)), + Violation::Malformed + ); +} + +#[test] +fn truncated_executables_are_rejected_without_panic() { + let policy = ElfPolicy::linux_x86_64_gnu(); + + assert_truncations_rejected(&elf(&ElfSpec::conforming()), |bytes| { + inspect_elf(bytes, &policy) + }); +} diff --git a/crates/trusted-server-release/src/inspect/tests/macho.rs b/crates/trusted-server-release/src/inspect/tests/macho.rs new file mode 100644 index 0000000000..a2d583e18e --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/macho.rs @@ -0,0 +1,206 @@ +//! Mach-O inspection tests. + +use object::macho as macho_format; + +use super::builders::{Command, ElfSpec, MachoSpec, elf, fat, macho, macos}; +use super::*; + +fn inspect(spec: &MachoSpec) -> Result> { + inspect_macho(&macho(spec), &MachoPolicy::macos(MachoCpu::Arm64)) +} + +fn with_command(command: Command) -> MachoSpec { + let mut spec = MachoSpec::conforming(); + spec.commands.push(command); + spec +} + +#[test] +fn conforming_executable_is_accepted_with_observed_facts() { + let bytes = macho(&MachoSpec::conforming()); + + let facts = inspect_macho(&bytes, &MachoPolicy::macos(MachoCpu::Arm64)) + .expect("should accept a conforming executable"); + + assert_eq!(facts.cpu(), MachoCpu::Arm64); + assert_eq!( + facts.minos(), + MacosVersion::new(13, 0), + "should record minos 13.0" + ); + assert_eq!( + facts.sdk(), + MacosVersion::new(15, 5), + "should record the SDK version" + ); + assert_eq!( + facts.dylibs(), + [ + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + "/usr/lib/libSystem.B.dylib", + ] + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should hash the inspected bytes" + ); +} + +#[test] +fn x86_64_policy_accepts_x86_64_executable() { + let mut spec = MachoSpec::conforming(); + spec.cputype = macho_format::CPU_TYPE_X86_64; + spec.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL | macho_format::CPU_SUBTYPE_LIB64; + + let facts = inspect_macho(&macho(&spec), &MachoPolicy::macos(MachoCpu::X86_64)) + .expect("should accept x86_64 with capability bits masked"); + + assert_eq!(facts.cpu(), MachoCpu::X86_64); +} + +#[test] +fn deployment_target_other_than_floor_is_rejected() { + for (major, minor) in [(11, 0), (14, 0), (12, 7)] { + let mut spec = MachoSpec::conforming(); + spec.commands[0] = Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(major, minor), + }; + + assert_eq!( + violation(inspect(&spec)), + Violation::DeploymentTarget, + "should reject minos {major}.{minor}" + ); + } +} + +#[test] +fn missing_or_legacy_deployment_target_is_rejected() { + let mut missing = MachoSpec::conforming(); + missing.commands.remove(0); + let mut legacy = MachoSpec::conforming(); + legacy.commands[0] = Command::VersionMin { + version: macos(13, 0), + }; + let duplicate = with_command(Command::BuildVersion { + platform: macho_format::PLATFORM_MACOS, + minos: macos(13, 0), + }); + + for spec in [missing, legacy, duplicate] { + assert_eq!(violation(inspect(&spec)), Violation::DeploymentTarget); + } +} + +#[test] +fn non_macos_platform_is_rejected() { + let mut spec = MachoSpec::conforming(); + spec.commands[0] = Command::BuildVersion { + platform: macho_format::PLATFORM_IOS, + minos: macos(13, 0), + }; + + assert_eq!(violation(inspect(&spec)), Violation::DeploymentTarget); +} + +#[test] +fn wrong_cpu_is_rejected() { + let mut x86 = MachoSpec::conforming(); + x86.cputype = macho_format::CPU_TYPE_X86_64; + x86.cpusubtype = macho_format::CPU_SUBTYPE_X86_64_ALL; + let mut arm64e = MachoSpec::conforming(); + arm64e.cpusubtype = macho_format::CPU_SUBTYPE_ARM64E; + + for spec in [x86, arm64e] { + assert_eq!(violation(inspect(&spec)), Violation::Architecture); + } +} + +#[test] +fn fat_binary_is_rejected() { + let thin = macho(&MachoSpec::conforming()); + + let result = inspect_macho(&fat(&thin), &MachoPolicy::macos(MachoCpu::Arm64)); + + assert_eq!(violation(result), Violation::Architecture); +} + +#[test] +fn unexpected_dylib_is_rejected() { + let spec = with_command(Command::Dylib { + cmd: macho_format::LC_LOAD_DYLIB, + name: "/usr/local/lib/libexample.dylib", + }); + + assert_eq!(violation(inspect(&spec)), Violation::Library); +} + +#[test] +fn non_plain_dylib_loads_are_rejected() { + for cmd in [ + macho_format::LC_LOAD_WEAK_DYLIB, + macho_format::LC_REEXPORT_DYLIB, + macho_format::LC_LAZY_LOAD_DYLIB, + macho_format::LC_LOAD_UPWARD_DYLIB, + ] { + let spec = with_command(Command::Dylib { + cmd, + name: "/usr/lib/libSystem.B.dylib", + }); + + assert_eq!( + violation(inspect(&spec)), + Violation::LinkKind, + "should reject {cmd:#x}" + ); + } +} + +#[test] +fn rpath_is_rejected() { + let spec = with_command(Command::Rpath("@executable_path/../lib")); + + assert_eq!(violation(inspect(&spec)), Violation::SearchPath); +} + +#[test] +fn unexpected_dylinker_is_rejected() { + let mut spec = MachoSpec::conforming(); + spec.commands[1] = Command::Dylinker("/opt/example/dyld"); + + assert_eq!(violation(inspect(&spec)), Violation::Interpreter); +} + +#[test] +fn embedded_runner_path_is_rejected() { + let mut spec = MachoSpec::conforming(); + spec.payload = b"panic at /Users/runner/work/example/src/lib.rs"; + + assert_eq!(violation(inspect(&spec)), Violation::EmbeddedPath); +} + +#[test] +fn non_macho_inputs_are_rejected() { + let policy = MachoPolicy::macos(MachoCpu::Arm64); + let linux = elf(&ElfSpec::conforming()); + + assert_eq!( + violation(inspect_macho(&linux, &policy)), + Violation::Malformed + ); + assert_eq!( + violation(inspect_macho(b"not an executable", &policy)), + Violation::Malformed + ); +} + +#[test] +fn truncated_executables_are_rejected_without_panic() { + let policy = MachoPolicy::macos(MachoCpu::Arm64); + let mut spec = MachoSpec::conforming(); + spec.payload = b""; + + assert_truncations_rejected(&macho(&spec), |bytes| inspect_macho(bytes, &policy)); +} diff --git a/crates/trusted-server-release/src/inspect/tests/wasm.rs b/crates/trusted-server-release/src/inspect/tests/wasm.rs new file mode 100644 index 0000000000..3d6f52499a --- /dev/null +++ b/crates/trusted-server-release/src/inspect/tests/wasm.rs @@ -0,0 +1,127 @@ +//! Core Wasm inspection tests. + +use super::builders::{section, wasm_module}; +use super::*; + +#[test] +fn valid_core_module_is_accepted_with_observed_imports() { + let bytes = wasm_module(&[ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "send"), + ("fastly_http_req", "body_downstream_get"), + ]); + + let facts = inspect_wasm(&bytes).expect("should accept a Fastly core module"); + + assert_eq!( + facts.import_modules(), + ["fastly_http_req", "wasi_snapshot_preview1"], + "should record distinct sorted import modules" + ); + assert_eq!( + facts.function_imports(), + 3, + "should count every function import" + ); + assert_eq!( + facts.sha256(), + sha256_hex(&bytes), + "should hash the inspected bytes" + ); +} + +#[test] +fn import_free_module_is_accepted() { + let facts = inspect_wasm(&wasm_module(&[])).expect("should accept an empty module"); + + assert!( + facts.import_modules().is_empty(), + "should record no imports" + ); +} + +#[test] +fn unknown_import_module_is_rejected() { + for module in [ + "wasi:http/types@0.3.0", + "spin:example/key-value", + "env", + "fastly_example", + ] { + let bytes = wasm_module(&[("wasi_snapshot_preview1", "fd_write"), (module, "call")]); + + assert_eq!( + violation(inspect_wasm(&bytes)), + Violation::WasmImport, + "should reject {module}" + ); + } +} + +#[test] +fn non_function_import_is_rejected() { + let mut bytes = b"\0asm\x01\0\0\0".to_vec(); + let mut body = vec![1]; + for name in ["fastly_log", "memory"] { + body.push(u8::try_from(name.len()).expect("should fit")); + body.extend_from_slice(name.as_bytes()); + } + body.extend_from_slice(&[2, 0, 1]); + section(&mut bytes, 2, &body); + + assert_eq!(violation(inspect_wasm(&bytes)), Violation::WasmImport); +} + +#[test] +fn component_is_rejected() { + assert_eq!( + violation(inspect_wasm(b"\0asm\x0d\0\x01\0")), + Violation::WasmEncoding, + "should reject a component before validation" + ); +} + +#[test] +fn non_wasm_is_rejected() { + for bytes in [ + &b""[..], + b"\0asm", + b"\0asm\x02\0\0\0", + b"\x7fELF\x02\x01\x01\0", + ] { + assert_eq!(violation(inspect_wasm(bytes)), Violation::WasmEncoding); + } +} + +#[test] +fn invalid_core_module_is_rejected() { + let mut import_without_type = b"\0asm\x01\0\0\0".to_vec(); + section(&mut import_without_type, 2, &[1, 1, b'a', 1, b'b', 0, 0]); + let mut unknown_section = b"\0asm\x01\0\0\0".to_vec(); + section(&mut unknown_section, 99, &[0]); + + for bytes in [import_without_type, unknown_section] { + assert_eq!(violation(inspect_wasm(&bytes)), Violation::WasmValidation); + } +} + +/// Prefix lengths that end on a section boundary and so form a valid module: +/// the bare header and the header plus the type section. +const SECTION_BOUNDARIES: [usize; 2] = [8, 14]; + +#[test] +fn truncated_modules_are_handled_without_panic() { + let bytes = wasm_module(&[ + ("fastly_log", "write"), + ("wasi_snapshot_preview1", "fd_write"), + ]); + + for length in 0..bytes.len() { + let truncated = &bytes[..length]; + let result = inspect_wasm(truncated); + assert!( + result.is_err() || SECTION_BOUNDARIES.contains(&length), + "should reject the module truncated to {length} bytes" + ); + } +} diff --git a/crates/trusted-server-release/src/inspect/wasm.rs b/crates/trusted-server-release/src/inspect/wasm.rs new file mode 100644 index 0000000000..327acf1c74 --- /dev/null +++ b/crates/trusted-server-release/src/inspect/wasm.rs @@ -0,0 +1,102 @@ +//! Core WebAssembly inspection for the Fastly Compute artifact. + +use std::collections::BTreeSet; + +use error_stack::Report; +use wasmparser::{Parser, Payload, TypeRef, Validator, WasmFeatures}; + +use super::{Violation, WasmFacts, rejected, sha256_hex}; +use crate::error::ReleaseError; + +/// Reviewed import modules of the Fastly Compute host ABI. +/// +/// Observed in a local release build of `trusted-server-adapter-fastly`: +/// WASI preview 1 plus these `fastly_*` hostcall modules. A new module is a +/// host-ABI change and needs review before it is added. +pub(crate) const FASTLY_IMPORT_MODULES: &[&str] = &[ + "fastly_cache", + "fastly_compute_runtime", + "fastly_config_store", + "fastly_erl", + "fastly_geo", + "fastly_http_body", + "fastly_http_cache", + "fastly_http_downstream", + "fastly_http_req", + "fastly_http_resp", + "fastly_image_optimizer", + "fastly_kv_store", + "fastly_log", + "fastly_purge", + "fastly_secret_store", + "wasi_snapshot_preview1", +]; + +/// Pinned validation feature set: the WebAssembly 2.0 specification. +const FEATURES: WasmFeatures = WasmFeatures::WASM2; + +/// Inspects a final Fastly Compute Wasm artifact. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] with a [`Violation`] when the bytes are +/// a component or not a core module ([`Violation::WasmEncoding`]), fail +/// validation under [`WasmFeatures::WASM2`] ([`Violation::WasmValidation`]), +/// or import a non-function item or from a module outside +/// [`FASTLY_IMPORT_MODULES`] ([`Violation::WasmImport`]). +pub(crate) fn inspect_wasm(bytes: &[u8]) -> Result> { + if Parser::is_component(bytes) { + return Err(rejected( + Violation::WasmEncoding, + "component, not a core module", + )); + } + if !Parser::is_core_wasm(bytes) { + return Err(rejected( + Violation::WasmEncoding, + "missing core module header", + )); + } + Validator::new_with_features(FEATURES) + .validate_all(bytes) + .map_err(|error| rejected(Violation::WasmValidation, error))?; + let (import_modules, function_imports) = imports(bytes)?; + if let Some(module) = import_modules + .iter() + .find(|module| !FASTLY_IMPORT_MODULES.contains(&module.as_str())) + { + return Err(rejected( + Violation::WasmImport, + format!("import module {module:?}"), + )); + } + Ok(WasmFacts { + sha256: sha256_hex(bytes), + import_modules: import_modules.into_iter().collect(), + function_imports, + }) +} + +/// Returns the distinct import modules and the function import count. +fn imports(bytes: &[u8]) -> Result<(BTreeSet, usize), Report> { + let parse_error = |error| rejected(Violation::WasmValidation, error); + let mut modules = BTreeSet::new(); + let mut functions = 0; + for payload in Parser::new(0).parse_all(bytes) { + let Payload::ImportSection(reader) = payload.map_err(parse_error)? else { + continue; + }; + for import in reader.into_imports() { + let import = import.map_err(parse_error)?; + if !matches!(import.ty, TypeRef::Func(_)) { + return Err(rejected( + Violation::WasmImport, + format!("non-function import {}::{}", import.module, import.name), + )); + } + modules.insert(import.module.to_owned()); + functions += 1; + } + } + Ok((modules, functions)) +} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 0f2e427684..840cd91786 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -1,7 +1,14 @@ +// consumed by Task 7 preparation and Task 9 recipes +#[allow(dead_code)] +mod boundary; // Atomic output and schema helpers are consumed by later producer stages. #[allow(dead_code)] mod documents; mod error; +#[allow(dead_code, reason = "consumed by Task 9 recipes")] +mod inspect; +#[allow(dead_code, reason = "consumed by Task 7 material preparation")] +mod materials; mod policy; mod preflight; // `expect` would be unfulfilled in the test build, where the tests use it. @@ -49,6 +56,19 @@ enum Command { }, /// Diagnostic report of pending inputs; constructs no authorization. AuditInputs, + /// Network-denial socket probe used by the boundary controls. + BoundaryProbe { + #[command(subcommand)] + probe: ProbeCommand, + }, +} + +#[derive(Subcommand)] +enum ProbeCommand { + /// Attempts one TCP connect to a literal loopback `IP:PORT`, prints + /// `connected` (exit 0) or `denied errno=` (exit 3), and does nothing + /// else. + Connect { address: String }, } /// Writes one human-readable diagnostic line to stderr. @@ -92,9 +112,28 @@ fn run( Ok(ExitCode::SUCCESS) } Command::AuditInputs => audit_inputs(), + Command::BoundaryProbe { + probe: ProbeCommand::Connect { address }, + } => boundary_probe(&address), } } +/// Runs the single-connect probe and reports its real outcome. +fn boundary_probe(address: &str) -> Result> { + let address = boundary::parse_loopback(address)?; + let report = boundary::connect_probe(address)?; + std::io::stdout() + .write_all(format!("{report}\n").as_bytes()) + .map_err(|error| { + Report::new(ReleaseError::Boundary) + .attach(format!("cannot write probe report: {error}")) + })?; + Ok(match report { + boundary::ProbeReport::Connected => ExitCode::SUCCESS, + boundary::ProbeReport::Denied { .. } => ExitCode::from(boundary::DENIED_STATUS), + }) +} + fn audit_inputs() -> Result> { let root = preflight::source_root()?; let inputs = policy::load_inputs(&root)?; diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs new file mode 100644 index 0000000000..8a6a6c47d2 --- /dev/null +++ b/crates/trusted-server-release/src/materials.rs @@ -0,0 +1,118 @@ +//! Original dependency identities derived from the independently approved source. +//! +//! Lockfile checksums authenticate original archive bytes. They do not prove +//! extraction, installation, lifecycle execution, tool qualification or JS +//! build completion. + +mod archive; +mod locked; + +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::documents::Role; +use crate::error::ReleaseError; +use crate::process::Cancellation; +use crate::source::ApprovedSource; +pub(crate) use archive::VerifiedOriginal; +use locked::{CargoMaterials, NpmMaterial, parse_cargo, parse_npm}; + +const NPM_LOCK: &str = "crates/trusted-server-js/lib/package-lock.json"; + +fn material_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Material).attach(detail.to_string()) +} + +/// Expected original dependency identities, bound to approved lockfile bytes. +/// +/// This is not a completed installation or a qualified tool selection. +pub(crate) struct LockedMaterials { + source_commit: String, + cargo_lock_sha256: String, + npm_lock_sha256: String, + cargo: CargoMaterials, + npm: BTreeMap, +} + +impl LockedMaterials { + /// Loads only the approved tracked lockfiles and derives their identities. + /// + /// # Errors + /// + /// Returns a material/source/document error if either lockfile changed, + /// has an unsupported format or cannot identify an original acquisition. + pub(crate) fn from_source(source: &ApprovedSource) -> Result> { + let cargo = source.read_tracked("Cargo.lock", Role::Metadata)?; + let npm = source.read_tracked(NPM_LOCK, Role::Metadata)?; + Ok(Self { + source_commit: source.commit().as_str().to_owned(), + cargo_lock_sha256: hex::encode(Sha256::digest(&cargo)), + npm_lock_sha256: hex::encode(Sha256::digest(&npm)), + cargo: parse_cargo(&cargo)?, + npm: parse_npm(&npm)?, + }) + } + + /// Verifies an original npm archive selected by its locked installation path. + /// + /// Optional packages receive the same checksum checks as every other package. + /// + /// # Errors + /// + /// Returns a material error for an unknown location or changed archive. + pub(crate) fn verify_npm( + &self, + location: &str, + path: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let locked = self + .npm + .get(location) + .ok_or_else(|| material_error("npm archive is not in the approved lockfile"))?; + archive::verify( + path, + archive::Expected::Sha512(&locked.sha512), + &self.source_commit, + &format!("npm:{location}"), + deadline, + cancellation, + ) + } + + /// Verifies an original registry crate selected by its exact locked identity. + /// + /// # Errors + /// + /// Returns a material error for an unknown identity or changed archive. + pub(crate) fn verify_registry( + &self, + name: &str, + version: &str, + path: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let locked = self + .cargo + .registry + .get(&(name.to_owned(), version.to_owned())) + .ok_or_else(|| material_error("crate archive is not in the approved lockfile"))?; + archive::verify( + path, + archive::Expected::Sha256(&locked.sha256), + &self.source_commit, + &format!("crate:{name}@{version}"), + deadline, + cancellation, + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/archive.rs b/crates/trusted-server-release/src/materials/archive.rs new file mode 100644 index 0000000000..7782bbab85 --- /dev/null +++ b/crates/trusted-server-release/src/materials/archive.rs @@ -0,0 +1,198 @@ +//! Checks original bytes and retains a private copy, without extracting them. + +use std::fs::{File, Metadata, OpenOptions}; +use std::io::{Read as _, Seek as _, Write}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256, Sha512}; + +use super::material_error; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +/// Original dependency archives are distinct from toolchain distributions. +/// The current npm/crate material stage accepts at most 64 MiB per original. +const ARCHIVE_LIMIT: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Copy)] +pub(super) enum Expected<'a> { + Sha256(&'a str), + Sha512(&'a [u8; 64]), +} + +fn stamp(metadata: &Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +/// Exact verified original bytes, retained independently of their input path. +/// +/// No deserialization or caller-supplied digest constructor is available. +pub(crate) struct VerifiedOriginal { + file: File, + source_commit: String, + identity: String, + sha256: String, + length: u64, + deadline: Instant, + cancellation: Cancellation, +} + +fn check_limits( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error( + "original material verification cancelled or timed out", + )); + } + Ok(()) +} + +impl VerifiedOriginal { + /// Copies the retained original after checking its exact bytes again. + /// + /// # Errors + /// + /// Returns a material error if retained bytes changed or copying fails. + pub(crate) fn copy_verified( + &mut self, + destination: &mut impl Write, + ) -> Result<(), Report> { + check_limits(self.deadline, &self.cancellation)?; + self.file.rewind().map_err(material_error)?; + let mut hasher = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check_limits(self.deadline, &self.cancellation)?; + let count = self.file.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + length += count as u64; + if length > self.length { + return Err(material_error("retained original grew")); + } + hasher.update(&buffer[..count]); + } + if length != self.length || hex::encode(hasher.finalize()) != self.sha256 { + return Err(material_error("retained original changed")); + } + self.file.rewind().map_err(material_error)?; + let mut copied = 0_u64; + loop { + check_limits(self.deadline, &self.cancellation)?; + let count = self.file.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + copied += count as u64; + if copied > self.length { + return Err(material_error("retained original grew while copying")); + } + destination + .write_all(&buffer[..count]) + .map_err(material_error)?; + } + if copied != self.length { + return Err(material_error("retained original changed while copying")); + } + check_limits(self.deadline, &self.cancellation)?; + Ok(()) + } + + /// Returns the approved source that supplies this archive's expected identity. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + + /// Returns the original's source-bound package or installation identity. + pub(crate) fn identity(&self) -> &str { + &self.identity + } + + /// Returns the SHA-256 of the exact retained distribution bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } +} + +pub(super) fn verify( + path: &Path, + expected: Expected<'_>, + source_commit: &str, + identity: &str, + deadline: Instant, + cancellation: &Cancellation, +) -> Result> { + check_limits(deadline, cancellation)?; + let mut input = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(material_error)?; + let before = input.metadata().map_err(material_error)?; + if !before.is_file() || before.nlink() != 1 || before.len() > ARCHIVE_LIMIT { + return Err(material_error( + "original must be a bounded regular single-link file", + )); + } + let mut file = tempfile::tempfile().map_err(material_error)?; + let mut sha256 = Sha256::new(); + let mut sha512 = Sha512::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check_limits(deadline, cancellation)?; + let count = input.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + length += count as u64; + if length > ARCHIVE_LIMIT { + return Err(material_error("original exceeds archive limit")); + } + sha256.update(&buffer[..count]); + sha512.update(&buffer[..count]); + file.write_all(&buffer[..count]).map_err(material_error)?; + } + if length != before.len() || stamp(&before) != stamp(&input.metadata().map_err(material_error)?) + { + return Err(material_error( + "original changed while reading its held descriptor", + )); + } + let digest = hex::encode(sha256.finalize()); + let matches = match expected { + Expected::Sha256(wanted) => digest == wanted, + Expected::Sha512(wanted) => sha512.finalize()[..] == wanted[..], + }; + if !matches { + return Err(material_error( + "original archive differs from its approved lockfile identity", + )); + } + check_limits(deadline, cancellation)?; + Ok(VerifiedOriginal { + file, + source_commit: source_commit.to_owned(), + identity: identity.to_owned(), + sha256: digest, + length, + deadline, + cancellation: cancellation.clone(), + }) +} diff --git a/crates/trusted-server-release/src/materials/locked.rs b/crates/trusted-server-release/src/materials/locked.rs new file mode 100644 index 0000000000..57b7f61c33 --- /dev/null +++ b/crates/trusted-server-release/src/materials/locked.rs @@ -0,0 +1,243 @@ +//! Parses identities without granting acquisition or execution authority. + +use std::collections::{BTreeMap, BTreeSet}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use error_stack::Report; +use serde_json::Value; + +use super::material_error; +use crate::documents::{Role, lower_hex, parse_json}; +use crate::error::ReleaseError; + +// This runtime acquisition identity must match the approved lockfile registry. +pub(super) const NPM_REGISTRY: &str = "https://registry.npmjs.org/"; // allow-domain: registry.npmjs.org +const CARGO_REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; + +/// One original npm distribution, retaining its installation-path join. +pub(super) struct NpmMaterial { + pub(super) name: String, + pub(super) version: String, + pub(super) resolved: String, + pub(super) sha512: [u8; 64], +} + +/// One registry crate's exact original distribution identity. +pub(super) struct RegistryMaterial { + pub(super) name: String, + pub(super) version: String, + pub(super) sha256: String, +} + +/// Cargo acquisition expectations, not verified dependency trees. +pub(super) struct CargoMaterials { + pub(super) registry: BTreeMap<(String, String), RegistryMaterial>, + pub(super) git: BTreeSet<(String, String)>, +} + +fn text<'a>(value: &'a Value, field: &str) -> Result<&'a str, Report> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| material_error(format!("locked npm package has no {field}"))) +} + +fn integrity(value: &str) -> Result<[u8; 64], Report> { + let encoded = value + .strip_prefix("sha512-") + .ok_or_else(|| material_error("npm archive requires one SHA-512 integrity"))?; + let bytes = STANDARD.decode(encoded).map_err(material_error)?; + if STANDARD.encode(&bytes) != encoded { + return Err(material_error("npm integrity is not canonical base64")); + } + bytes + .try_into() + .map_err(|_| material_error("npm SHA-512 identity must have 64 bytes")) +} + +fn component(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn npm_name(name: &str) -> bool { + let parts: Vec<&str> = name.split('/').collect(); + match parts.as_slice() { + [name] => component(name), + [scope, name] => scope.strip_prefix('@').is_some_and(component) && component(name), + _ => false, + } +} + +pub(super) fn parse_npm( + bytes: &[u8], +) -> Result, Report> { + let document = parse_json(bytes, Role::Metadata)?; + if document.get("lockfileVersion").and_then(Value::as_u64) != Some(3) { + return Err(material_error("npm lockfile must use version 3")); + } + let packages = document + .get("packages") + .and_then(Value::as_object) + .ok_or_else(|| material_error("npm lockfile has no package table"))?; + let mut result = BTreeMap::new(); + for (path, package) in packages { + if path.is_empty() { + continue; + } + if !path.starts_with("node_modules/") + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || path.chars().any(char::is_control) + { + return Err(material_error( + "npm installation path is not a safe locked location", + )); + } + let locations = path + .strip_prefix("node_modules/") + .ok_or_else(|| material_error("missing locked installation prefix"))?; + if locations + .split("/node_modules/") + .any(|name| !npm_name(name)) + { + return Err(material_error("invalid nested npm installation location")); + } + let inferred = path + .rsplit("node_modules/") + .next() + .ok_or_else(|| material_error("missing npm package name"))?; + let name = package + .get("name") + .map(|value| { + value + .as_str() + .ok_or_else(|| material_error("locked npm name must be a string")) + }) + .transpose()? + .unwrap_or(inferred); + if name != inferred { + return Err(material_error( + "npm aliases are outside the supported material contract", + )); + } + let linked = package + .get("link") + .map(|value| { + value + .as_bool() + .ok_or_else(|| material_error("locked npm link selector must be a boolean")) + }) + .transpose()? + .unwrap_or(false); + let version = text(package, "version")?; + if !npm_name(name) + || !version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')) + { + return Err(material_error( + "npm identity contains unsupported path or version characters", + )); + } + let resolved = text(package, "resolved")?; + let basename = name + .rsplit('/') + .next() + .ok_or_else(|| material_error("invalid npm package name"))?; + let expected = format!("{NPM_REGISTRY}{name}/-/{basename}-{version}.tgz"); + if resolved != expected || linked { + return Err(material_error( + "npm distribution is not its canonical registry archive", + )); + } + result.insert( + path.clone(), + NpmMaterial { + name: name.to_owned(), + version: version.to_owned(), + resolved: resolved.to_owned(), + sha512: integrity(text(package, "integrity")?)?, + }, + ); + } + Ok(result) +} + +pub(super) fn parse_cargo(bytes: &[u8]) -> Result> { + let text = std::str::from_utf8(bytes).map_err(material_error)?; + let lock: toml::Value = toml::from_str(text).map_err(material_error)?; + if lock.get("version").and_then(toml::Value::as_integer) != Some(4) { + return Err(material_error("Cargo lockfile must use version 4")); + } + let packages = lock + .get("package") + .and_then(toml::Value::as_array) + .ok_or_else(|| material_error("Cargo lockfile has no package array"))?; + let mut result = CargoMaterials { + registry: BTreeMap::new(), + git: BTreeSet::new(), + }; + let mut identities = BTreeSet::new(); + for package in packages { + let field = |key| { + package + .get(key) + .and_then(toml::Value::as_str) + .ok_or_else(|| material_error(format!("Cargo package has no {key}"))) + }; + let name = field("name")?; + let version = field("version")?; + let source = package + .get("source") + .map(|value| { + value + .as_str() + .ok_or_else(|| material_error("Cargo source must be a string")) + }) + .transpose()?; + if !identities.insert((name, version, source)) { + return Err(material_error("duplicate Cargo package identity")); + } + match source { + None => {} + Some(CARGO_REGISTRY) => { + let checksum = field("checksum")?; + if !lower_hex(checksum, 64) { + return Err(material_error("registry archive has no exact SHA-256")); + } + result.registry.insert( + (name.to_owned(), version.to_owned()), + RegistryMaterial { + name: name.to_owned(), + version: version.to_owned(), + sha256: checksum.to_owned(), + }, + ); + } + Some(source) if source.starts_with("git+https://") => { + let (repository, commit) = source + .rsplit_once('#') + .ok_or_else(|| material_error("Git material lacks a locked revision"))?; + if !lower_hex(commit, 40) || repository.chars().any(char::is_control) { + return Err(material_error( + "Git material requires a full lowercase revision", + )); + } + result + .git + .insert((repository.to_owned(), commit.to_owned())); + } + Some(_) => return Err(material_error("unsupported Cargo material source")), + } + } + Ok(result) +} diff --git a/crates/trusted-server-release/src/materials/tests.rs b/crates/trusted-server-release/src/materials/tests.rs new file mode 100644 index 0000000000..efcb61cd98 --- /dev/null +++ b/crates/trusted-server-release/src/materials/tests.rs @@ -0,0 +1,328 @@ +use crate::process::Cancellation; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use serde_json::json; +use sha2::{Digest as _, Sha512}; +use std::ffi::CString; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::symlink; +use std::time::{Duration, Instant}; + +use super::*; + +fn npm_lock(integrity: &str) -> Vec { + serde_json::to_vec(&json!({ + "lockfileVersion": 3, + "packages": { + "": {"name": "fictional-project"}, + "node_modules/fictional-package": { + "version": "1.0.0", + "resolved": format!("{}fictional-package/-/fictional-package-1.0.0.tgz", locked::NPM_REGISTRY), + "integrity": integrity, + "optional": true + } + } + })).expect("should encode fictional lockfile") +} + +#[test] +fn npm_integrity_requires_one_canonical_sha512_identity() { + for integrity in [ + "", + "sha256-AA==", + "sha512-AA==", + "sha512-???", + "sha512-AA== sha512-AA==", + ] { + assert!( + parse_npm(&npm_lock(integrity)).is_err(), + "should reject missing, ambiguous or unsupported archive integrity" + ); + } +} + +#[test] +fn cargo_git_revision_cannot_be_only_a_tag() { + let lock = b"version = 4\n[[package]]\nname = \"fictional\"\nversion = \"1.0.0\"\nsource = \"git+https://example.com/fictional?tag=v1\"\n"; + assert!( + parse_cargo(lock).is_err(), + "should require a full locked Git revision" + ); +} + +#[test] +fn cargo_material_source_cannot_be_mistyped_as_a_local_package() { + let lock = + b"version = 4\n[[package]]\nname = \"fictional\"\nversion = \"1.0.0\"\nsource = 42\n"; + assert!( + parse_cargo(lock).is_err(), + "should reject malformed acquisition identity" + ); +} + +#[test] +fn npm_names_cannot_change_the_registry_path() { + let integrity = format!("sha512-{}", STANDARD.encode([0_u8; 64])); + let mut lock: serde_json::Value = + serde_json::from_slice(&npm_lock(&integrity)).expect("should parse fictional lockfile"); + lock["packages"]["node_modules/fictional-package"]["name"] = json!("../fictional-package"); + lock["packages"]["node_modules/fictional-package"]["resolved"] = json!(format!( + "{}../fictional-package/-/fictional-package-1.0.0.tgz", + locked::NPM_REGISTRY + )); + assert!( + parse_npm(&serde_json::to_vec(&lock).expect("should encode fixture")).is_err(), + "should reject traversal in the declared package identity" + ); +} + +#[test] +fn source_lockfiles_account_for_each_supported_acquisition_class() { + let cargo = parse_cargo(include_bytes!("../../../../Cargo.lock")) + .expect("should parse the source-controlled Cargo lock"); + assert!( + !cargo.registry.is_empty(), + "should retain registry originals" + ); + assert!( + !cargo.git.is_empty(), + "should retain exact Git revisions as unverified expectations" + ); + let npm = parse_npm(include_bytes!( + "../../../trusted-server-js/lib/package-lock.json" + )) + .expect("should parse the source-controlled npm lock"); + for package in [ + "node_modules/@esbuild/darwin-arm64", + "node_modules/@esbuild/darwin-x64", + "node_modules/@esbuild/linux-x64", + ] { + assert!( + npm.contains_key(package), + "should retain selected native esbuild originals despite optional flags" + ); + } +} + +#[test] +fn optional_npm_original_still_requires_its_exact_bytes() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let original = directory.path().join("original.tgz"); + let bytes = b"fictional original archive bytes"; + std::fs::write(&original, bytes).expect("should create fictional original"); + let packages = parse_npm(&npm_lock(&format!( + "sha512-{}", + STANDARD.encode(Sha512::digest(bytes)) + ))) + .expect("should parse locked archive"); + let deadline = Instant::now() + Duration::from_secs(5); + let expected = &packages["node_modules/fictional-package"].sha512; + let mut retained = archive::verify( + &original, + archive::Expected::Sha512(expected), + "fictional-source", + "npm:node_modules/fictional-package", + deadline, + &Cancellation::default(), + ) + .expect("should verify the exact original"); + let mut changed = bytes.to_vec(); + changed[0] ^= 1; + std::fs::write(&original, changed).expect("should substitute one byte at the same path"); + assert!( + archive::verify( + &original, + archive::Expected::Sha512(expected), + "fictional-source", + "npm:node_modules/fictional-package", + deadline, + &Cancellation::default() + ) + .is_err(), + "should reject a changed original even when its filename is unchanged" + ); + let mut copy = Vec::new(); + retained + .copy_verified(&mut copy) + .expect("should retain the original verified bytes"); + assert_eq!( + copy, bytes, + "should consume the held private bytes rather than reopening the replaced path" + ); +} + +#[test] +fn npm_selectors_and_nested_locations_are_not_ambiguous() { + let integrity = format!("sha512-{}", STANDARD.encode([0_u8; 64])); + for (field, value) in [ + ("name", json!(42)), + ("name", json!("another-package")), + ("link", json!("false")), + ] { + let mut lock: serde_json::Value = + serde_json::from_slice(&npm_lock(&integrity)).expect("should parse fixture"); + lock["packages"]["node_modules/fictional-package"][field] = value; + assert!( + parse_npm(&serde_json::to_vec(&lock).expect("should encode fixture")).is_err(), + "should reject mistyped or conflicting package selectors" + ); + } + let mut lock: serde_json::Value = + serde_json::from_slice(&npm_lock(&integrity)).expect("should parse fixture"); + let package = lock["packages"] + .as_object_mut() + .expect("should have packages") + .remove("node_modules/fictional-package") + .expect("should have fictional package"); + lock["packages"]["node_modules/not/a/package/node_modules/fictional-package"] = package; + assert!( + parse_npm(&serde_json::to_vec(&lock).expect("should encode fixture")).is_err(), + "should validate every nested installation component" + ); +} + +#[test] +fn cancellation_after_verification_prevents_material_consumption() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let original = directory.path().join("original.crate"); + let bytes = b"fictional original"; + std::fs::write(&original, bytes).expect("should create fixture original"); + let cancellation = Cancellation::default(); + let expected = hex::encode(sha2::Sha256::digest(bytes)); + let mut retained = archive::verify( + &original, + archive::Expected::Sha256(&expected), + "fictional-source", + "crate:fictional-package@1.0.0", + Instant::now() + Duration::from_secs(5), + &cancellation, + ) + .expect("should verify original before cancellation"); + cancellation.cancel(); + let mut destination = Vec::new(); + assert!( + retained.copy_verified(&mut destination).is_err(), + "should retain the shared cancellation boundary through later use" + ); + assert!( + destination.is_empty(), + "should not emit cancelled material bytes" + ); +} + +#[test] +fn archive_inputs_reject_aliases_special_files_and_oversize() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let regular = directory.path().join("original"); + let alias = directory.path().join("alias"); + let hard = directory.path().join("hard-link"); + let fifo = directory.path().join("fifo"); + let huge = directory.path().join("huge"); + std::fs::write(®ular, b"fictional").expect("should write original"); + symlink(®ular, &alias).expect("should create symlink"); + std::fs::hard_link(®ular, &hard).expect("should create hard link"); + let fifo_name = CString::new(fifo.as_os_str().as_bytes()).expect("should encode FIFO path"); + // SAFETY: The path is a live NUL-terminated string and mode is valid. + assert_eq!( + unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, + 0, + "should create FIFO" + ); + std::fs::File::create(&huge) + .expect("should create sparse fixture") + .set_len(64 * 1024 * 1024 + 1) + .expect("should exceed archive limit"); + let digest = hex::encode(sha2::Sha256::digest(b"fictional")); + for path in [ + ®ular, + &alias, + &hard, + &fifo, + &huge, + &directory.path().to_path_buf(), + ] { + assert!( + archive::verify( + path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + Instant::now() + Duration::from_secs(5), + &Cancellation::default() + ) + .is_err(), + "should reject unsafe original before any blocking read" + ); + } +} + +#[test] +fn registry_original_and_attempt_bounds_are_checked_before_use() { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let path = directory.path().join("fictional.crate"); + let bytes = b"fictional original"; + std::fs::write(&path, bytes).expect("should write original"); + let digest = hex::encode(sha2::Sha256::digest(bytes)); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(5); + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + deadline, + &cancellation, + ) + .expect("should verify registry SHA-256"); + let expired = Instant::now() + .checked_sub(Duration::from_secs(1)) + .expect("should form expired deadline"); + assert!( + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + expired, + &cancellation + ) + .is_err(), + "should reject an expired attempt" + ); + cancellation.cancel(); + assert!( + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + deadline, + &cancellation + ) + .is_err(), + "should reject cancellation before reading" + ); + let mut changed = bytes.to_vec(); + changed[0] ^= 1; + std::fs::write(&path, changed).expect("should alter one original byte"); + assert!( + archive::verify( + &path, + archive::Expected::Sha256(&digest), + "fictional-source", + "crate:fictional@1.0.0", + deadline, + &Cancellation::default() + ) + .is_err(), + "should reject changed registry bytes" + ); +} + +#[test] +fn npm_lockfile_duplicate_keys_are_rejected_before_identity_selection() { + assert!( + parse_npm(br#"{"lockfileVersion":3,"packages":{},"packages":{}}"#).is_err(), + "should reject ambiguous archive selection before parsing package identities" + ); +} diff --git a/crates/trusted-server-release/src/source.rs b/crates/trusted-server-release/src/source.rs index 02769b6766..d5820cd645 100644 --- a/crates/trusted-server-release/src/source.rs +++ b/crates/trusted-server-release/src/source.rs @@ -159,6 +159,29 @@ impl ApprovedSource { .get(path) .map(|(_, sha256)| sha256.as_str()) } + + /// Reads bounded tracked bytes and checks their approved object digest. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Source`] for an untracked name, changed bytes, + /// or an input rejected by the held-descriptor document reader. + pub(crate) fn read_tracked( + &self, + path: &str, + role: Role, + ) -> Result, Report> { + let expected = self + .tracked_sha256(path) + .ok_or_else(|| source_error("material input is not an approved tracked file"))?; + let raw = read_bounded(&self.snapshot.root.join(path), role)?; + if raw.sha256 != expected { + return Err(source_error( + "tracked material input differs from the approved source", + )); + } + Ok(raw.bytes) + } } /// Authenticates the checkout at `root` as exactly the approved commit. diff --git a/crates/trusted-server-release/src/source/git.rs b/crates/trusted-server-release/src/source/git.rs index 1d1b638f3f..24adf75298 100644 --- a/crates/trusted-server-release/src/source/git.rs +++ b/crates/trusted-server-release/src/source/git.rs @@ -3,20 +3,19 @@ use std::cell::Cell; use std::ffi::OsString; use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write as _}; +use std::io::{self, BufRead, Cursor, Read as _}; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::thread; +use std::time::{Duration, Instant}; use error_stack::Report; use super::{checked_sha1, source_error}; use crate::error::ReleaseError; use crate::preflight::AuditedEnvironment; +use crate::process::{BoundedCommand, ProcessLimits, ProcessResult}; const STDERR_LIMIT: u64 = 64 * 1024; -const STDERR_SHOWN: usize = 4 * 1024; /// Largest single object read; far above any tracked source file. const OBJECT_LIMIT: u64 = 64 * 1024 * 1024; /// Total bytes of all objects read in one authentication. The tracked tree is @@ -33,6 +32,8 @@ pub(super) struct Git { home: tempfile::TempDir, /// Remaining bytes of the aggregate object budget. object_budget: Cell, + wall_limit: Duration, + attempt_deadline: Instant, } impl Git { @@ -68,20 +69,38 @@ impl Git { root: root.to_path_buf(), home, object_budget: Cell::new(AGGREGATE_OBJECT_BUDGET), + wall_limit: Duration::from_secs(120), + attempt_deadline: Instant::now() + Duration::from_secs(900), }) } - fn command(&self, args: &[&str]) -> Command { + #[cfg(test)] + pub(super) fn with_wall_limit(mut self, limit: Duration) -> Self { + self.wall_limit = limit; + self + } + + fn command( + &self, + args: &[&str], + stdout_limit: u64, + ) -> Result> { let mut git_dir = OsString::from("--git-dir="); git_dir.push(self.root.join(".git")); let mut work_tree = OsString::from("--work-tree="); work_tree.push(&self.root); - let mut command = Command::new(&self.program); - command - .arg(git_dir) - .arg(work_tree) - .arg("--no-replace-objects") - .args([ + let remaining = self + .attempt_deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| source_error("Git authentication attempt exceeded its deadline"))?; + let limits = ProcessLimits { + wall: self.wall_limit.min(remaining), + stdout_max: stdout_limit, + stderr_max: STDERR_LIMIT, + }; + let mut arguments = vec![git_dir, work_tree, OsString::from("--no-replace-objects")]; + arguments.extend( + [ "-c", "core.hooksPath=/dev/null", "-c", @@ -90,32 +109,42 @@ impl Git { "core.untrackedCache=false", "-c", "protocol.allow=never", - ]) - .args(args) - .current_dir(&self.root) - .env_clear() - .env("PATH", &self.child_path) - .env("HOME", self.home.path()) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_NO_REPLACE_OBJECTS", "1") - .env("GIT_TERMINAL_PROMPT", "0") - .env("GIT_OPTIONAL_LOCKS", "0") - .env("LC_ALL", "C") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - command + ] + .into_iter() + .chain(args.iter().copied()) + .map(OsString::from), + ); + // HOME is fresh and private and XDG_CONFIG_HOME is absent. This + // disables user configuration without an arbitrary Git config path. + BoundedCommand::new(&self.program, &self.root, limits)? + .arguments(arguments) + .environment("PATH", &self.child_path)? + .environment("HOME", self.home.path())? + .environment("GIT_CONFIG_NOSYSTEM", "1")? + .environment("GIT_NO_REPLACE_OBJECTS", "1")? + .environment("GIT_TERMINAL_PROMPT", "0")? + .environment("GIT_OPTIONAL_LOCKS", "0")? + .environment("LC_ALL", "C") } - fn spawn(&self, args: &[&str], stdin: bool) -> Result> { - let mut command = self.command(args); - if stdin { - command.stdin(Stdio::piped()); + fn execute( + &self, + args: &[&str], + stdin: Option>, + stdout_limit: u64, + ) -> Result, Report> { + let mut command = self.command(args, stdout_limit)?; + if let Some(bytes) = stdin { + command = command.stdin_bytes(bytes); } command - .spawn() - .map_err(|error| source_error(format!("cannot run git {}: {error}", args.join(" ")))) + .run() + .and_then(ProcessResult::require_success) + .map_err(|report| { + report + .change_context(ReleaseError::Source) + .attach(format!("git {}", args.join(" "))) + }) } /// Replaces the aggregate object budget so tests can exercise exhaustion. @@ -131,26 +160,7 @@ impl Git { args: &[&str], stdout_limit: u64, ) -> Result, Report> { - let mut child = self.spawn(args, false)?; - let stderr = capture_stderr(&mut child); - let mut output = Vec::new(); - let read = match child.stdout.take() { - Some(stdout) => stdout - .take(stdout_limit + 1) - .read_to_end(&mut output) - .map(|_| ()) - .map_err(|error| source_error(format!("cannot read stdout: {error}"))), - None => Err(source_error("stdout is unavailable")), - }; - let read = read.and_then(|()| { - if output.len() as u64 > stdout_limit { - Err(source_error("stdout exceeds its byte limit")) - } else { - Ok(()) - } - }); - finish(child, stderr, args, read)?; - Ok(output) + self.execute(args, None, stdout_limit) } /// Reads objects of one `kind` through `cat-file --batch`, rehashing each. @@ -164,42 +174,71 @@ impl Git { oids: &[&str], mut visit: impl FnMut(usize, &[u8]) -> Result<(), Report>, ) -> Result<(), Report> { - let args = ["cat-file", "--batch"]; - let mut child = self.spawn(&args, true)?; - let stderr = capture_stderr(&mut child); - let request: Vec = oids - .iter() - .flat_map(|oid| oid.bytes().chain(std::iter::once(b'\n'))) - .collect(); - let writer = child - .stdin - .take() - .map(|mut stdin| thread::spawn(move || stdin.write_all(&request))); - let read = match child.stdout.take() { - Some(stdout) => read_batch( - &mut BufReader::new(stdout), + let sizes = self.object_sizes(kind, oids)?; + let mut start = 0; + while start < oids.len() { + let mut end = start; + let mut limit = 0; + while end < oids.len() && limit + sizes[end] + 128 <= OBJECT_LIMIT + 128 { + limit += sizes[end] + 128; + end += 1; + } + let output = self.execute( + &["cat-file", "--batch"], + Some(request(&oids[start..end])), + limit, + )?; + let mut reader = Cursor::new(output.as_slice()); + read_batch( + &mut reader, &Batch { kind, - oids, + oids: &oids[start..end], budget: &self.object_budget, }, - &mut visit, - ), - None => Err(source_error("stdout is unavailable")), - }; - if read.is_err() { - let _ = child.kill(); + &mut |index, bytes| visit(start + index, bytes), + )?; + if reader.position() != output.len() as u64 { + return Err(source_error("unexpected trailing Git batch output")); + } + start = end; } - let written = match writer.map(thread::JoinHandle::join) { - Some(Ok(Ok(()))) => Ok(()), - Some(Ok(Err(error))) => Err(source_error(format!("cannot write stdin: {error}"))), - Some(Err(_)) => Err(source_error("stdin writer panicked")), - None => Err(source_error("stdin is unavailable")), - }; - finish(child, stderr, &args, read.and(written)) + Ok(()) + } + + fn object_sizes(&self, kind: &str, oids: &[&str]) -> Result, Report> { + let limit = (oids.len() as u64) + .checked_mul(128) + .ok_or_else(|| source_error("object header budget overflow"))?; + let output = self.execute(&["cat-file", "--batch-check"], Some(request(oids)), limit)?; + let mut reader = Cursor::new(output.as_slice()); + let mut total = 0_u64; + let mut sizes = Vec::with_capacity(oids.len()); + for oid in oids { + let size = read_header(&mut reader, kind, oid)?; + total = total + .checked_add(size) + .ok_or_else(|| source_error("object byte budget overflow"))?; + if size > OBJECT_LIMIT || total > self.object_budget.get() { + return Err(source_error( + "object exceeds the per-object or aggregate object byte budget", + )); + } + sizes.push(size); + } + if reader.position() != output.len() as u64 { + return Err(source_error("unexpected trailing Git object metadata")); + } + Ok(sizes) } } +fn request(oids: &[&str]) -> Vec { + oids.iter() + .flat_map(|oid| oid.bytes().chain(std::iter::once(b'\n'))) + .collect() +} + /// Collision-checked Git SHA-1 object id of `bytes` stored as an object of `kind`. fn object_id(kind: &str, bytes: &[u8]) -> Result> { let header = format!("{kind} {}\0", bytes.len()); @@ -270,56 +309,3 @@ fn read_header( ))), } } - -type StderrCapture = Option>>; - -fn capture_stderr(child: &mut Child) -> StderrCapture { - child.stderr.take().map(|stderr| { - thread::spawn(move || { - let mut captured = Vec::new(); - let mut stderr = stderr; - let _ = (&mut stderr).take(STDERR_LIMIT).read_to_end(&mut captured); - let _ = io::copy(&mut stderr, &mut io::sink()); - captured - }) - }) -} - -/// Reaps the child and attaches its bounded stderr to any failure. -fn finish( - mut child: Child, - stderr: StderrCapture, - args: &[&str], - read: Result<(), Report>, -) -> Result<(), Report> { - if read.is_err() { - let _ = child.kill(); - } - // Task 7 bounded process module replaces this unbounded wait. - let status = child - .wait() - .map_err(|error| source_error(format!("cannot wait for git: {error}")))?; - let detail = bounded_detail(stderr); - let context = format!("git {}; stderr: {detail}", args.join(" ")); - read.map_err(|report| report.attach(context.clone()))?; - if !status.success() { - return Err(source_error(format!("{context}; exit {status}"))); - } - Ok(()) -} - -fn bounded_detail(stderr: StderrCapture) -> String { - let detail = stderr - .and_then(|handle| handle.join().ok()) - .unwrap_or_default(); - let mut detail = String::from_utf8_lossy(&detail).trim().to_owned(); - if detail.len() > STDERR_SHOWN { - let mut cut = STDERR_SHOWN; - while !detail.is_char_boundary(cut) { - cut -= 1; - } - detail.truncate(cut); - detail.push_str(" [truncated]"); - } - detail -} diff --git a/crates/trusted-server-release/src/source/tests.rs b/crates/trusted-server-release/src/source/tests.rs index 531c95c924..5396fb29ab 100644 --- a/crates/trusted-server-release/src/source/tests.rs +++ b/crates/trusted-server-release/src/source/tests.rs @@ -189,4 +189,5 @@ fn assert_rejects(fixture: &Fixture, needle: &str) { mod closure; mod layout; mod objects; +mod process; mod worktree; diff --git a/crates/trusted-server-release/src/source/tests/closure.rs b/crates/trusted-server-release/src/source/tests/closure.rs index 4c4f3afa98..f9aba350fb 100644 --- a/crates/trusted-server-release/src/source/tests/closure.rs +++ b/crates/trusted-server-release/src/source/tests/closure.rs @@ -2,6 +2,77 @@ use super::*; +#[test] +fn material_lockfiles_are_read_only_as_approved_tracked_bytes() { + let mut fixture = Fixture::new(); + let original_bytes = b"fictional original registry distribution"; + let checksum = hex::encode(Sha256::digest(original_bytes)); + let registry_line = include_str!("../../../../../Cargo.lock") + .lines() + .find(|line| line.starts_with("source = \"registry+")) + .expect("should derive the reviewed registry identity from the source lock"); + let lock = format!( + "version = 4\n[[package]]\nname = \"fictional\"\nversion = \"1.0.0\"\n{registry_line}\nchecksum = \"{checksum}\"\n" + ); + write(&fixture.root, "Cargo.lock", lock.as_bytes()); + write( + &fixture.root, + "crates/trusted-server-js/lib/package-lock.json", + br#"{"lockfileVersion":3,"packages":{}}"#, + ); + fixture.commit_all(); + let source = fixture + .authenticate() + .expect("should authenticate fixture source"); + let materials = crate::materials::LockedMaterials::from_source(&source) + .expect("should read approved lockfiles"); + let original = fixture.parent.path().join("fictional.crate"); + fs::write(&original, original_bytes).expect("should create original outside source roots"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let cancellation = crate::process::Cancellation::default(); + let retained = materials + .verify_registry("fictional", "1.0.0", &original, deadline, &cancellation) + .expect("should authenticate original from the approved lockfile"); + assert_eq!( + retained.source_commit(), + fixture.commit, + "should bind the original to approved source" + ); + assert_eq!( + retained.identity(), + "crate:fictional@1.0.0", + "should retain locked package identity" + ); + assert_eq!( + retained.sha256(), + checksum, + "should retain exact original archive bytes" + ); + assert!( + materials + .verify_registry("unknown", "1.0.0", &original, deadline, &cancellation) + .is_err(), + "should reject an archive absent from approved registry materials" + ); + assert!( + materials + .verify_npm("node_modules/unknown", &original, deadline, &cancellation) + .is_err(), + "should reject an archive absent from approved npm locations" + ); + assert!( + source + .read_tracked("../Cargo.lock", Role::Metadata) + .is_err(), + "should reject paths outside the approved tracked names" + ); + write(&fixture.root, "Cargo.lock", b"version = 4\n"); + assert!( + crate::materials::LockedMaterials::from_source(&source).is_err(), + "should reject lockfile changes after source authentication" + ); +} + #[test] fn closure_requires_every_listed_member_manifest() { let mut fixture = Fixture::new(); diff --git a/crates/trusted-server-release/src/source/tests/process.rs b/crates/trusted-server-release/src/source/tests/process.rs new file mode 100644 index 0000000000..13703bd10b --- /dev/null +++ b/crates/trusted-server-release/src/source/tests/process.rs @@ -0,0 +1,31 @@ +//! Git plumbing uses the same bounded child supervision as release recipes. + +use std::time::{Duration, Instant}; + +use super::*; + +#[test] +fn git_plumbing_deadline_terminates_the_child_group() { + let fixture = Fixture::new(); + let tools = fixture.parent.path().join("fake-tools"); + fs::create_dir(&tools).expect("should create fixture tools"); + let fake = tools.join("git"); + fs::write(&fake, b"#!/bin/sh\n/bin/sleep 2\n") + .expect("should create finite hanging git fixture"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)) + .expect("should make fixture executable"); + let environment = audit_environment(&[(OsString::from("PATH"), tools.into_os_string())]) + .expect("should audit fixture environment"); + let git = Git::locate(&fixture.root, &environment) + .expect("should locate fixture git") + .with_wall_limit(Duration::from_millis(50)); + let started = Instant::now(); + assert!( + git.run(&["rev-parse", "HEAD"], 1024).is_err(), + "should reject a child that exceeds the Git deadline" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "should terminate the sleeping child group before its normal exit" + ); +} diff --git a/crates/trusted-server-release/tests/boundary.rs b/crates/trusted-server-release/tests/boundary.rs new file mode 100644 index 0000000000..117bcdfb5a --- /dev/null +++ b/crates/trusted-server-release/tests/boundary.rs @@ -0,0 +1,141 @@ +//! Public process tests for the `boundary-probe connect` subcommand. +//! +//! The probe makes one TCP connect to a literal loopback `IP:PORT`, prints +//! `connected` (exit 0) or `denied errno=` (exit 3) with the real errno, +//! and does nothing else. + +use std::io::ErrorKind; +use std::net::TcpListener; +use std::process::{Command, Output}; + +const RELEASE_BIN: &str = env!("CARGO_BIN_EXE_trusted-server-release"); +const DENIED_STATUS: i32 = 3; + +fn probe(address: &str) -> Output { + Command::new(RELEASE_BIN) + .args(["boundary-probe", "connect", address]) + .env_clear() + .output() + .expect("should run the release binary") +} + +fn listener() -> TcpListener { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind listener"); + listener + .set_nonblocking(true) + .expect("should make listener non-blocking"); + listener +} + +fn accepted(listener: &TcpListener) -> usize { + let mut count = 0; + loop { + match listener.accept() { + Ok(_) => count += 1, + Err(error) if error.kind() == ErrorKind::WouldBlock => return count, + Err(error) => { + assert!( + error.kind() == ErrorKind::Interrupted, + "should accept: {error}" + ); + } + } + } +} + +#[test] +fn probe_connects_to_a_loopback_listener() { + let listener = listener(); + let address = listener + .local_addr() + .expect("should read address") + .to_string(); + let output = probe(&address); + assert_eq!( + output.status.code(), + Some(0), + "should exit 0 when connected" + ); + assert_eq!( + output.stdout, b"connected\n", + "should print exactly one report" + ); + assert_eq!( + accepted(&listener), + 1, + "should complete exactly one handshake" + ); +} + +#[test] +fn probe_reports_the_real_refusal_errno() { + let address = { + let closed = TcpListener::bind("127.0.0.1:0").expect("should bind"); + closed + .local_addr() + .expect("should read address") + .to_string() + }; + let output = probe(&address); + assert_eq!( + output.status.code(), + Some(DENIED_STATUS), + "should exit 3 when refused" + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("denied errno={}\n", libc::ECONNREFUSED), + "should report the operating system's errno" + ); +} + +#[test] +fn probe_rejects_non_loopback_and_malformed_addresses() { + for address in ["192.0.2.1:80", "localhost:80", "127.0.0.1:0", "127.0.0.1"] { + let output = probe(address); + assert_eq!(output.status.code(), Some(1), "should reject {address}"); + assert!(output.stdout.is_empty(), "should not report for {address}"); + } + let extra = Command::new(RELEASE_BIN) + .args(["boundary-probe", "connect", "127.0.0.1:80", "extra"]) + .env_clear() + .output() + .expect("should run the release binary"); + assert_eq!( + extra.status.code(), + Some(1), + "should reject extra arguments" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn probe_is_denied_with_eperm_under_sandbox_exec() { + let listener = listener(); + let address = listener + .local_addr() + .expect("should read address") + .to_string(); + let output = Command::new("/usr/bin/sandbox-exec") + .args([ + "-p", + "(version 1)(allow default)(deny network*)", + RELEASE_BIN, + "boundary-probe", + "connect", + &address, + ]) + .env_clear() + .output() + .expect("should run the sandboxed release binary"); + assert_eq!( + output.status.code(), + Some(DENIED_STATUS), + "should exit 3 when denied" + ); + assert_eq!( + output.stdout, b"denied errno=1\n", + "should report real EPERM" + ); + assert_eq!(accepted(&listener), 0, "should observe no accept"); +} diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 84ae3f808a..bc909e95cd 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -39,7 +39,7 @@ The user's 2026-10-14 correction prohibits introduced Python release tooling. Th Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrappers use an independently approved-source, OS-native Rust toolchain to build/select the release tool before executing it; a colocated SHA file or self-reported source label supplies no authority. Developer Cargo commands below establish local behavior only. Production needs the authenticated bootstrap, full qualified source/dependency/tool closure and reviewed native OS adapters. No language migration waives native SDK/tool acquisition or authenticating the original Fastly CLI archive through independently reviewed vendor-source evidence. - [x] **M0: Remove superseded tooling.** Introduced Python release code and validator requirements are removed. Deleted prototype behavior is not implemented or fixed by removal; pending Rust regressions below remain mandatory. -- [ ] **M1: Port primitives and preflight (Tasks 2–3).** The host crate and strict document/schema utilities are implemented. Fixed policy/source preflight, complete helper closure and environment sanitation remain Task 3 work. Register each private test module before applying the nonzero-selection guard. +- [ ] **M1: Port primitives and preflight (Tasks 2–3).** Strict document/schema utilities, fixed policy/environment checks, local Git object/worktree authentication and the helper-closure fingerprint are implemented. Git commands use bounded process supervision and a shared command deadline. Independently authenticated bootstrap/tool acquisition and qualification remain incomplete; the closure fingerprint supplies no acquisition authority. Register each private test module before applying the nonzero-selection guard. - [ ] **M2: Port material/process/OS boundaries (Tasks 7–9).** Establish original archive/Git identities, bounded child execution, kill/reap/cleanup, private staging, source/object read-only protections and real native denial controls. Preserve independent all-cohort remote receipts separately from rehashing tools actually installed in the current execution environment. - [ ] **M3: Restore real JS orchestration (Task 7).** Perform two fresh offline installations, actual lifecycle observations and denied missing-tool probes, all tracked bundle comparisons and both full registered JS/type-check suites. Only actual successful execution constructs a private typed completion bound to source, tools, settings and retained A bytes; parsed JSON, paths or booleans cannot construct it. - [ ] **M4: Restore canonical recipes and inspection (Tasks 8–9).** Compose actual Cargo preparation, compile, final acceptance and copying; collect Task 10 metadata/tree/native evidence through that same boundary before hosted qualification. Local tiny artifacts cannot qualify the full product or actual macOS 15/GNU cohort. @@ -47,7 +47,7 @@ Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrapp The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. -Current implementation includes the host-only crate and strict document/schema utilities, owning build facts, checked JS inventory and Node composition. Source/preflight, the complete helper closure, real native process/OS boundaries, two-installation orchestration and canonical recipes remain pending. Source approvals, acquisition roots and qualification pins remain pending; production wrappers and producer selection remain closed. Local utility checks are not actual macOS 15/GNU or hosted release proof. +Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The macOS profile still permits a scrubbed process-group escape and is not a qualified lifecycle-containment backend. Linux container execution requires actual native CI and qualification. Original-material preparation, two-installation JS orchestration, canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. Local utility checks are not actual macOS 15/GNU or hosted release proof. ## Work breakdown and ownership diff --git a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md index c48358a52a..754be9059a 100644 --- a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md +++ b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md @@ -38,7 +38,7 @@ One `release.yml` owns publication and same-source `release-build.yml` owns sign The active plan keeps Task IDs 1–22 and prepends migration checkpoints: M0 verify removal/fail-closed wrappers; M1 Rust documents/preflight/source; M2 original materials/process/OS boundaries; M3 real two-installation JS completion; M4 actual canonical recipes/inspection/composition; then finish Tasks 10–22. Full AGENTS CI and independent review remain required before handoff. No resource creation/dispatch, signing, publication or provider operation follows from this language correction. -Introduced Python producer tooling and requirements are removed. The host-only crate now implements strict document/schema utilities; Rust build-fact and checked-inventory guards and Node composition are implemented. Deleting prototype bugs does not port or fix their behavior. Task 3 source/preflight and full helper closure, M2–M4 actual execution, independently approved source/acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. +Introduced Python producer tooling and requirements are removed. The host-only crate implements strict documents/policy, local source/preflight checks, a helper-closure fingerprint, bounded process supervision and final-artifact inspection. Rust build-fact and checked-inventory guards and Node composition are implemented. The experimental macOS profile does not contain descendants that escape their process group and scrub the tracking environment; it cannot qualify M2 lifecycle containment. Original-material preparation, complete M2–M4 execution, independently approved acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. ## Mandatory replacement regressions diff --git a/scripts/release/qualification/README.md b/scripts/release/qualification/README.md index c7a26d6dc2..8705d2922c 100644 --- a/scripts/release/qualification/README.md +++ b/scripts/release/qualification/README.md @@ -5,13 +5,20 @@ SDK, OCI image or recipe has become qualified. `release-tools.json` retains null measurement slots. A source checksum and a successful unit test cannot supply hosted execution or acquisition authenticity. -The retained observation schema, source-owned candidate boundary design and inert -hosted proposal guide replacement Rust semantic checks and native execution. It creates no repository, dispatches no job, registers no workflow, +The retained observation schema, experimental source-owned boundaries and inert +hosted proposal guide Rust semantic checks and native execution. This framework creates no repository, dispatches no job, registers no workflow, publishes no tag, signs no release and deploys no publisher service. ## Rust migration and Task 9 status -Introduced Python qualification/recipe tooling and validator requirements are removed. Historical local socket/Cargo/tiny Mach-O probes are not replacement Rust boundary results or selected macOS 15/GNU qualification. The [Rust migration addendum](../../../docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md) and [primary plan](../../../docs/superpowers/plans/2026-10-13-attested-release-implementation.md#rust-migration-and-current-execution-boundary) define the host-only `crates/trusted-server-release` tool. Its document/schema utilities are implemented; source/preflight, complete helper closure and actual qualification remain pending. +Introduced Python qualification/recipe tooling and validator requirements are removed. Historical local socket/Cargo/tiny Mach-O probes are not selected macOS 15/GNU qualification. The [Rust migration addendum](../../../docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md) and [primary plan](../../../docs/superpowers/plans/2026-10-13-attested-release-implementation.md#rust-migration-and-current-execution-boundary) define the host-only `crates/trusted-server-release` tool. Strict document/policy checks, local source authentication, helper-closure fingerprinting, bounded Git/process execution and final-artifact inspection are implemented. Authenticated bootstrap/acquisition and actual qualification remain pending. + +The experimental macOS sandbox denies network access but does not establish +complete descendant cleanup: `posix_spawn` can escape the supervised group, +and a child that scrubs its tracking environment can survive the run. Socket +denial and successful utility tests do not qualify that lifecycle boundary. +The production macOS and Linux selectors remain unconstructible until their +required controls and independently authenticated inputs are established. Current wrappers fail closed until the independently approved native Rust bootstrap and source-owned authenticated selections are implemented. Neither From 58da0d771168d040962294ff079487a8a9028275 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 16:00:17 +0530 Subject: [PATCH 051/153] Preserve bounded diagnostics for failed denial probes --- .../src/boundary/proof.rs | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-release/src/boundary/proof.rs b/crates/trusted-server-release/src/boundary/proof.rs index b60d578f1e..7c26c99b48 100644 --- a/crates/trusted-server-release/src/boundary/proof.rs +++ b/crates/trusted-server-release/src/boundary/proof.rs @@ -108,13 +108,19 @@ fn evaluate( fn observe( probe: &ProbeProgram, listener: &LoopbackListener, - result: &ProcessResult, + result: ProcessResult, ) -> Result> { - Ok(Observation { + let observation = Observation { outcome: result.outcome(), report: probe.parse(result.stdout()), accepts: listener.drain_accepts()?, - }) + }; + match observation.outcome { + ProcessOutcome::Exited { code } if code == 0 || code == i32::from(DENIED_STATUS) => { + Ok(observation) + } + _ => result.require_success().map(|_| observation), + } } fn unconfined_command( @@ -168,17 +174,51 @@ pub(super) fn establish( )); } let control_result = unconfined_command(probe, &listener, limits)?.run()?; - let control = observe(probe, &listener, &control_result)?; + let control = observe(probe, &listener, control_result)?; let confined_result = boundary.execute(&confined_command(boundary, probe, &listener, limits)?)?; - let confined = observe(probe, &listener, &confined_result)?; + let confined = observe(probe, &listener, confined_result)?; evaluate(boundary.fingerprint, control, confined) } #[cfg(test)] mod tests { + use std::ffi::OsString; + use std::time::Duration; + use super::*; + #[test] + fn unexpected_probe_exit_keeps_bounded_startup_diagnostic() { + let probe = ProbeProgram::release_binary().expect("should locate probe executable"); + let listener = LoopbackListener::bind().expect("should bind fixture listener"); + let result = BoundedCommand::new( + Path::new("/bin/sh"), + Path::new("/"), + ProcessLimits { + wall: Duration::from_secs(5), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should select fixture command") + .arguments( + [ + "-c", + "printf 'fictional container startup failure' >&2; exit 125", + ] + .map(OsString::from), + ) + .run() + .expect("should supervise fixture child"); + let error = observe(&probe, &listener, result) + .expect_err("should reject failed probe child before protocol evaluation"); + assert!( + format!("{error:?}").contains("fictional container startup failure"), + "should preserve bounded child failure diagnostic" + ); + } + fn observation(code: i32, report: Option, accepts: usize) -> Observation { Observation { outcome: ProcessOutcome::Exited { code }, From 69583d8c02a4661a1af8ef4b40a1886148f21d13 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 16:02:08 +0530 Subject: [PATCH 052/153] Extract authenticated dependency archives into bounded private state --- Cargo.lock | 12 + crates/trusted-server-release/Cargo.toml | 2 + .../trusted-server-release/src/materials.rs | 74 ++++ .../src/materials/archive.rs | 4 + .../src/materials/extract.rs | 364 ++++++++++++++++ .../src/materials/extract/tests.rs | 387 ++++++++++++++++++ scripts/release/js-composition.md | 13 + 7 files changed, 856 insertions(+) create mode 100644 crates/trusted-server-release/src/materials/extract.rs create mode 100644 crates/trusted-server-release/src/materials/extract/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7124d6b129..e7b8a16aa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6218,6 +6218,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "temp-env" version = "0.3.6" @@ -6944,6 +6954,7 @@ dependencies = [ "clap", "derive_more", "error-stack", + "flate2", "hex", "jsonschema", "libc", @@ -6952,6 +6963,7 @@ dependencies = [ "serde_json", "sha1-checked", "sha2 0.10.9", + "tar", "tempfile", "toml", "wasmparser", diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index a944871935..63c311f70f 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -11,6 +11,7 @@ base64.workspace = true clap.workspace = true derive_more.workspace = true error-stack.workspace = true +flate2.workspace = true hex.workspace = true libc.workspace = true jsonschema = { version = "=0.58.6", default-features = false } @@ -25,6 +26,7 @@ serde_json.workspace = true sha1-checked = { version = "=0.10.0", default-features = false } sha2.workspace = true tempfile.workspace = true +tar = { version = "=0.4.46", default-features = false } toml.workspace = true wasmparser = { version = "=0.247.0", default-features = false, features = [ "features", diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs index 8a6a6c47d2..dd7fa1a856 100644 --- a/crates/trusted-server-release/src/materials.rs +++ b/crates/trusted-server-release/src/materials.rs @@ -5,6 +5,7 @@ //! build completion. mod archive; +mod extract; mod locked; use std::collections::BTreeMap; @@ -19,6 +20,7 @@ use crate::error::ReleaseError; use crate::process::Cancellation; use crate::source::ApprovedSource; pub(crate) use archive::VerifiedOriginal; +pub(crate) use extract::ExtractedOriginal; use locked::{CargoMaterials, NpmMaterial, parse_cargo, parse_npm}; const NPM_LOCK: &str = "crates/trusted-server-js/lib/package-lock.json"; @@ -39,6 +41,78 @@ pub(crate) struct LockedMaterials { } impl LockedMaterials { + /// Extracts a verified npm original into new private state. + /// + /// # Errors + /// + /// Returns a material error if the original is not bound to this source + /// and locked location, or its archive violates the extraction policy. + pub(crate) fn extract_npm( + &self, + location: &str, + original: VerifiedOriginal, + ) -> Result> { + let locked = self + .npm + .get(location) + .ok_or_else(|| material_error("unknown npm location"))?; + self.join_original(&original, &format!("npm:{location}"))?; + let index_alias = matches!( + (locked.name.as_str(), locked.version.as_str()), + ("agent-base", "7.1.4") + | ("http-proxy-agent", "7.0.2") + | ("https-proxy-agent", "7.0.6") + ); + extract::extract( + original, + extract::Selection { + root: "package", + index_alias, + }, + ) + } + + /// Extracts a verified registry crate under its exact locked root. + /// + /// # Errors + /// + /// Returns a material error for a mismatched identity or unsafe archive. + pub(crate) fn extract_registry( + &self, + name: &str, + version: &str, + original: VerifiedOriginal, + ) -> Result> { + if !self + .cargo + .registry + .contains_key(&(name.to_owned(), version.to_owned())) + { + return Err(material_error("unknown registry identity")); + } + self.join_original(&original, &format!("crate:{name}@{version}"))?; + extract::extract( + original, + extract::Selection { + root: &format!("{name}-{version}"), + index_alias: false, + }, + ) + } + + fn join_original( + &self, + original: &VerifiedOriginal, + identity: &str, + ) -> Result<(), Report> { + if original.source_commit() != self.source_commit || original.identity() != identity { + return Err(material_error( + "original is not bound to this approved locked identity", + )); + } + Ok(()) + } + /// Loads only the approved tracked lockfiles and derives their identities. /// /// # Errors diff --git a/crates/trusted-server-release/src/materials/archive.rs b/crates/trusted-server-release/src/materials/archive.rs index 7782bbab85..fde4a02a5a 100644 --- a/crates/trusted-server-release/src/materials/archive.rs +++ b/crates/trusted-server-release/src/materials/archive.rs @@ -62,6 +62,10 @@ fn check_limits( } impl VerifiedOriginal { + pub(super) fn check(&self) -> Result<(), Report> { + check_limits(self.deadline, &self.cancellation) + } + /// Copies the retained original after checking its exact bytes again. /// /// # Errors diff --git a/crates/trusted-server-release/src/materials/extract.rs b/crates/trusted-server-release/src/materials/extract.rs new file mode 100644 index 0000000000..ca66c6c5e5 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract.rs @@ -0,0 +1,364 @@ +//! Extracts authenticated originals into fresh private state, without execution. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::io::{self, BufReader, Read, Seek as _, Write as _}; +use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use flate2::bufread::GzDecoder; +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::{VerifiedOriginal, material_error}; +use crate::error::ReleaseError; + +#[derive(Clone, Copy)] +pub(super) struct Selection<'a> { + pub(super) root: &'a str, + pub(super) index_alias: bool, +} + +const DECODED_LIMIT: u64 = 512 * 1024 * 1024; +const MEMBER_LIMIT: u64 = 64 * 1024 * 1024; +const ENTRY_LIMIT: usize = 100_000; +const DEPTH_LIMIT: usize = 64; +const PATH_LIMIT: usize = 4096; + +#[derive(PartialEq, Eq)] +struct MemberFacts { + size: u64, + mode: u32, + sha256: String, +} + +/// A privately extracted original, without installation or execution authority. +pub(crate) struct ExtractedOriginal { + original: VerifiedOriginal, + directory: TempDir, + root: PathBuf, + files: BTreeMap, +} + +impl ExtractedOriginal { + /// Returns the privately owned package root for later preparation checks. + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// Returns the approved source identity of the retained original. + pub(crate) fn source_commit(&self) -> &str { + self.original.source_commit() + } +} + +/// Checks cancellation and decoded expansion on every bounded read. +struct Decoded<'a, R> { + reader: R, + original: &'a VerifiedOriginal, + count: u64, +} + +impl Read for Decoded<'_, R> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.original + .check() + .map_err(|_| io::Error::other("archive cancelled or timed out"))?; + let maximum = bytes.len().min(64 * 1024); + let count = self.reader.read(&mut bytes[..maximum])?; + self.count += count as u64; + if self.count > DECODED_LIMIT { + return Err(io::Error::other("archive exceeds decoded byte limit")); + } + Ok(count) + } +} + +fn field(bytes: &[u8]) -> Result<&str, Report> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + if bytes[end..].iter().any(|byte| *byte != 0) { + return Err(material_error( + "TAR name has hidden bytes after its terminator", + )); + } + std::str::from_utf8(&bytes[..end]).map_err(material_error) +} + +/// Uses raw fields because [`tar::Entry::path_bytes`] normalizes backslashes. +fn raw_name(header: &tar::Header) -> Result> { + let name = field(&header.as_old().name)?; + match header.as_ustar() { + Some(ustar) => { + let prefix = field(&ustar.prefix)?; + Ok(if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}/{name}") + }) + } + None => Ok(name.to_owned()), + } +} + +fn member_name( + raw: &str, + directory: bool, + selection: &Selection<'_>, +) -> Result> { + if raw.is_empty() + || raw.len() > PATH_LIMIT + || !raw.is_ascii() + || raw + .bytes() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'\\' | b':')) + { + return Err(material_error("TAR path is not a bounded portable name")); + } + let name = if directory { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + let name = if !directory && selection.index_alias && name == "package/./dist/index.js" { + "package/dist/index.js" + } else { + name + }; + let parts: Vec<&str> = name.split('/').collect(); + if parts.len() > DEPTH_LIMIT + || parts + .iter() + .any(|part| part.is_empty() || *part == "." || *part == "..") + || parts[0] != selection.root + || (!directory && parts.len() == 1) + { + return Err(material_error("TAR path leaves its exact locked root")); + } + Ok(name.to_owned()) +} + +struct State { + directory: TempDir, + spellings: BTreeSet, + portable: BTreeMap, + files: BTreeMap, + first_spelling: BTreeMap, + directories: BTreeSet, +} + +impl State { + fn portable_name(&mut self, name: &str) -> Result<(), Report> { + let mut prefix = String::new(); + for part in name.split('/') { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(part); + let lower = prefix.to_ascii_lowercase(); + if let Some(previous) = self.portable.insert(lower, prefix.clone()) + && previous != prefix + { + return Err(material_error("TAR has case-aliased paths")); + } + } + Ok(()) + } + + fn entry( + &mut self, + mut entry: tar::Entry<'_, R>, + selection: &Selection<'_>, + ) -> Result<(), Report> { + let header = entry.header(); + let directory = header.entry_type().is_dir(); + if !directory && !header.entry_type().is_file() { + return Err(material_error( + "TAR member is not a regular file or directory", + )); + } + let raw = raw_name(header)?; + let name = member_name(&raw, directory, selection)?; + if !self.spellings.insert(raw.clone()) { + return Err(material_error("TAR repeats a member spelling")); + } + self.portable_name(&name)?; + let mode = header.mode().map_err(material_error)?; + let size = entry.size(); + if mode & !0o777 != 0 || size > MEMBER_LIMIT || (directory && size != 0) { + return Err(material_error( + "TAR member has privileged mode or invalid size", + )); + } + let path = self.directory.path().join(&name); + if directory { + if !self.directories.insert(name) { + return Err(material_error("TAR repeats a directory path")); + } + fs::create_dir_all(&path).map_err(material_error)?; + return Ok(()); + } + let duplicate = self.files.contains_key(&name); + if duplicate + && !(selection.index_alias + && name == "package/dist/index.js" + && matches!( + ( + self.first_spelling.get(&name).map(String::as_str), + raw.as_str() + ), + (Some("package/./dist/index.js"), "package/dist/index.js") + | (Some("package/dist/index.js"), "package/./dist/index.js") + )) + { + return Err(material_error("TAR has an unreviewed duplicate path")); + } + let mut file = if duplicate { + None + } else { + fs::create_dir_all( + path.parent() + .ok_or_else(|| material_error("member has no parent"))?, + ) + .map_err(material_error)?; + Some( + File::options() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .map_err(material_error)?, + ) + }; + let mut hasher = Sha256::new(); + let mut count = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = entry.read(&mut buffer).map_err(material_error)?; + if read == 0 { + break; + } + count += read as u64; + if count > size { + return Err(material_error("TAR member exceeds declared size")); + } + hasher.update(&buffer[..read]); + if let Some(file) = &mut file { + file.write_all(&buffer[..read]).map_err(material_error)?; + } + } + if count != size { + return Err(material_error("TAR member is truncated")); + } + let facts = MemberFacts { + size, + mode, + sha256: hex::encode(hasher.finalize()), + }; + if duplicate { + if self.files.get(&name) != Some(&facts) { + return Err(material_error("reviewed TAR aliases differ")); + } + } else { + // Only executable bits carry across; ownership and ambient write access do not. + file.as_ref() + .expect("should have exclusively created member") + .set_permissions(fs::Permissions::from_mode(0o600 | (mode & 0o111))) + .map_err(material_error)?; + self.first_spelling.insert(name.clone(), raw); + self.files.insert(name, facts); + } + Ok(()) + } +} + +pub(super) fn extract( + original: VerifiedOriginal, + selection: Selection<'_>, +) -> Result> { + extract_bounded(original, selection, ENTRY_LIMIT) +} + +fn extract_bounded( + mut original: VerifiedOriginal, + selection: Selection<'_>, + entry_limit: usize, +) -> Result> { + let mut compressed = tempfile::tempfile().map_err(material_error)?; + original.copy_verified(&mut compressed)?; + compressed.rewind().map_err(material_error)?; + let decoder = GzDecoder::new(BufReader::new(compressed)); + let decoded = Decoded { + reader: decoder, + original: &original, + count: 0, + }; + let mut archive = tar::Archive::new(decoded); + let mut state = State { + directory: tempfile::tempdir().map_err(material_error)?, + spellings: BTreeSet::new(), + portable: BTreeMap::new(), + files: BTreeMap::new(), + first_spelling: BTreeMap::new(), + directories: BTreeSet::new(), + }; + for (index, entry) in archive + .entries() + .map_err(material_error)? + .raw(true) + .enumerate() + { + if index >= entry_limit { + return Err(material_error("TAR exceeds entry count limit")); + } + state.entry(entry.map_err(material_error)?, &selection)?; + } + if state.spellings.contains("package/./dist/index.js") + && !state.spellings.contains("package/dist/index.js") + { + return Err(material_error( + "reviewed dot alias lacks its canonical partner", + )); + } + // The parser consumed the first zero header. Require at least the second, + // then only complete zero blocks through the checked gzip trailer. + let mut decoded = archive.into_inner(); + let mut tail = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = decoded.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + tail += count as u64; + if buffer[..count].iter().any(|byte| *byte != 0) { + return Err(material_error("TAR hides data after its terminator")); + } + } + if tail < 512 || !tail.is_multiple_of(512) { + return Err(material_error("TAR lacks complete terminator blocks")); + } + let mut compressed = decoded.reader.into_inner(); + if compressed.read(&mut buffer[..1]).map_err(material_error)? != 0 { + return Err(material_error( + "gzip has additional members or trailing bytes", + )); + } + original.check()?; + let root = state.directory.path().join(selection.root); + if !root.is_dir() || state.files.is_empty() { + return Err(material_error("archive has no package files")); + } + Ok(ExtractedOriginal { + original, + root, + directory: state.directory, + files: state.files, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/extract/tests.rs b/crates/trusted-server-release/src/materials/extract/tests.rs new file mode 100644 index 0000000000..77185efffa --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/tests.rs @@ -0,0 +1,387 @@ +use std::fs; +use std::io; +use std::io::Write as _; +use std::time::{Duration, Instant}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use flate2::{Compression, write::GzEncoder}; +use serde_json::json; +use sha2::{Digest as _, Sha256, Sha512}; + +use super::*; +use crate::materials::archive::{Expected, verify}; +use crate::materials::{LockedMaterials, locked}; +use crate::process::Cancellation; + +fn compressed(tar: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(tar).expect("should compress TAR bytes"); + encoder.finish().expect("should finish gzip trailer") +} + +fn tar_bytes(entries: &[(&str, u8, u32, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + for (name, kind, mode, bytes) in entries { + let mut header = tar::Header::new_ustar(); + header.set_entry_type(tar::EntryType::new(*kind)); + header.set_mode(*mode); + header.set_size(bytes.len() as u64); + if name.len() <= 100 { + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + } else { + let (prefix, leaf) = name + .rsplit_once('/') + .expect("should split long fixture name"); + let ustar = header.as_ustar_mut().expect("should have USTAR header"); + ustar.prefix[..prefix.len()].copy_from_slice(prefix.as_bytes()); + ustar.name[..leaf.len()].copy_from_slice(leaf.as_bytes()); + } + header.set_cksum(); + builder + .append(&header, *bytes) + .expect("should append fixture entry"); + } + builder.into_inner().expect("should finish TAR") +} + +fn original(bytes: &[u8]) -> VerifiedOriginal { + let directory = tempfile::tempdir().expect("should create archive directory"); + let path = directory.path().join("original.tgz"); + fs::write(&path, bytes).expect("should write archive"); + verify( + &path, + Expected::Sha256(&hex::encode(Sha256::digest(bytes))), + &"a".repeat(40), + "npm:node_modules/fictional-package", + Instant::now() + Duration::from_secs(10), + &Cancellation::default(), + ) + .expect("should verify original bytes") +} + +#[test] +fn extracts_verified_regular_files_and_preserves_original_bytes() { + let bytes = compressed(&tar_bytes(&[( + "package/bin/tool", + b'0', + 0o755, + b"fictional executable", + )])); + let extracted = extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + }, + ) + .expect("should extract verified regular file"); + assert_eq!( + fs::read(extracted.directory.path().join("package/bin/tool")).expect("should read file"), + b"fictional executable", + "should retain exact member bytes" + ); + let mut retained = Vec::new(); + let mut original = extracted.original; + original + .copy_verified(&mut retained) + .expect("should retain authenticated compressed original"); + assert_eq!(retained, bytes, "should preserve exact compressed bytes"); +} + +#[test] +fn rejects_unsafe_paths_types_modes_duplicates_and_collisions() { + for entries in [ + vec![("other/file", b'0', 0o644, b"x".as_slice())], + vec![("package/../file", b'0', 0o644, b"x".as_slice())], + vec![("package/./file", b'0', 0o644, b"x".as_slice())], + vec![("package\\file", b'0', 0o644, b"x".as_slice())], + vec![("/package/file", b'0', 0o644, b"x".as_slice())], + vec![("package/file", b'0', 0o4644, b"x".as_slice())], + vec![("package/file", b'2', 0o644, b"".as_slice())], + vec![("package/file", b'1', 0o644, b"".as_slice())], + vec![("package/file", b'6', 0o644, b"".as_slice())], + vec![("package/file", b'x', 0o644, b"x".as_slice())], + vec![ + ("package/file", b'0', 0o644, b"x".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ], + vec![ + ("package/file", b'0', 0o644, b"x".as_slice()), + ("package/file/child", b'0', 0o644, b"x".as_slice()), + ], + vec![ + ("package/File", b'0', 0o644, b"x".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ], + vec![ + ("package/", b'5', 0o755, b"".as_slice()), + ("package", b'5', 0o755, b"".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ], + ] { + assert!( + extract( + original(&compressed(&tar_bytes(&entries))), + Selection { + root: "package", + index_alias: false + } + ) + .is_err(), + "should reject unsafe member set" + ); + } +} + +#[test] +fn rejects_excessive_depth_member_size_and_cancelled_extraction() { + let name = format!("package/{}file", "a/".repeat(64)); + let bytes = compressed(&tar_bytes(&[(&name, b'0', 0o644, b"x")])); + assert!( + extract( + original(&bytes), + Selection { + root: "package", + index_alias: false + } + ) + .is_err(), + "should reject excessive member depth" + ); + let mut tar = tar_bytes(&[("package/file", b'0', 0o644, b"x")]); + let mut header = tar::Header::new_ustar(); + header.as_mut_bytes().copy_from_slice(&tar[..512]); + header.set_size(MEMBER_LIMIT + 1); + header.set_cksum(); + tar[..512].copy_from_slice(header.as_bytes()); + assert!( + extract( + original(&compressed(&tar)), + Selection { + root: "package", + index_alias: false + } + ) + .is_err(), + "should reject oversized member before reading body" + ); + let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); + let cancellation = Cancellation::default(); + // Reverification binds the extraction to the same cancellation token. + let directory = tempfile::tempdir().expect("should create archive directory"); + let path = directory.path().join("original.tgz"); + fs::write(&path, &bytes).expect("should write archive"); + let original = verify( + &path, + Expected::Sha256(&hex::encode(Sha256::digest(&bytes))), + &"a".repeat(40), + "npm:node_modules/fictional-package", + Instant::now() + Duration::from_secs(10), + &cancellation, + ) + .expect("should verify original"); + cancellation.cancel(); + assert!( + extract( + original, + Selection { + root: "package", + index_alias: false + } + ) + .is_err(), + "should carry cancellation into extraction" + ); +} + +#[test] +fn index_alias_exception_requires_identical_pair_and_no_third_entry() { + let pair = [ + ("package/./dist/index.js", b'0', 0o644, b"same".as_slice()), + ("package/dist/index.js", b'0', 0o644, b"same".as_slice()), + ]; + let bytes = compressed(&tar_bytes(&pair)); + assert!( + extract( + original(&compressed(&tar_bytes(&pair[..1]))), + Selection { + root: "package", + index_alias: true + } + ) + .is_err(), + "should require canonical partner for dot alias" + ); + assert!( + extract( + original(&bytes), + Selection { + root: "package", + index_alias: true + } + ) + .is_ok(), + "should allow exactly reviewed identical pair" + ); + assert!( + extract( + original(&bytes), + Selection { + root: "package", + index_alias: false + } + ) + .is_err(), + "should reject pair for other locked identities" + ); + let mut changed = pair; + changed[1].3 = b"evil"; + assert!( + extract( + original(&compressed(&tar_bytes(&changed))), + Selection { + root: "package", + index_alias: true + } + ) + .is_err(), + "should reject different alias bytes" + ); + let mut third = pair.to_vec(); + third.push(pair[0]); + assert!( + extract( + original(&compressed(&tar_bytes(&third))), + Selection { + root: "package", + index_alias: true + } + ) + .is_err(), + "should reject third occurrence" + ); + let directory_alias = [ + ("package/./dist/index.js/", b'5', 0o755, b"".as_slice()), + ("package/file", b'0', 0o644, b"x".as_slice()), + ]; + assert!( + extract( + original(&compressed(&tar_bytes(&directory_alias))), + Selection { + root: "package", + index_alias: true + } + ) + .is_err(), + "should restrict dot alias exception to regular files" + ); +} + +#[test] +fn rejects_hidden_data_bad_trailers_concatenation_and_truncation() { + let tar = tar_bytes(&[("package/file", b'0', 0o644, b"x")]); + let mut hidden = tar.clone(); + hidden.extend_from_slice(&tar); + let bytes = compressed(&tar); + let mut corrupt = bytes.clone(); + let last = corrupt.len() - 8; + corrupt[last] ^= 1; + let mut concatenated = bytes.clone(); + concatenated.extend_from_slice(&bytes); + let mut trailing = bytes.clone(); + trailing.push(0); + for bad in [ + compressed(&hidden), + corrupt, + concatenated, + trailing, + bytes[..bytes.len() - 1].to_vec(), + compressed(&tar[..512]), + ] { + assert!( + extract( + original(&bad), + Selection { + root: "package", + index_alias: false + } + ) + .is_err(), + "should reject incomplete or ambiguous archive" + ); + } +} + +#[test] +fn catalog_extraction_rejoins_source_and_locked_acquisition_identity() { + let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); + let lock = serde_json::to_vec(&json!({"lockfileVersion":3, "packages": { + "": {}, "node_modules/fictional-package": { + "version":"1.0.0", "resolved":format!("{}fictional-package/-/fictional-package-1.0.0.tgz", locked::NPM_REGISTRY), + "integrity":format!("sha512-{}", STANDARD.encode(Sha512::digest(&bytes))) + } + }})).expect("should encode locked original identity"); + let mut catalog = LockedMaterials { + source_commit: "a".repeat(40), + cargo_lock_sha256: "0".repeat(64), + npm_lock_sha256: "0".repeat(64), + cargo: locked::parse_cargo(b"version = 4\npackage = []\n") + .expect("should parse empty Cargo lock"), + npm: locked::parse_npm(&lock).expect("should parse fixture npm lock"), + }; + assert!( + catalog + .extract_npm("node_modules/fictional-package", original(&bytes)) + .is_ok(), + "should extract bound locked original" + ); + assert!( + catalog + .extract_npm("node_modules/other", original(&bytes)) + .is_err(), + "should reject unknown installation identity" + ); + assert!( + catalog + .extract_registry("fictional-package", "1.0.0", original(&bytes)) + .is_err(), + "should reject npm original as a crate" + ); + catalog.source_commit = "b".repeat(40); + assert!( + catalog + .extract_npm("node_modules/fictional-package", original(&bytes)) + .is_err(), + "should reject original bound to different approved source" + ); +} + +#[test] +fn enforces_decoded_byte_and_entry_budgets() { + let bytes = compressed(&tar_bytes(&[ + ("package/file", b'0', 0o644, b"x"), + ("package/other", b'0', 0o644, b"y"), + ])); + assert!( + extract_bounded( + original(&bytes), + Selection { + root: "package", + index_alias: false + }, + 1 + ) + .is_err(), + "should reject entry exceeding bounded parser budget" + ); + let original = original(&bytes); + let mut decoded = Decoded { + reader: io::repeat(0).take(DECODED_LIMIT + 1), + original: &original, + count: 0, + }; + assert!( + io::copy(&mut decoded, &mut io::sink()).is_err(), + "should stop unbounded decoded expansion" + ); +} diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index ccf34c5966..9ac6a02328 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -134,6 +134,19 @@ over 32 MiB or cumulative bodies over 512 MiB, then rejoin exact body headers. This reader assumes the sanitized trusted Git implementation and an immutable object store, rather than claiming adversarial race-proof streaming. +The Rust dependency archive inspector limits each authenticated compressed +original to 64 MiB, decoded TAR bytes to 512 MiB, each member to 64 MiB, +entries to 100,000, path depth to 64 and path text to 4,096 bytes. It accepts +ASCII regular-file and directory names under the exact locked root, rejects +case aliases and unsupported extension records (including PAX, GNU long-name +and sparse records), and requires a single complete gzip member with complete +TAR zero terminators and no hidden trailing data. These are dependency archive +limits, separate from toolchain distributions and Git object limits. Extraction +owns a fresh private directory and preserves original compressed bytes. File +permissions become owner read/write plus the archive's executable bits; archive +ownership, timestamps and other permission bits do not propagate. This stage +does not establish an installed dependency, executed hook or completed JS build. + Seed each private cache through selected npm's public `cache add` API with only verified archives, offline and scripts disabled. Run fresh pinned npm 11.6.2 `ci --offline --include=dev --no-audit --no-fund` with hooks in the foreground From c9d0b5f993bc151ca9a5e838936a34667a8daeb8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 16:09:07 +0530 Subject: [PATCH 053/153] Specify private propagation for recursive read-only mounts --- crates/trusted-server-release/src/boundary/docker.rs | 2 +- crates/trusted-server-release/src/boundary/tests/container.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-release/src/boundary/docker.rs b/crates/trusted-server-release/src/boundary/docker.rs index 78231bb51c..857a1645f7 100644 --- a/crates/trusted-server-release/src/boundary/docker.rs +++ b/crates/trusted-server-release/src/boundary/docker.rs @@ -250,7 +250,7 @@ fn mount_argument(mount: &Mount) -> OsString { value.push(",target="); value.push(&mount.target); if mount.read_only { - value.push(",readonly,bind-recursive=readonly"); + value.push(",readonly,bind-propagation=rprivate,bind-recursive=readonly"); } value } diff --git a/crates/trusted-server-release/src/boundary/tests/container.rs b/crates/trusted-server-release/src/boundary/tests/container.rs index 2519aaeee8..e5c0f7cdba 100644 --- a/crates/trusted-server-release/src/boundary/tests/container.rs +++ b/crates/trusted-server-release/src/boundary/tests/container.rs @@ -171,9 +171,9 @@ fn expected_run_arguments(root: &str, image: &str) -> Vec { .iter() .map(|part| (*part).to_owned()) .chain([ - format!("type=bind,source={root}/immutable,target=/boundary/immutable/0,readonly,bind-recursive=readonly"), + format!("type=bind,source={root}/immutable,target=/boundary/immutable/0,readonly,bind-propagation=rprivate,bind-recursive=readonly"), String::from("--mount"), - format!("type=bind,source={root}/tool,target=/boundary/tool/0,readonly,bind-recursive=readonly"), + format!("type=bind,source={root}/tool,target=/boundary/tool/0,readonly,bind-propagation=rprivate,bind-recursive=readonly"), String::from("--mount"), format!("type=bind,source={root}/writable,target=/boundary/writable/0"), ]) From 4473b75aa4ca56d8f9d0ca9e6ced8c152726d93a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 16:17:25 +0530 Subject: [PATCH 054/153] Review lifecycle declarations against authenticated package manifests --- .../trusted-server-release/src/materials.rs | 42 +++ .../src/materials/extract.rs | 55 ++++ .../src/materials/extract/tests.rs | 84 ++++- .../src/materials/manifest.rs | 308 ++++++++++++++++++ .../src/source/tests/closure.rs | 68 ++++ scripts/release/js-composition.md | 19 ++ 6 files changed, 570 insertions(+), 6 deletions(-) create mode 100644 crates/trusted-server-release/src/materials/manifest.rs diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs index dd7fa1a856..804a0333f6 100644 --- a/crates/trusted-server-release/src/materials.rs +++ b/crates/trusted-server-release/src/materials.rs @@ -7,6 +7,7 @@ mod archive; mod extract; mod locked; +mod manifest; use std::collections::BTreeMap; use std::path::Path; @@ -22,6 +23,7 @@ use crate::source::ApprovedSource; pub(crate) use archive::VerifiedOriginal; pub(crate) use extract::ExtractedOriginal; use locked::{CargoMaterials, NpmMaterial, parse_cargo, parse_npm}; +pub(crate) use manifest::{ReviewedNpmManifest, RootManifestReview}; const NPM_LOCK: &str = "crates/trusted-server-js/lib/package-lock.json"; @@ -41,6 +43,46 @@ pub(crate) struct LockedMaterials { } impl LockedMaterials { + /// Reviews approved root declarations without executing npm lifecycle hooks. + /// + /// # Errors + /// + /// Returns an error for changed source, lifecycle hooks or an implicit install. + pub(crate) fn review_root( + &self, + source: &ApprovedSource, + ) -> Result> { + if source.commit().as_str() != self.source_commit { + return Err(material_error( + "root manifest is bound to a different approved source", + )); + } + manifest::review_root(source) + } + + /// Reviews a verified npm manifest against locked identity and hook policy. + /// + /// # Errors + /// + /// Returns an error for a mismatched original, changed manifest or unreviewed hook. + pub(crate) fn review_npm( + &self, + location: &str, + extracted: ExtractedOriginal, + ) -> Result> { + let locked = self + .npm + .get(location) + .ok_or_else(|| material_error("unknown npm location"))?; + if extracted.source_commit() != self.source_commit + || extracted.identity() != format!("npm:{location}") + { + return Err(material_error( + "extracted manifest is not bound to this locked acquisition", + )); + } + manifest::review_npm(extracted, &locked.name, &locked.version) + } /// Extracts a verified npm original into new private state. /// /// # Errors diff --git a/crates/trusted-server-release/src/materials/extract.rs b/crates/trusted-server-release/src/materials/extract.rs index ca66c6c5e5..ab7f017c61 100644 --- a/crates/trusted-server-release/src/materials/extract.rs +++ b/crates/trusted-server-release/src/materials/extract.rs @@ -12,6 +12,7 @@ use sha2::{Digest as _, Sha256}; use tempfile::TempDir; use super::{VerifiedOriginal, material_error}; +use crate::documents::{Role, read_bounded}; use crate::error::ReleaseError; #[derive(Clone, Copy)] @@ -39,9 +40,62 @@ pub(crate) struct ExtractedOriginal { directory: TempDir, root: PathBuf, files: BTreeMap, + root_name: String, } impl ExtractedOriginal { + /// Returns the locked acquisition identity supplying this extracted tree. + pub(super) fn identity(&self) -> &str { + self.original.identity() + } + + /// Reads a bounded manifest input and rejoins its extraction-time digest. + /// + /// # Errors + /// + /// Returns an error for an unrecorded, changed, linked or oversized input, + /// or when the original's deadline/cancellation boundary has expired. + pub(super) fn read_member_checked( + &self, + relative: &str, + ) -> Result, Report> { + self.original.check()?; + let key = format!("{}/{relative}", self.root_name); + let expected = self + .files + .get(&key) + .ok_or_else(|| material_error("manifest input is not a recorded archive member"))?; + let raw = read_bounded(&self.root.join(relative), Role::Metadata)?; + if raw.sha256 != expected.sha256 || raw.bytes.len() as u64 != expected.size { + return Err(material_error("extracted manifest input changed")); + } + self.original.check()?; + Ok(raw.bytes) + } + + /// Checks recorded manifest-input presence without allowing a later omission. + /// + /// # Errors + /// + /// Returns an error if the selected original input was removed or changed, + /// an unrecorded input appeared, or the attempt expired. + pub(super) fn contains_member_checked( + &self, + relative: &str, + ) -> Result> { + self.original.check()?; + let key = format!("{}/{relative}", self.root_name); + if self.files.contains_key(&key) { + self.read_member_checked(relative)?; + return Ok(true); + } + match fs::symlink_metadata(self.root.join(relative)) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + _ => Err(material_error( + "unrecorded or invalid manifest input appeared", + )), + } + } /// Returns the privately owned package root for later preparation checks. pub(crate) fn root(&self) -> &Path { &self.root @@ -353,6 +407,7 @@ fn extract_bounded( return Err(material_error("archive has no package files")); } Ok(ExtractedOriginal { + root_name: selection.root.to_owned(), original, root, directory: state.directory, diff --git a/crates/trusted-server-release/src/materials/extract/tests.rs b/crates/trusted-server-release/src/materials/extract/tests.rs index 77185efffa..13a49d56ba 100644 --- a/crates/trusted-server-release/src/materials/extract/tests.rs +++ b/crates/trusted-server-release/src/materials/extract/tests.rs @@ -312,23 +312,27 @@ fn rejects_hidden_data_bad_trailers_concatenation_and_truncation() { } } -#[test] -fn catalog_extraction_rejoins_source_and_locked_acquisition_identity() { - let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); +fn catalog(bytes: &[u8]) -> LockedMaterials { let lock = serde_json::to_vec(&json!({"lockfileVersion":3, "packages": { "": {}, "node_modules/fictional-package": { "version":"1.0.0", "resolved":format!("{}fictional-package/-/fictional-package-1.0.0.tgz", locked::NPM_REGISTRY), - "integrity":format!("sha512-{}", STANDARD.encode(Sha512::digest(&bytes))) + "integrity":format!("sha512-{}", STANDARD.encode(Sha512::digest(bytes))) } }})).expect("should encode locked original identity"); - let mut catalog = LockedMaterials { + LockedMaterials { source_commit: "a".repeat(40), cargo_lock_sha256: "0".repeat(64), npm_lock_sha256: "0".repeat(64), cargo: locked::parse_cargo(b"version = 4\npackage = []\n") .expect("should parse empty Cargo lock"), npm: locked::parse_npm(&lock).expect("should parse fixture npm lock"), - }; + } +} + +#[test] +fn catalog_extraction_rejoins_source_and_locked_acquisition_identity() { + let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); + let mut catalog = catalog(&bytes); assert!( catalog .extract_npm("node_modules/fictional-package", original(&bytes)) @@ -356,6 +360,74 @@ fn catalog_extraction_rejoins_source_and_locked_acquisition_identity() { ); } +#[test] +fn manifest_review_uses_original_members_and_rejects_later_changes() { + let manifest = serde_json::to_vec(&json!({"name":"fictional-package","version":"1.0.0"})) + .expect("should encode fixture manifest"); + for binding in [false, true] { + let mut entries = vec![("package/package.json", b'0', 0o644, manifest.as_slice())]; + if binding { + entries.push(( + "package/binding.gyp", + b'0', + 0o644, + b"fictional binding".as_slice(), + )); + } + let bytes = compressed(&tar_bytes(&entries)); + let catalog = catalog(&bytes); + let extract_package = || { + catalog + .extract_npm("node_modules/fictional-package", original(&bytes)) + .expect("should extract original manifest") + }; + let reviewed = catalog.review_npm("node_modules/fictional-package", extract_package()); + if binding { + assert!(reviewed.is_err(), "should reject original implicit install"); + let extracted = extract_package(); + fs::remove_file(extracted.root().join("binding.gyp")) + .expect("should remove fixture binding"); + assert!( + catalog + .review_npm("node_modules/fictional-package", extracted) + .is_err(), + "should reject removed original binding" + ); + } else { + assert!( + reviewed + .expect("should review hook-free original") + .hooks() + .is_empty(), + "should declare no execution" + ); + let extracted = extract_package(); + fs::write(extracted.root().join("binding.gyp"), b"added binding") + .expect("should add fixture binding"); + assert!( + catalog + .review_npm("node_modules/fictional-package", extracted) + .is_err(), + "should reject unrecorded binding" + ); + } + let extracted = extract_package(); + fs::write(extracted.root().join("package.json"), b"{}").expect("should change manifest"); + assert!( + catalog + .review_npm("node_modules/fictional-package", extracted) + .is_err(), + "should reject changed manifest bytes" + ); + assert!( + catalog + .review_npm("node_modules/other", extract_package()) + .is_err(), + "should reject another locked location" + ); + } +} + #[test] fn enforces_decoded_byte_and_entry_budgets() { let bytes = compressed(&tar_bytes(&[ diff --git a/crates/trusted-server-release/src/materials/manifest.rs b/crates/trusted-server-release/src/materials/manifest.rs new file mode 100644 index 0000000000..ac2f137f76 --- /dev/null +++ b/crates/trusted-server-release/src/materials/manifest.rs @@ -0,0 +1,308 @@ +//! Reviews declarations only; this module never observes or executes hooks. + +use error_stack::Report; +use serde_json::{Map, Value}; +use sha2::{Digest as _, Sha256}; + +use super::{ExtractedOriginal, material_error}; +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; +use crate::source::ApprovedSource; + +const ROOT_MANIFEST: &str = "crates/trusted-server-js/lib/package.json"; +const ROOT_BINDING: &str = "crates/trusted-server-js/lib/binding.gyp"; +const ROOT_EVENTS: [&str; 10] = [ + "preinstall", + "install", + "postinstall", + "prepublish", + "preprepare", + "prepare", + "postprepare", + "predependencies", + "dependencies", + "postdependencies", +]; + +// These exact declarations are source-owned policy, separate from observations. +const REVIEWED_HOOKS: [(&str, &str, &str, &str); 2] = [ + ( + "core-js", + "3.48.0", + "postinstall", + "node -e \"try{require('./postinstall')}catch(e){}\"", + ), + ("esbuild", "0.27.3", "postinstall", "node install.js"), +]; + +/// Approved root manifest declarations, without lifecycle execution authority. +pub(crate) struct RootManifestReview { + source_commit: String, + manifest_sha256: String, +} + +impl RootManifestReview { + /// Returns the approved source identity supplying the reviewed declarations. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } +} + +/// A declared install hook; this is not an observed lifecycle event. +pub(crate) struct DeclaredHook { + event: String, + script_sha256: String, +} + +impl DeclaredHook { + /// Returns the reviewed npm event name. + pub(crate) fn event(&self) -> &str { + &self.event + } + + /// Returns the hash of exact UTF-8 command bytes from the verified manifest. + pub(crate) fn script_sha256(&self) -> &str { + &self.script_sha256 + } +} + +/// Reviewed declarations from one retained, authenticated npm original. +/// +/// This type cannot construct a lifecycle observation or completed build. +pub(crate) struct ReviewedNpmManifest { + extracted: ExtractedOriginal, + manifest_sha256: String, + hooks: Vec, +} + +impl ReviewedNpmManifest { + /// Returns declarations only, without claiming any hook was executed. + pub(crate) fn hooks(&self) -> &[DeclaredHook] { + &self.hooks + } +} + +/// Reviews only approved tracked root declarations. +/// +/// # Errors +/// +/// Returns an error for changed source, malformed manifests or root lifecycle hooks. +pub(super) fn review_root( + source: &ApprovedSource, +) -> Result> { + source.snapshot().verify_unchanged()?; + let bytes = source.read_tracked(ROOT_MANIFEST, Role::Metadata)?; + let document = parse_json(&bytes, Role::Metadata)?; + review_root_document(&document, source.tracked_sha256(ROOT_BINDING).is_some())?; + source.snapshot().verify_unchanged()?; + Ok(RootManifestReview { + source_commit: source.commit().as_str().to_owned(), + manifest_sha256: hex::encode(Sha256::digest(&bytes)), + }) +} + +/// Reviews exact dependency declarations from authenticated archive members. +/// +/// # Errors +/// +/// Returns an error for changed members, identity substitutions or unreviewed hooks. +pub(super) fn review_npm( + extracted: ExtractedOriginal, + name: &str, + version: &str, +) -> Result> { + let bytes = extracted.read_member_checked("package.json")?; + let document = parse_json(&bytes, Role::Metadata)?; + let binding = extracted.contains_member_checked("binding.gyp")?; + let hooks = declared_hooks(&document, name, version, binding)?; + Ok(ReviewedNpmManifest { + extracted, + manifest_sha256: hex::encode(Sha256::digest(&bytes)), + hooks, + }) +} + +fn scripts(document: &Value) -> Result>, Report> { + let object = document + .as_object() + .ok_or_else(|| material_error("package manifest must be an object"))?; + match object.get("scripts") { + None => Ok(None), + Some(Value::Object(scripts)) + if scripts + .values() + .all(|value| value.as_str().is_some_and(|value| !value.is_empty())) => + { + Ok(Some(scripts)) + } + Some(_) => Err(material_error( + "manifest scripts must contain nonempty command strings", + )), + } +} + +fn review_root_document(document: &Value, binding: bool) -> Result<(), Report> { + let scripts = scripts(document)?; + if binding + || ROOT_EVENTS + .iter() + .any(|event| scripts.is_some_and(|scripts| scripts.contains_key(*event))) + { + return Err(material_error( + "root npm ci lifecycle hooks and implicit installs are not allowed", + )); + } + Ok(()) +} + +fn declared_hooks( + document: &Value, + name: &str, + version: &str, + binding: bool, +) -> Result, Report> { + if document.get("name").and_then(Value::as_str) != Some(name) + || document.get("version").and_then(Value::as_str) != Some(version) + { + return Err(material_error( + "verified package manifest differs from locked package identity", + )); + } + let scripts = scripts(document)?; + if binding + && !scripts.is_some_and(|scripts| { + scripts.contains_key("preinstall") || scripts.contains_key("install") + }) + { + return Err(material_error( + "implicit binding.gyp installs are not reviewed", + )); + } + let mut result = Vec::new(); + for event in ["preinstall", "install", "postinstall"] { + let Some(command) = scripts + .and_then(|scripts| scripts.get(event)) + .and_then(Value::as_str) + else { + continue; + }; + if !REVIEWED_HOOKS.contains(&(name, version, event, command)) { + return Err(material_error( + "dependency install hook has no exact source-owned review", + )); + } + result.push(DeclaredHook { + event: event.to_owned(), + script_sha256: hex::encode(Sha256::digest(command.as_bytes())), + }); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn root_review_allows_build_scripts_but_rejects_all_ci_hooks() { + let mut manifest = json!({"name":"fictional-project", "scripts":{"build":"node build.mjs", "test":"vitest run"}}); + assert!( + review_root_document(&manifest, false).is_ok(), + "should allow ordinary explicit build and test scripts" + ); + assert!( + review_root_document(&manifest, true).is_err(), + "should reject implicit root binding install" + ); + for event in [ + "preinstall", + "install", + "postinstall", + "prepublish", + "preprepare", + "prepare", + "postprepare", + "predependencies", + "dependencies", + "postdependencies", + ] { + manifest["scripts"][event] = json!("node fictional-hook.mjs"); + assert!( + review_root_document(&manifest, false).is_err(), + "should reject root lifecycle hook" + ); + manifest["scripts"] + .as_object_mut() + .expect("should have scripts object") + .remove(event); + } + } + + #[test] + fn dependency_review_requires_exact_source_owned_hook_tuples() { + let manifest = json!({"name":"fictional-package", "version":"1.0.0"}); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false) + .expect("should accept package without hooks") + .is_empty(), + "should declare no hook execution" + ); + assert!( + declared_hooks(&manifest, "other-package", "1.0.0", false).is_err(), + "should reject manifest identity substitution" + ); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", true).is_err(), + "should reject implicit binding install" + ); + for (name, version, command) in [ + ( + "core-js", + "3.48.0", + "node -e \"try{require('./postinstall')}catch(e){}\"", + ), + ("esbuild", "0.27.3", "node install.js"), + ] { + let mut manifest = + json!({"name":name,"version":version,"scripts":{"postinstall":command}}); + assert_eq!( + declared_hooks(&manifest, name, version, false) + .expect("should match exact reviewed declaration") + .len(), + 1, + "should return one declaration" + ); + manifest["scripts"]["postinstall"] = json!(format!("{command} ")); + assert!( + declared_hooks(&manifest, name, version, false).is_err(), + "should reject changed command bytes" + ); + manifest["scripts"]["postinstall"] = json!(command); + manifest["scripts"]["install"] = json!("node other.mjs"); + assert!( + declared_hooks(&manifest, name, version, false).is_err(), + "should reject added lifecycle command" + ); + } + } + + #[test] + fn rejects_malformed_scripts_and_hashes_the_exact_declared_command() { + for scripts in [Value::Null, json!([]), json!({"postinstall":42}), json!({"postinstall":""})] { + let manifest = json!({"name":"fictional-package", "version":"1.0.0", "scripts":scripts}); + assert!(declared_hooks(&manifest, "fictional-package", "1.0.0", false).is_err(), "should reject malformed script declarations"); + } + let manifest = json!({"name":"esbuild", "version":"0.27.3", "scripts":{"postinstall":"node install.js"}}); + let declarations = declared_hooks(&manifest, "esbuild", "0.27.3", false).expect("should match reviewed command"); + assert_eq!(declarations[0].event(), "postinstall", "should preserve declared event"); + assert_eq!(declarations[0].script_sha256(), "912d4d8f507b7b392ae422a459f98da94669a742e4cc43cfe061e630ee2846fe", "should hash exact UTF-8 command bytes"); + let mut changed = manifest; + changed["version"] = json!("0.27.4"); + assert!(declared_hooks(&changed, "esbuild", "0.27.4", false).is_err(), "should reject unreviewed locked version"); + let prepare = json!({"name":"fictional-package", "version":"1.0.0", "scripts":{"prepare":"node source-only.mjs"}}); + assert!(declared_hooks(&prepare, "fictional-package", "1.0.0", false).expect("should parse registry manifest").is_empty(), "should not infer registry prepare execution"); + assert!(parse_json(br#"{"name":"fictional-package","name":"substitute","version":"1.0.0"}"#, Role::Metadata).is_err(), "should reject duplicate manifest keys before selection"); + } +} diff --git a/crates/trusted-server-release/src/source/tests/closure.rs b/crates/trusted-server-release/src/source/tests/closure.rs index f9aba350fb..3fc297f28f 100644 --- a/crates/trusted-server-release/src/source/tests/closure.rs +++ b/crates/trusted-server-release/src/source/tests/closure.rs @@ -2,6 +2,74 @@ use super::*; +#[test] +fn root_lifecycle_review_reads_only_the_approved_manifest() { + let mut fixture = Fixture::new(); + let manifest = serde_json::to_vec( + &json!({"name":"fictional-project", "scripts":{"build":"node build.mjs"}}), + ) + .expect("should encode root manifest"); + write(&fixture.root, "Cargo.lock", b"version = 4\npackage = []\n"); + write( + &fixture.root, + "crates/trusted-server-js/lib/package-lock.json", + &serde_json::to_vec(&json!({"lockfileVersion":3,"packages":{}})) + .expect("should encode empty lock"), + ); + write( + &fixture.root, + "crates/trusted-server-js/lib/package.json", + &manifest, + ); + fixture.commit_all(); + let source = fixture + .authenticate() + .expect("should authenticate root source"); + let materials = crate::materials::LockedMaterials::from_source(&source) + .expect("should read approved locks"); + let review = materials + .review_root(&source) + .expect("should review hook-free approved root"); + assert_eq!( + review.source_commit(), + fixture.commit, + "should bind declarations to approved source" + ); + write( + &fixture.root, + "crates/trusted-server-js/lib/package.json", + b"{}", + ); + assert!( + materials.review_root(&source).is_err(), + "should reject root manifest changed after approval" + ); + write( + &fixture.root, + "crates/trusted-server-js/lib/package.json", + &manifest, + ); + write( + &fixture.root, + "crates/trusted-server-js/lib/binding.gyp", + b"fictional implicit binding", + ); + fixture.commit_all(); + let source = fixture + .authenticate() + .expect("should authenticate changed source"); + let current = crate::materials::LockedMaterials::from_source(&source) + .expect("should read current approved locks"); + assert!( + current.review_root(&source).is_err(), + "should reject approved root implicit install" + ); + assert!( + materials.review_root(&source).is_err(), + "should reject another approved source identity" + ); +} + #[test] fn material_lockfiles_are_read_only_as_approved_tracked_bytes() { let mut fixture = Fixture::new(); diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index 9ac6a02328..4f49e1766d 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -158,6 +158,25 @@ selected Node 24.12.0 despite package-bin shadowing. Missing prepared native Vite/esbuild tools MUST fail without fetching substitutes. Observed lifecycle records cannot be inferred from lockfile flags or successful npm exit alone. +The source-owned declaration policy in the Rust material reviewer permits only +these exact dependency install-hook tuples. It checks the name/version against +the approved lock, the manifest bytes against the authenticated extraction, and +the command bytes against this policy: + +| Package | Version | Event | Exact command | +| --------- | -------- | ------------- | --------------------------------------------------- | +| `core-js` | `3.48.0` | `postinstall` | `node -e "try{require('./postinstall')}catch(e){}"` | +| `esbuild` | `0.27.3` | `postinstall` | `node install.js` | + +Any added hook or command/version change requires a source policy review. +Root review rejects all ten `npm ci` lifecycle hooks: `preinstall`, `install`, +`postinstall`, `prepublish`, `preprepare`, `prepare`, `postprepare`, +`predependencies`, `dependencies` and `postdependencies`. The last three also run +through pinned npm's Arborist when installation changes the dependency tree. These +results describe declarations only. They grant no execution authority and supply +no starts, completions, failures or changed-file observations. Full installation +tree mutation checks and actual lifecycle observations remain separate gates. + Run collector and unconditional full Vitest/type checks under that same native boundary, checking tracked source and unexpected files before/after each stage. Preserve release Vitest's literal host and narrowly scoped native `node:` import From fd7c2d3c86c3bab5faea4cd10522ded4bd8b417c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 16:19:33 +0530 Subject: [PATCH 055/153] Require Git object bodies to match preflight sizes --- .../src/materials/manifest.rs | 51 +++++++++++++++---- .../trusted-server-release/src/source/git.rs | 51 +++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-release/src/materials/manifest.rs b/crates/trusted-server-release/src/materials/manifest.rs index ac2f137f76..e5060ef9be 100644 --- a/crates/trusted-server-release/src/materials/manifest.rs +++ b/crates/trusted-server-release/src/materials/manifest.rs @@ -290,19 +290,52 @@ mod tests { #[test] fn rejects_malformed_scripts_and_hashes_the_exact_declared_command() { - for scripts in [Value::Null, json!([]), json!({"postinstall":42}), json!({"postinstall":""})] { - let manifest = json!({"name":"fictional-package", "version":"1.0.0", "scripts":scripts}); - assert!(declared_hooks(&manifest, "fictional-package", "1.0.0", false).is_err(), "should reject malformed script declarations"); + for scripts in [ + Value::Null, + json!([]), + json!({"postinstall":42}), + json!({"postinstall":""}), + ] { + let manifest = + json!({"name":"fictional-package", "version":"1.0.0", "scripts":scripts}); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false).is_err(), + "should reject malformed script declarations" + ); } let manifest = json!({"name":"esbuild", "version":"0.27.3", "scripts":{"postinstall":"node install.js"}}); - let declarations = declared_hooks(&manifest, "esbuild", "0.27.3", false).expect("should match reviewed command"); - assert_eq!(declarations[0].event(), "postinstall", "should preserve declared event"); - assert_eq!(declarations[0].script_sha256(), "912d4d8f507b7b392ae422a459f98da94669a742e4cc43cfe061e630ee2846fe", "should hash exact UTF-8 command bytes"); + let declarations = declared_hooks(&manifest, "esbuild", "0.27.3", false) + .expect("should match reviewed command"); + assert_eq!( + declarations[0].event(), + "postinstall", + "should preserve declared event" + ); + assert_eq!( + declarations[0].script_sha256(), + "912d4d8f507b7b392ae422a459f98da94669a742e4cc43cfe061e630ee2846fe", + "should hash exact UTF-8 command bytes" + ); let mut changed = manifest; changed["version"] = json!("0.27.4"); - assert!(declared_hooks(&changed, "esbuild", "0.27.4", false).is_err(), "should reject unreviewed locked version"); + assert!( + declared_hooks(&changed, "esbuild", "0.27.4", false).is_err(), + "should reject unreviewed locked version" + ); let prepare = json!({"name":"fictional-package", "version":"1.0.0", "scripts":{"prepare":"node source-only.mjs"}}); - assert!(declared_hooks(&prepare, "fictional-package", "1.0.0", false).expect("should parse registry manifest").is_empty(), "should not infer registry prepare execution"); - assert!(parse_json(br#"{"name":"fictional-package","name":"substitute","version":"1.0.0"}"#, Role::Metadata).is_err(), "should reject duplicate manifest keys before selection"); + assert!( + declared_hooks(&prepare, "fictional-package", "1.0.0", false) + .expect("should parse registry manifest") + .is_empty(), + "should not infer registry prepare execution" + ); + assert!( + parse_json( + br#"{"name":"fictional-package","name":"substitute","version":"1.0.0"}"#, + Role::Metadata + ) + .is_err(), + "should reject duplicate manifest keys before selection" + ); } } diff --git a/crates/trusted-server-release/src/source/git.rs b/crates/trusted-server-release/src/source/git.rs index 24adf75298..d76fd2bfe1 100644 --- a/crates/trusted-server-release/src/source/git.rs +++ b/crates/trusted-server-release/src/source/git.rs @@ -194,6 +194,7 @@ impl Git { &Batch { kind, oids: &oids[start..end], + sizes: &sizes[start..end], budget: &self.object_budget, }, &mut |index, bytes| visit(start + index, bytes), @@ -249,6 +250,7 @@ fn object_id(kind: &str, bytes: &[u8]) -> Result> { struct Batch<'a> { kind: &'a str, oids: &'a [&'a str], + sizes: &'a [u64], budget: &'a Cell, } @@ -259,8 +261,18 @@ fn read_batch( ) -> Result<(), Report> { let io_error = |error: io::Error| source_error(format!("cannot read batch output: {error}")); let kind = batch.kind; + if batch.sizes.len() != batch.oids.len() { + return Err(source_error( + "Git preflight size count differs from requested object count", + )); + } for (index, oid) in batch.oids.iter().enumerate() { let size = read_header(reader, kind, oid)?; + if size != batch.sizes[index] { + return Err(source_error( + "Git object body size differs from preflight metadata", + )); + } let remaining = batch.budget.get(); if size > OBJECT_LIMIT || size > remaining { return Err(source_error(format!( @@ -309,3 +321,42 @@ fn read_header( ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_body_size_must_match_the_preflight_size() { + let bytes = b"fictional object"; + let oid = object_id("blob", bytes).expect("should hash fixture blob"); + let mut raw = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + raw.extend_from_slice(bytes); + raw.push(b'\n'); + let budget = Cell::new(1024); + let batch = Batch { + kind: "blob", + oids: &[oid.as_str()], + sizes: &[1], + budget: &budget, + }; + let mut delivered = false; + let result = read_batch(&mut Cursor::new(raw), &batch, &mut |_, _| { + delivered = true; + Ok(()) + }); + assert!( + result.is_err(), + "should reject body header inconsistent with preflight metadata" + ); + assert!( + !delivered, + "should reject before delivering any object bytes" + ); + assert_eq!( + budget.get(), + 1024, + "should reject before consuming object budget" + ); + } +} From 67aaa3fc3d8ba21011472be60e500b884f590742 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 16:30:25 +0530 Subject: [PATCH 056/153] Share bounded Git object verification across source and Cargo policies --- crates/trusted-server-release/src/error.rs | 2 + .../trusted-server-release/src/git_objects.rs | 395 ++++++++++++++++++ crates/trusted-server-release/src/main.rs | 1 + crates/trusted-server-release/src/source.rs | 21 +- .../trusted-server-release/src/source/git.rs | 220 +--------- .../src/source/tests.rs | 1 + ...6-10-13-attested-release-implementation.md | 48 ++- 7 files changed, 452 insertions(+), 236 deletions(-) create mode 100644 crates/trusted-server-release/src/git_objects.rs diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 7f59ef7df7..9e5c580571 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -14,6 +14,8 @@ pub(crate) enum ReleaseError { Environment, #[display("Release source is not authenticated")] Source, + #[display("Git object verification failed")] + GitObject, #[display("Production release selection is not qualified")] Unqualified, #[display("Release inputs are incomplete")] diff --git a/crates/trusted-server-release/src/git_objects.rs b/crates/trusted-server-release/src/git_objects.rs new file mode 100644 index 0000000000..d2aea4cfc3 --- /dev/null +++ b/crates/trusted-server-release/src/git_objects.rs @@ -0,0 +1,395 @@ +//! Collision-checked Git objects under fixed source and Cargo material budgets. + +use std::cell::Cell; +use std::io::{self, BufRead, Cursor, Read as _}; + +use error_stack::Report; + +use crate::error::ReleaseError; + +fn object_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::GitObject).attach(detail.to_string()) +} + +/// A shared byte budget across every request in one object collection. +/// +/// These checks authenticate bytes, not a Git executable or installed checkout. +pub(crate) struct ObjectReader { + object_limit: u64, + remaining: Cell, +} + +impl ObjectReader { + /// Creates the source-checkout policy: 64 MiB per object, 2 GiB total. + pub(crate) fn source() -> Self { + Self { + object_limit: 64 * 1024 * 1024, + remaining: Cell::new(2 * 1024 * 1024 * 1024), + } + } + + /// Creates one Cargo collection policy: 32 MiB per object, 512 MiB total. + #[allow(dead_code, reason = "consumed by Task 7 Cargo acquisition")] + pub(crate) fn cargo_materials() -> Self { + Self { + object_limit: 32 * 1024 * 1024, + remaining: Cell::new(512 * 1024 * 1024), + } + } + + #[cfg(test)] + /// Lowers a test's budget without allocating production-sized bodies. + pub(crate) fn set_budget_for_tests(&self, bytes: u64) { + self.remaining.set(bytes); + } + + /// Preflights sizes, reads bounded batches and delivers only rehashed bytes. + /// + /// The caller's executor must enforce each supplied output bound and its + /// shared deadline/cancellation policy. This reader does not authenticate + /// the executable or supervise processes itself. + /// + /// # Errors + /// + /// Returns an error for failed commands, malformed or inconsistent replies, + /// exceeded byte budgets, identity mismatches or detected SHA-1 collisions. + pub(crate) fn read_objects( + &self, + kind: &'static str, + oids: &[&str], + execute: &mut impl FnMut(&[&str], Vec, u64) -> Result, Report>, + mut visit: impl FnMut(usize, &[u8]) -> Result<(), Report>, + ) -> Result<(), Report> { + let sizes = self.object_sizes(kind, oids, execute)?; + let mut start = 0; + while start < oids.len() { + let mut end = start; + let mut limit = 0; + while end < oids.len() && limit + sizes[end] + 128 <= self.object_limit + 128 { + limit += sizes[end] + 128; + end += 1; + } + let output = execute(&["cat-file", "--batch"], request(&oids[start..end]), limit)?; + let mut reader = Cursor::new(output.as_slice()); + read_batch( + &mut reader, + &Batch { + kind, + object_limit: self.object_limit, + oids: &oids[start..end], + sizes: &sizes[start..end], + budget: &self.remaining, + }, + &mut |index, bytes| visit(start + index, bytes), + )?; + if reader.position() != output.len() as u64 { + return Err(object_error("unexpected trailing Git batch output")); + } + start = end; + } + Ok(()) + } + + fn object_sizes( + &self, + kind: &str, + oids: &[&str], + execute: &mut impl FnMut(&[&str], Vec, u64) -> Result, Report>, + ) -> Result, Report> { + let limit = (oids.len() as u64) + .checked_mul(128) + .ok_or_else(|| object_error("object header budget overflow"))?; + let output = execute(&["cat-file", "--batch-check"], request(oids), limit)?; + let mut reader = Cursor::new(output.as_slice()); + let mut total = 0_u64; + let mut sizes = Vec::with_capacity(oids.len()); + for oid in oids { + let size = read_header(&mut reader, kind, oid)?; + total = total + .checked_add(size) + .ok_or_else(|| object_error("object byte budget overflow"))?; + if size > self.object_limit || total > self.remaining.get() { + return Err(object_error( + "object exceeds the per-object or aggregate object byte budget", + )); + } + sizes.push(size); + } + if reader.position() != output.len() as u64 { + return Err(object_error("unexpected trailing Git object metadata")); + } + Ok(sizes) + } +} + +fn request(oids: &[&str]) -> Vec { + oids.iter() + .flat_map(|oid| oid.bytes().chain(std::iter::once(b'\n'))) + .collect() +} + +/// Collision-checked Git SHA-1 object id of `bytes` stored as an object of `kind`. +fn object_id(kind: &str, bytes: &[u8]) -> Result> { + let header = format!("{kind} {}\0", bytes.len()); + checked_sha1(&[header.as_bytes(), bytes]) +} + +/// One `cat-file --batch` request and the shared aggregate byte budget. +struct Batch<'a> { + kind: &'a str, + object_limit: u64, + oids: &'a [&'a str], + sizes: &'a [u64], + budget: &'a Cell, +} + +fn read_batch( + reader: &mut impl BufRead, + batch: &Batch<'_>, + visit: &mut impl FnMut(usize, &[u8]) -> Result<(), Report>, +) -> Result<(), Report> { + let io_error = |error: io::Error| object_error(format!("cannot read batch output: {error}")); + let kind = batch.kind; + if batch.sizes.len() != batch.oids.len() { + return Err(object_error( + "Git preflight size count differs from requested object count", + )); + } + for (index, oid) in batch.oids.iter().enumerate() { + let size = read_header(reader, kind, oid)?; + if size != batch.sizes[index] { + return Err(object_error( + "Git object body size differs from preflight metadata", + )); + } + let remaining = batch.budget.get(); + if size > batch.object_limit || size > remaining { + return Err(object_error(format!( + "object {oid} exceeds the per-object or aggregate object byte budget" + ))); + } + batch.budget.set(remaining - size); + let length = usize::try_from(size).map_err(|_| object_error("object size overflows"))?; + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).map_err(io_error)?; + let mut terminator = [0]; + reader.read_exact(&mut terminator).map_err(io_error)?; + if terminator != *b"\n" { + return Err(object_error(format!("batch output for {oid} is malformed"))); + } + if object_id(kind, &bytes)? != *oid { + return Err(object_error(format!( + "object identity mismatch: {kind} {oid} content does not hash to its id" + ))); + } + visit(index, &bytes)?; + } + Ok(()) +} + +/// Reads one ` ` header and returns the object size. +fn read_header( + reader: &mut impl BufRead, + kind: &str, + oid: &str, +) -> Result> { + let mut header = Vec::new(); + reader + .take(128) + .read_until(b'\n', &mut header) + .map_err(|error| object_error(format!("cannot read batch header: {error}")))?; + let header = + std::str::from_utf8(&header).map_err(|_| object_error("batch header is not UTF-8"))?; + let fields: Vec<&str> = header.trim_end_matches('\n').split(' ').collect(); + match fields.as_slice() { + [returned, returned_kind, size] if *returned == oid && *returned_kind == kind => size + .parse::() + .map_err(|_| object_error("batch object size is malformed")), + _ => Err(object_error(format!( + "object {oid} is not a readable {kind}" + ))), + } +} + +/// Computes a Git identity with SHA-1 collision detection. +/// +/// # Errors +/// +/// Returns an error when the collision detector rejects the supplied bytes. +pub(crate) fn checked_sha1(parts: &[&[u8]]) -> Result> { + let mut hasher = sha1_checked::Sha1::new(); + for part in parts { + sha1_checked::Digest::update(&mut hasher, part); + } + let result = hasher.try_finalize(); + if result.has_collision() { + return Err(object_error( + "SHA-1 collision detection flags a Git object; its identity is not trusted", + )); + } + Ok(hex::encode(result.hash())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn shared_reader_preserves_collision_checked_object_delivery() { + let bytes = b"fictional object"; + let mut hash = sha1_checked::Sha1::new(); + sha1_checked::Digest::update(&mut hash, format!("blob {}\0", bytes.len()).as_bytes()); + sha1_checked::Digest::update(&mut hash, bytes); + let oid = hex::encode(hash.try_finalize().hash()); + let mut delivered = false; + ObjectReader::source() + .read_objects( + "blob", + &[&oid], + &mut |arguments, _, _| { + let mut output = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + if arguments.contains(&"--batch") { + output.extend_from_slice(bytes); + output.push(b'\n'); + } + Ok(output) + }, + |_, body| { + assert_eq!(body, bytes, "should deliver exact verified bytes"); + delivered = true; + Ok(()) + }, + ) + .expect("should verify a complete object through shared reader"); + assert!(delivered, "should deliver verified object"); + } + + #[test] + fn cargo_materials_reject_over_32_mib_before_requesting_body() { + let oid = "a".repeat(40); + for (reader, expects_body) in [ + (ObjectReader::cargo_materials(), false), + (ObjectReader::source(), true), + ] { + let body_requested = Cell::new(false); + let result = reader.read_objects( + "blob", + &[&oid], + &mut |arguments, _, _| { + if arguments.contains(&"--batch-check") { + Ok(format!("{oid} blob {}\n", 32 * 1024 * 1024 + 1).into_bytes()) + } else { + body_requested.set(true); + Err(Report::new(ReleaseError::Source) + .attach("fixture stops before allocating body")) + } + }, + |_, _| panic!("should never deliver fixture object"), + ); + assert!( + result.is_err(), + "should reject fixture without allocating large body" + ); + assert_eq!( + body_requested.get(), + expects_body, + "should preserve distinct source and Cargo per-object policies" + ); + } + } + + #[test] + fn cargo_collection_rejects_over_512_mib_before_any_body_request() { + let oid = "a".repeat(40); + let oids = vec![oid.as_str(); 17]; + for (reader, expects_body) in [ + (ObjectReader::cargo_materials(), false), + (ObjectReader::source(), true), + ] { + let mut body_requested = false; + let result = reader.read_objects( + "blob", + &oids, + &mut |arguments, _, _| { + if arguments.contains(&"--batch-check") { + Ok(format!("{oid} blob {}\n", 32 * 1024 * 1024) + .repeat(oids.len()) + .into_bytes()) + } else { + body_requested = true; + Err(object_error("fixture stops before body allocation")) + } + }, + |_, _| panic!("should never deliver large fixture object"), + ); + assert!( + result.is_err(), + "should reject fixture without materializing bodies" + ); + assert_eq!( + body_requested, expects_body, + "should preserve distinct aggregate source and Cargo limits" + ); + } + } + + #[test] + fn body_size_disagreement_rejects_before_budget_or_delivery() { + let bytes = b"fictional object"; + let oid = object_id("blob", bytes).expect("should hash fixture blob"); + let mut raw = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + raw.extend_from_slice(bytes); + raw.push(b'\n'); + let budget = Cell::new(1024); + let batch = Batch { + kind: "blob", + object_limit: 64 * 1024 * 1024, + oids: &[&oid], + sizes: &[1], + budget: &budget, + }; + let mut delivered = false; + assert!( + read_batch(&mut Cursor::new(raw), &batch, &mut |_, _| { + delivered = true; + Ok(()) + }) + .is_err(), + "should reject body size inconsistent with preflight" + ); + assert!(!delivered, "should reject before delivering object bytes"); + assert_eq!( + budget.get(), + 1024, + "should preserve budget before metadata joins" + ); + } + + #[test] + fn cargo_budget_accumulates_across_separate_object_requests() { + let bytes = b"fictional object"; + let oid = object_id("blob", bytes).expect("should hash fixture object"); + let reader = ObjectReader::cargo_materials(); + reader.set_budget_for_tests(bytes.len() as u64); + let mut execute = |arguments: &[&str], _: Vec, _: u64| { + let mut output = format!("{oid} blob {}\n", bytes.len()).into_bytes(); + if arguments.contains(&"--batch") { + output.extend_from_slice(bytes); + output.push(b'\n'); + } + Ok(output) + }; + reader + .read_objects("blob", &[&oid], &mut execute, |_, _| Ok(())) + .expect("should consume entire shared budget"); + assert!( + reader + .read_objects("blob", &[&oid], &mut execute, |_, _| panic!( + "should not deliver after exhaustion" + )) + .is_err(), + "should preserve aggregate budget across requests" + ); + } +} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 840cd91786..46c1b22005 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -5,6 +5,7 @@ mod boundary; #[allow(dead_code)] mod documents; mod error; +mod git_objects; #[allow(dead_code, reason = "consumed by Task 9 recipes")] mod inspect; #[allow(dead_code, reason = "consumed by Task 7 material preparation")] diff --git a/crates/trusted-server-release/src/source.rs b/crates/trusted-server-release/src/source.rs index d5820cd645..ded2a4714f 100644 --- a/crates/trusted-server-release/src/source.rs +++ b/crates/trusted-server-release/src/source.rs @@ -370,26 +370,7 @@ fn commit_tree(raw: &[u8]) -> Result> { .ok_or_else(|| source_error("commit object must start with a SHA-1 tree line")) } -/// Collision-checked SHA-1 over the concatenation of `parts`. -/// -/// # Errors -/// -/// Fails when SHA-1 collision detection flags the input, including inputs -/// the detector would otherwise silently mitigate. -fn checked_sha1(parts: &[&[u8]]) -> Result> { - let mut hasher = sha1_checked::Sha1::new(); - for part in parts { - sha1_checked::Digest::update(&mut hasher, part); - } - let result = hasher.try_finalize(); - if result.has_collision() { - return Err(source_error( - "SHA-1 collision detection flags a Git object; its identity is not trusted", - )); - } - Ok(hex::encode(result.hash())) -} - +/// Records the verified tracked blob identities and original root manifest. fn tracked_records( git: &Git, entries: &[TreeEntry], diff --git a/crates/trusted-server-release/src/source/git.rs b/crates/trusted-server-release/src/source/git.rs index d76fd2bfe1..f98cf57ebd 100644 --- a/crates/trusted-server-release/src/source/git.rs +++ b/crates/trusted-server-release/src/source/git.rs @@ -1,28 +1,20 @@ //! Bounded read-only Git plumbing under a cleared, hardened environment. -use std::cell::Cell; use std::ffi::OsString; use std::fs; -use std::io::{self, BufRead, Cursor, Read as _}; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use error_stack::Report; -use super::{checked_sha1, source_error}; +use super::source_error; use crate::error::ReleaseError; +use crate::git_objects::ObjectReader; use crate::preflight::AuditedEnvironment; use crate::process::{BoundedCommand, ProcessLimits, ProcessResult}; const STDERR_LIMIT: u64 = 64 * 1024; -/// Largest single object read; far above any tracked source file. -const OBJECT_LIMIT: u64 = 64 * 1024 * 1024; -/// Total bytes of all objects read in one authentication. The tracked tree is -/// currently tens of MiB; 2 GiB leaves ample growth while bounding the work a -/// hostile repository can cause, since objects are held one at a time. -const AGGREGATE_OBJECT_BUDGET: u64 = 2 * 1024 * 1024 * 1024; - /// Read-only Git plumbing under a cleared, hardened environment. pub(super) struct Git { program: PathBuf, @@ -31,7 +23,7 @@ pub(super) struct Git { root: PathBuf, home: tempfile::TempDir, /// Remaining bytes of the aggregate object budget. - object_budget: Cell, + objects: ObjectReader, wall_limit: Duration, attempt_deadline: Instant, } @@ -68,7 +60,7 @@ impl Git { child_path, root: root.to_path_buf(), home, - object_budget: Cell::new(AGGREGATE_OBJECT_BUDGET), + objects: ObjectReader::source(), wall_limit: Duration::from_secs(120), attempt_deadline: Instant::now() + Duration::from_secs(900), }) @@ -150,7 +142,7 @@ impl Git { /// Replaces the aggregate object budget so tests can exercise exhaustion. #[cfg(test)] pub(super) fn with_object_budget(self, bytes: u64) -> Self { - self.object_budget.set(bytes); + self.objects.set_budget_for_tests(bytes); self } @@ -163,200 +155,24 @@ impl Git { self.execute(args, None, stdout_limit) } - /// Reads objects of one `kind` through `cat-file --batch`, rehashing each. + /// Reads and collision-checks source objects under the source byte policy. + /// + /// # Errors /// - /// Every returned object must hash to its requested id as - /// `sha1(" \0" + bytes)` with collision detection; Git's - /// loose-object reads do not verify this themselves. + /// Returns an error for failed Git commands or invalid object bytes. pub(super) fn read_objects( &self, kind: &'static str, oids: &[&str], - mut visit: impl FnMut(usize, &[u8]) -> Result<(), Report>, + visit: impl FnMut(usize, &[u8]) -> Result<(), Report>, ) -> Result<(), Report> { - let sizes = self.object_sizes(kind, oids)?; - let mut start = 0; - while start < oids.len() { - let mut end = start; - let mut limit = 0; - while end < oids.len() && limit + sizes[end] + 128 <= OBJECT_LIMIT + 128 { - limit += sizes[end] + 128; - end += 1; - } - let output = self.execute( - &["cat-file", "--batch"], - Some(request(&oids[start..end])), - limit, - )?; - let mut reader = Cursor::new(output.as_slice()); - read_batch( - &mut reader, - &Batch { - kind, - oids: &oids[start..end], - sizes: &sizes[start..end], - budget: &self.object_budget, - }, - &mut |index, bytes| visit(start + index, bytes), - )?; - if reader.position() != output.len() as u64 { - return Err(source_error("unexpected trailing Git batch output")); - } - start = end; - } - Ok(()) - } - - fn object_sizes(&self, kind: &str, oids: &[&str]) -> Result, Report> { - let limit = (oids.len() as u64) - .checked_mul(128) - .ok_or_else(|| source_error("object header budget overflow"))?; - let output = self.execute(&["cat-file", "--batch-check"], Some(request(oids)), limit)?; - let mut reader = Cursor::new(output.as_slice()); - let mut total = 0_u64; - let mut sizes = Vec::with_capacity(oids.len()); - for oid in oids { - let size = read_header(&mut reader, kind, oid)?; - total = total - .checked_add(size) - .ok_or_else(|| source_error("object byte budget overflow"))?; - if size > OBJECT_LIMIT || total > self.object_budget.get() { - return Err(source_error( - "object exceeds the per-object or aggregate object byte budget", - )); - } - sizes.push(size); - } - if reader.position() != output.len() as u64 { - return Err(source_error("unexpected trailing Git object metadata")); - } - Ok(sizes) - } -} - -fn request(oids: &[&str]) -> Vec { - oids.iter() - .flat_map(|oid| oid.bytes().chain(std::iter::once(b'\n'))) - .collect() -} - -/// Collision-checked Git SHA-1 object id of `bytes` stored as an object of `kind`. -fn object_id(kind: &str, bytes: &[u8]) -> Result> { - let header = format!("{kind} {}\0", bytes.len()); - checked_sha1(&[header.as_bytes(), bytes]) -} - -/// One `cat-file --batch` request and the shared aggregate byte budget. -struct Batch<'a> { - kind: &'a str, - oids: &'a [&'a str], - sizes: &'a [u64], - budget: &'a Cell, -} - -fn read_batch( - reader: &mut impl BufRead, - batch: &Batch<'_>, - visit: &mut impl FnMut(usize, &[u8]) -> Result<(), Report>, -) -> Result<(), Report> { - let io_error = |error: io::Error| source_error(format!("cannot read batch output: {error}")); - let kind = batch.kind; - if batch.sizes.len() != batch.oids.len() { - return Err(source_error( - "Git preflight size count differs from requested object count", - )); - } - for (index, oid) in batch.oids.iter().enumerate() { - let size = read_header(reader, kind, oid)?; - if size != batch.sizes[index] { - return Err(source_error( - "Git object body size differs from preflight metadata", - )); - } - let remaining = batch.budget.get(); - if size > OBJECT_LIMIT || size > remaining { - return Err(source_error(format!( - "object {oid} exceeds the per-object or aggregate object byte budget" - ))); - } - batch.budget.set(remaining - size); - let length = usize::try_from(size).map_err(|_| source_error("object size overflows"))?; - let mut bytes = vec![0; length]; - reader.read_exact(&mut bytes).map_err(io_error)?; - let mut terminator = [0]; - reader.read_exact(&mut terminator).map_err(io_error)?; - if terminator != *b"\n" { - return Err(source_error(format!("batch output for {oid} is malformed"))); - } - if object_id(kind, &bytes)? != *oid { - return Err(source_error(format!( - "object identity mismatch: {kind} {oid} content does not hash to its id" - ))); - } - visit(index, &bytes)?; - } - Ok(()) -} - -/// Reads one ` ` header and returns the object size. -fn read_header( - reader: &mut impl BufRead, - kind: &str, - oid: &str, -) -> Result> { - let mut header = Vec::new(); - reader - .take(128) - .read_until(b'\n', &mut header) - .map_err(|error| source_error(format!("cannot read batch header: {error}")))?; - let header = - std::str::from_utf8(&header).map_err(|_| source_error("batch header is not UTF-8"))?; - let fields: Vec<&str> = header.trim_end_matches('\n').split(' ').collect(); - match fields.as_slice() { - [returned, returned_kind, size] if *returned == oid && *returned_kind == kind => size - .parse::() - .map_err(|_| source_error("batch object size is malformed")), - _ => Err(source_error(format!( - "object {oid} is not a readable {kind}" - ))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn batch_body_size_must_match_the_preflight_size() { - let bytes = b"fictional object"; - let oid = object_id("blob", bytes).expect("should hash fixture blob"); - let mut raw = format!("{oid} blob {}\n", bytes.len()).into_bytes(); - raw.extend_from_slice(bytes); - raw.push(b'\n'); - let budget = Cell::new(1024); - let batch = Batch { - kind: "blob", - oids: &[oid.as_str()], - sizes: &[1], - budget: &budget, - }; - let mut delivered = false; - let result = read_batch(&mut Cursor::new(raw), &batch, &mut |_, _| { - delivered = true; - Ok(()) - }); - assert!( - result.is_err(), - "should reject body header inconsistent with preflight metadata" - ); - assert!( - !delivered, - "should reject before delivering any object bytes" - ); - assert_eq!( - budget.get(), - 1024, - "should reject before consuming object budget" - ); + self.objects + .read_objects( + kind, + oids, + &mut |arguments, input, limit| self.execute(arguments, Some(input), limit), + visit, + ) + .map_err(|report| report.change_context(ReleaseError::Source)) } } diff --git a/crates/trusted-server-release/src/source/tests.rs b/crates/trusted-server-release/src/source/tests.rs index 5396fb29ab..fa7db0272b 100644 --- a/crates/trusted-server-release/src/source/tests.rs +++ b/crates/trusted-server-release/src/source/tests.rs @@ -10,6 +10,7 @@ use super::config::check_git_config; use super::git::Git; use super::tree::{TreeLimits, parse_tree_object}; use super::*; +use crate::git_objects::checked_sha1; use crate::policy::ReleaseTarget; use crate::preflight::audit_environment; use crate::preflight::child::{ChildRequest, child_environment}; diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index bc909e95cd..58b153959a 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -47,7 +47,27 @@ Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrapp The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. -Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The macOS profile still permits a scrubbed process-group escape and is not a qualified lifecycle-containment backend. Linux container execution requires actual native CI and qualification. Original-material preparation, two-installation JS orchestration, canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. Local utility checks are not actual macOS 15/GNU or hosted release proof. +Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Original archive authentication, bounded private extraction and lifecycle declaration review are implemented. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The macOS profile still permits a scrubbed process-group escape and is not a qualified lifecycle-containment backend. Native boundary controls pass ordinary Ubuntu 24.04 and macOS 15 CI; they do not qualify complete release recipes or authenticated tool selections. Git material preparation, two-installation JS orchestration, canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. + +## Current task status — 2026-10-15 + +Count complete task exits separately from implemented code awaiting external +qualification. There are **6 complete tasks and 16 open tasks**. Two open tasks +have their implementation finished; eight tasks therefore have completed code. +Task counts do not measure remaining effort. + +| Status | Count | Tasks | Remaining exit work | +| ---------------------------------------------- | ----- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Complete | 6 | 2, 4, 5, 6, 17, 20 | Implementation, local verification and independent review completed | +| Implementation complete; qualification pending | 2 | 18, 19 | Pinned real signed fixture and independent first-use verification | +| Partial or prerequisite approval pending | 5 | 1, 3, 7, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; Git materials, actual JS lifecycle and A/B runs; platform qualification; full Rust recipes/runtime acceptance | +| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | + +Task 7's archive and declaration checks are prerequisites. They do not construct +`JsBuildCompleted`, prove hook execution or complete that task. Task 9's +structural binary inspection does not prove a successful canonical build or +Fastly publisher request. Ordinary CI passes do not change the pending +qualification pins or authorize release publication. ## Work breakdown and ownership @@ -380,13 +400,13 @@ Implementation sequencing: review the local validation framework and candidate b **Create:** CLI `src/release/mod.rs`, `error.rs`, `policy.rs`, `documents.rs`, `src/release/tests/mod.rs`, `src/release/tests/documents.rs`, `tests/fixtures/release/`. **Modify:** CLI `src/lib.rs`, `Cargo.toml` (host-only sha2/schema validator with justification). -- [ ] Before the first filtered run, wire the host-only `mod release` in CLI `src/lib.rs`, the production child modules in `src/release/mod.rs`, `#[cfg(test)] mod tests` there, and `mod documents` in `src/release/tests/mod.rs`. Add the test cases before checking selection; Rust does not discover this directory automatically. Keep these internals private. -- [ ] Test closed schema/kind/profile/action combinations, structurally valid but policy-forbidden `fastly-staging`, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. -- [ ] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::documents`; expect the intended missing implementation or assertion failure. When it compiles, use the nonzero-selection guard below before accepting red or green; a successful run selecting zero tests is a failure of this plan, not a pass. -- [ ] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (independently approved source snapshot or fixed official GitHub Contents API acquisition from that exact independently approved full commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. -- [ ] Follow [R-POLICY](../specs/2026-10-09-1235-release-attestation-design.md#r-policy): fetch only `https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref=`, `Accept: application/vnd.github.raw+json`, explicit read-only credential, 1 MiB maximum and attempt deadline. Reject arbitrary host/path or credential-forwarding redirects. Add source/API mismatch, unsupported producer v2 under consumer v1, reviewed historical v1 under newer consumer, and current-consumer-bound enforcement negatives. -- [ ] Keep the already registered tests active and require nonzero selected counts on every focused rerun; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. -- [ ] Fix repository/owner IDs, supported combinations, exact gh/action contract and all R-BOUNDS limits. Re-run tests/clippy. Commit `Parse release evidence under embedded consumer policy`. +- [x] Before the first filtered run, wire the host-only `mod release` in CLI `src/lib.rs`, the production child modules in `src/release/mod.rs`, `#[cfg(test)] mod tests` there, and `mod documents` in `src/release/tests/mod.rs`. Add the test cases before checking selection; Rust does not discover this directory automatically. Keep these internals private. +- [x] Test closed schema/kind/profile/action combinations, structurally valid but policy-forbidden `fastly-staging`, duplicate/deep/oversize JSON, self-selected policy, historical policy acceptance/retirement, altered approved-source policy bytes, allowlisted digest paired with the wrong source, unsupported tool and CLI-as-server substitution. +- [x] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::documents`; expect the intended missing implementation or assertion failure. When it compiles, use the nonzero-selection guard below before accepting red or green; a successful run selecting zero tests is a failure of this plan, not a pass. +- [x] Implement byte-first duplicate-rejecting parsers and the same schema/semantic fixtures as producer. Embed reviewed policy bytes; compute their SHA-256 externally at runtime. Policy contains no own digest. First bind producer-policy bytes to the independently approved source commit through the fixed official repository path `crates/trusted-server-cli/release-policy.json` (independently approved source snapshot or fixed official GitHub Contents API acquisition from that exact independently approved full commit); never obtain this authority from the release index or an arbitrary supplied path. Match its exact-byte digest/version, then accept only current policy bytes or the reviewed historical allowlist. For offline checks, independently transfer the approved source-policy snapshot alongside other trust inputs. Downloaded metadata never changes consumer policy. +- [x] Follow [R-POLICY](../specs/2026-10-09-1235-release-attestation-design.md#r-policy): fetch only `https://api.github.com/repos/IABTechLab/trusted-server/contents/crates/trusted-server-cli/release-policy.json?ref=`, `Accept: application/vnd.github.raw+json`, explicit read-only credential, 1 MiB maximum and attempt deadline. Reject arbitrary host/path or credential-forwarding redirects. Add source/API mismatch, unsupported producer v2 under consumer v1, reviewed historical v1 under newer consumer, and current-consumer-bound enforcement negatives. +- [x] Keep the already registered tests active and require nonzero selected counts on every focused rerun; process tests use public CLI dispatch rather than making internals public. Keep concrete release errors as Report; convert to the existing String dispatch boundary only at the command adapter. Do not introduce a new generic attestation crate or publisher types. +- [x] Fix repository/owner IDs, supported combinations, exact gh/action contract and all R-BOUNDS limits. Re-run tests/clippy. Commit `Parse release evidence under embedded consumer policy`. ### Task 18: Implement the bounded isolated gh runner @@ -422,12 +442,12 @@ Implementation sequencing: review the local validation framework and candidate b **Create:** CLI `src/commands/release.rs`, `tests/release_cli.rs`, `src/release/offline.rs`, `src/release/tests/offline.rs`, `docs/guide/release-verification.md`. **Modify:** CLI `src/commands/mod.rs`, `src/run.rs`, `src/release/mod.rs`, `src/release/tests/mod.rs`; `docs/guide/cli.md`, `.vitepress/config.mts`. -- [ ] Register `mod offline` in both the private production and test roots before its first focused run. Register the public command module/dispatch before process tests; run `release::tests::offline` with the nonzero-selection guard in addition to the CLI process target. -- [ ] Test the required parse path `ts release verify v1.2.0 --asset ts-aarch64-apple-darwin --expected-source-commit `, invalid source/asset/unsupported offline-deploy flags and no provider/config/build hooks on all failure paths. -- [ ] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" --test release_cli`; expect unknown command. -- [ ] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag MUST be documented and reviewed, not smuggled into an implementation example. -- [ ] Document trusted gh installation and independent source/previous-verifier bootstrap before first executing downloaded ts. A source-built verifier provides approved-source trust, not official binary provenance for itself. Describe exact policy checks after primitive gh signatures; do not instruct self-verification or checksum-only installation. -- [ ] Add an internal build-evidence-only offline verification path, reusing parser/policy/subject/SPDX joins and bounded gh runner with independently approved roots/source/digest. Test it with networking denied and token absent; its result cannot construct publication/deployment authorization. No initial public offline flag is added. Document the pinned primitive commands and the manual companion/document/policy checks needed beyond their exit status; offline success is build evidence only, no official release membership or offline deploy. Run CLI helper, docs lint/format/build and malformed/timeout/zero-provider-write fixtures. Commit `Expose official release verification and trusted bootstrap`. +- [x] Register `mod offline` in both the private production and test roots before its first focused run. Register the public command module/dispatch before process tests; run `release::tests::offline` with the nonzero-selection guard in addition to the CLI process target. +- [x] Test the required parse path `ts release verify v1.2.0 --asset ts-aarch64-apple-darwin --expected-source-commit `, invalid source/asset/unsupported offline-deploy flags and no provider/config/build hooks on all failure paths. +- [x] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" --test release_cli`; expect unknown command. +- [x] Add the command adapter to the reusable host verifier. The initial public surface stays the spec's verify/tag/asset/expected-source contract; any cache/export/output flag MUST be documented and reviewed, not smuggled into an implementation example. +- [x] Document trusted gh installation and independent source/previous-verifier bootstrap before first executing downloaded ts. A source-built verifier provides approved-source trust, not official binary provenance for itself. Describe exact policy checks after primitive gh signatures; do not instruct self-verification or checksum-only installation. +- [x] Add an internal build-evidence-only offline verification path, reusing parser/policy/subject/SPDX joins and bounded gh runner with independently approved roots/source/digest. Test it with networking denied and token absent; its result cannot construct publication/deployment authorization. No initial public offline flag is added. Document the pinned primitive commands and the manual companion/document/policy checks needed beyond their exit status; offline success is build evidence only, no official release membership or offline deploy. Run CLI helper, docs lint/format/build and malformed/timeout/zero-provider-write fixtures. Commit `Expose official release verification and trusted bootstrap`. ### Task 21: Validate and document manual Fastly production consumption From e07944bd540ffcec0080043b0fe97f6cc439af4a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 17:09:12 +0530 Subject: [PATCH 057/153] Keep boundary sibling fixtures alive until parent cleanup --- .../src/boundary/tests.rs | 7 +++-- .../src/boundary/tests/modes.rs | 4 +++ .../src/boundary/tests/unit.rs | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-release/src/boundary/tests.rs b/crates/trusted-server-release/src/boundary/tests.rs index 5689787d7d..2913b320f6 100644 --- a/crates/trusted-server-release/src/boundary/tests.rs +++ b/crates/trusted-server-release/src/boundary/tests.rs @@ -134,14 +134,15 @@ fn helper_report(stdout: &[u8]) -> String { .to_owned() } -/// Spawns an unconfined same-user sibling helper that sleeps. +/// Spawns a same-user sibling that lives until killed or its parent pipe closes. fn spawn_sibling() -> std::process::Child { let mut command = StdCommand::new(test_binary()); command - .args(helper_arguments(&["30000"])) + .args(helper_arguments(&[])) .env_clear() + .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::null()); - for (name, value) in helper_environment("sleep") { + for (name, value) in helper_environment("wait-for-eof") { command.env(name, value); } command.spawn().expect("should spawn sibling") diff --git a/crates/trusted-server-release/src/boundary/tests/modes.rs b/crates/trusted-server-release/src/boundary/tests/modes.rs index 6330cb7b64..fb50eba2bb 100644 --- a/crates/trusted-server-release/src/boundary/tests/modes.rs +++ b/crates/trusted-server-release/src/boundary/tests/modes.rs @@ -53,6 +53,10 @@ pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { ), "signal" => signal(argument(0), argument(1)), "signal-own-child" => signal_own_child(), + "wait-for-eof" => match std::io::copy(&mut std::io::stdin(), &mut std::io::sink()) { + Ok(_) => 0, + Err(_) => HELPER_SETUP_FAILED, + }, "sleep" => { std::thread::sleep(Duration::from_millis(argument(0).parse().unwrap_or(0))); 0 diff --git a/crates/trusted-server-release/src/boundary/tests/unit.rs b/crates/trusted-server-release/src/boundary/tests/unit.rs index 323305c3e6..1fa5cfa72b 100644 --- a/crates/trusted-server-release/src/boundary/tests/unit.rs +++ b/crates/trusted-server-release/src/boundary/tests/unit.rs @@ -479,3 +479,33 @@ fn sbpl_profile_denies_group_escape_and_scopes_unix_sockets() { assert!(!text.contains(absent), "should not allow {absent}"); } } + +#[test] +fn sibling_lifetime_is_owned_by_the_parent_pipe() { + let mut sibling = spawn_sibling(); + let controlled = sibling.stdin.is_some(); + if controlled { + drop(sibling.stdin.take()); + } else { + sibling + .kill() + .expect("should stop the old timer-based fixture"); + } + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = sibling.try_wait().expect("should poll sibling") { + break Some(status); + } + if Instant::now() >= deadline { + sibling.kill().expect("should stop a failed fixture"); + sibling.wait().expect("should reap a failed fixture"); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + assert!(controlled, "should retain a parent-owned lifetime pipe"); + assert!( + status.is_some_and(|status| status.success()), + "should exit successfully when the parent closes the pipe" + ); +} From 4eebe62138606636e095aa975b7dd37a4fa899ca Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 17:24:13 +0530 Subject: [PATCH 058/153] Validate pinned consumer verification against real signed fixtures --- .../src/release/evidence.rs | 8 +- .../release/evidence/upstream_format_tests.rs | 66 +++++++ .../fixtures/release/upstream-formats.json | 174 ++++++++++++++++++ ...6-10-13-attested-release-implementation.md | 42 ++--- .../2026-10-15-consumer-real-fixtures.md | 150 +++++++++++++++ 5 files changed, 417 insertions(+), 23 deletions(-) create mode 100644 crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs create mode 100644 crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json create mode 100644 scripts/release/qualification/2026-10-15-consumer-real-fixtures.md diff --git a/crates/trusted-server-cli/src/release/evidence.rs b/crates/trusted-server-cli/src/release/evidence.rs index b56d2bc5a7..a390bbd403 100644 --- a/crates/trusted-server-cli/src/release/evidence.rs +++ b/crates/trusted-server-cli/src/release/evidence.rs @@ -25,11 +25,15 @@ //! - The GitHub workflow provenance predicate layout follows the //! `@actions/attest` builder: //! . -//! It was read at the toolkit's default branch, not at the pinned -//! `actions/attest` 4.2.2 dependency; real pinned fixtures must confirm it. +//! Real attest 4.2.2 provenance/SPDX fixtures were verified with gh 2.97.0; +//! the measured consumer qualification report records their identities, +//! digests and replay commands. Official project first-use remains separate. mod joins; +#[cfg(test)] +mod upstream_format_tests; + use std::collections::{BTreeMap, BTreeSet}; use std::sync::LazyLock; diff --git a/crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs b/crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs new file mode 100644 index 0000000000..aeec296a94 --- /dev/null +++ b/crates/trusted-server-cli/src/release/evidence/upstream_format_tests.rs @@ -0,0 +1,66 @@ +//! Fictionalized format observations from separately verified upstream proofs. +//! +//! These JSON values contain no signatures and supply no verification authority. +//! Actual cryptographic qualification is recorded separately under +//! `scripts/release/qualification/`; these tests protect parser compatibility. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use super::{Expected, check_certificate, check_subjects, statement}; +use crate::release::documents::{ApprovedCommit, ApprovedRelease, ReleaseTag, Role, parse_json}; +use crate::release::policy::{PROVENANCE_PREDICATE, SBOM_PREDICATE}; + +const FORMATS: &[u8] = include_bytes!("../../../tests/fixtures/release/upstream-formats.json"); + +fn observed(kind: &str) -> Value { + let formats = parse_json(FORMATS, Role::GhOutput) + .expect("should parse fictionalized upstream format observations"); + formats[kind][0].clone() +} + +#[test] +fn pinned_upstream_provenance_and_spdx_shapes_reach_subject_checks() { + let digest = "a".repeat(64); + let expected = BTreeMap::from([("example-asset", digest.as_str())]); + for (kind, predicate) in [ + ("provenance", PROVENANCE_PREDICATE), + ("spdx", SBOM_PREDICATE), + ] { + let proof = observed(kind); + let statement = statement(&proof, predicate) + .expect("should accept the observed pinned verification result shape"); + check_subjects(statement, &expected) + .expect("should bind the observed subject shape to exact bytes"); + let changed = BTreeMap::from([("another-asset", digest.as_str())]); + assert!( + check_subjects(statement, &changed).is_err(), + "should reject a subject name outside the expected set" + ); + } +} + +#[test] +fn upstream_format_never_confers_official_repository_identity() { + let release = ApprovedRelease::new( + ReleaseTag::parse("v1.2.0").expect("should parse the fictional tag"), + ApprovedCommit::parse("0123456789abcdef0123456789abcdef01234567") + .expect("should parse the fictional commit"), + ); + let expected = Expected::new(&release); + for kind in ["provenance", "spdx"] { + assert!( + check_certificate(&observed(kind), &expected).is_err(), + "should reject an unrelated signer despite compatible JSON" + ); + } +} + +#[test] +fn observed_spdx_predicate_cannot_substitute_for_provenance() { + assert!( + statement(&observed("spdx"), PROVENANCE_PREDICATE).is_err(), + "should keep the observed predicate roles distinct" + ); +} diff --git a/crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json b/crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json new file mode 100644 index 0000000000..456900b6df --- /dev/null +++ b/crates/trusted-server-cli/tests/fixtures/release/upstream-formats.json @@ -0,0 +1,174 @@ +{ + "provenance": [ + { + "verificationResult": { + "mediaType": "application/vnd.dev.sigstore.verificationresult+json;version=0.1", + "signature": { + "certificate": { + "certificateIssuer": "CN=example-intermediate,O=example.com", + "subjectAlternativeName": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0", + "issuer": "https://example.com/issuer", + "githubWorkflowTrigger": "push", + "githubWorkflowSHA": "0123456789abcdef0123456789abcdef01234567", + "githubWorkflowName": "Release archives", + "githubWorkflowRepository": "example/project", + "githubWorkflowRef": "refs/tags/v1.2.0", + "buildSignerURI": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0", + "buildSignerDigest": "0123456789abcdef0123456789abcdef01234567", + "runnerEnvironment": "github-hosted", + "sourceRepositoryURI": "https://example.com/example/project", + "sourceRepositoryDigest": "0123456789abcdef0123456789abcdef01234567", + "sourceRepositoryRef": "refs/tags/v1.2.0", + "sourceRepositoryIdentifier": "123456", + "sourceRepositoryOwnerURI": "https://example.com/example", + "sourceRepositoryOwnerIdentifier": "654321", + "buildConfigURI": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0", + "buildConfigDigest": "0123456789abcdef0123456789abcdef01234567", + "buildTrigger": "push", + "runInvocationURI": "https://example.com/example/project/actions/runs/1234567890/attempts/1", + "sourceRepositoryVisibilityAtSigning": "public" + } + }, + "verifiedTimestamps": [ + { + "type": "Tlog", + "uri": "https://example.com/transparency-log", + "timestamp": "2026-01-01T00:00:00Z" + } + ], + "verifiedIdentity": { + "subjectAlternativeName": { + "subjectAlternativeName": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0" + }, + "issuer": { + "issuer": "", + "regexp": ".*" + }, + "runnerEnvironment": "github-hosted" + }, + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "example-asset", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://actions.github.io/buildtypes/workflow/v1", + "externalParameters": { + "workflow": { + "path": ".github/workflows/release-archives.yml", + "ref": "refs/tags/v1.2.0", + "repository": "https://example.com/example/project" + } + }, + "internalParameters": { + "github": { + "event_name": "push", + "repository_id": "123456", + "repository_owner_id": "654321", + "runner_environment": "github-hosted" + } + }, + "resolvedDependencies": [ + { + "digest": { + "gitCommit": "0123456789abcdef0123456789abcdef01234567" + }, + "uri": "git+https://example.com/example/project@refs/tags/v1.2.0" + } + ] + }, + "runDetails": { + "builder": { + "id": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0" + }, + "metadata": { + "invocationId": "https://example.com/example/project/actions/runs/1234567890/attempts/1" + } + } + } + } + } + } + ], + "spdx": [ + { + "verificationResult": { + "mediaType": "application/vnd.dev.sigstore.verificationresult+json;version=0.1", + "signature": { + "certificate": { + "certificateIssuer": "CN=example-intermediate,O=example.com", + "subjectAlternativeName": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0", + "issuer": "https://example.com/issuer", + "githubWorkflowTrigger": "push", + "githubWorkflowSHA": "0123456789abcdef0123456789abcdef01234567", + "githubWorkflowName": "Release archives", + "githubWorkflowRepository": "example/project", + "githubWorkflowRef": "refs/tags/v1.2.0", + "buildSignerURI": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0", + "buildSignerDigest": "0123456789abcdef0123456789abcdef01234567", + "runnerEnvironment": "github-hosted", + "sourceRepositoryURI": "https://example.com/example/project", + "sourceRepositoryDigest": "0123456789abcdef0123456789abcdef01234567", + "sourceRepositoryRef": "refs/tags/v1.2.0", + "sourceRepositoryIdentifier": "123456", + "sourceRepositoryOwnerURI": "https://example.com/example", + "sourceRepositoryOwnerIdentifier": "654321", + "buildConfigURI": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0", + "buildConfigDigest": "0123456789abcdef0123456789abcdef01234567", + "buildTrigger": "push", + "runInvocationURI": "https://example.com/example/project/actions/runs/1234567890/attempts/1", + "sourceRepositoryVisibilityAtSigning": "public" + } + }, + "verifiedTimestamps": [ + { + "type": "Tlog", + "uri": "https://example.com/transparency-log", + "timestamp": "2026-01-01T00:00:00Z" + } + ], + "verifiedIdentity": { + "subjectAlternativeName": { + "subjectAlternativeName": "https://example.com/example/project/.github/workflows/release-archives.yml@refs/tags/v1.2.0" + }, + "issuer": { + "issuer": "", + "regexp": ".*" + }, + "runnerEnvironment": "github-hosted" + }, + "statement": { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "example-asset", + "digest": { + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ], + "predicateType": "https://spdx.dev/Document/v2.3", + "predicate": { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "example-document", + "documentNamespace": "https://example.com/spdx/example", + "creationInfo": { + "created": "2026-01-01T00:00:00Z", + "creators": ["Tool: example-generator"] + }, + "packages": [] + } + } + } + } + ] +} diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 58b153959a..25bd19158e 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -52,16 +52,16 @@ Current implementation includes strict documents/policy, local source/preflight ## Current task status — 2026-10-15 Count complete task exits separately from implemented code awaiting external -qualification. There are **6 complete tasks and 16 open tasks**. Two open tasks -have their implementation finished; eight tasks therefore have completed code. +qualification. There are **7 complete tasks and 15 open tasks**. One open task +has its implementation finished; eight tasks therefore have completed code. Task counts do not measure remaining effort. -| Status | Count | Tasks | Remaining exit work | -| ---------------------------------------------- | ----- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Complete | 6 | 2, 4, 5, 6, 17, 20 | Implementation, local verification and independent review completed | -| Implementation complete; qualification pending | 2 | 18, 19 | Pinned real signed fixture and independent first-use verification | -| Partial or prerequisite approval pending | 5 | 1, 3, 7, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; Git materials, actual JS lifecycle and A/B runs; platform qualification; full Rust recipes/runtime acceptance | -| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | +| Status | Count | Tasks | Remaining exit work | +| ---------------------------------------------- | ----- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Complete | 7 | 2, 4, 5, 6, 17, 18, 20 | Implementation, local verification, real pinned-verifier public-fixture validation where required, and independent review completed | +| Implementation complete; qualification pending | 1 | 19 | Official Trusted Server signed-release first-use verification; upstream provenance/SPDX format compatibility has been measured | +| Partial or prerequisite approval pending | 5 | 1, 3, 7, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; Git materials, actual JS lifecycle and A/B runs; platform qualification; full Rust recipes/runtime acceptance | +| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | Task 7's archive and declaration checks are prerequisites. They do not construct `JsBuildCompleted`, prove hook execution or complete that task. Task 9's @@ -414,13 +414,13 @@ Implementation sequencing: review the local validation framework and candidate b **Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. -- [ ] Register the new gh production modules and `mod gh` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. +- [x] Register the new gh production modules and `mod gh` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. -- [ ] Write fake-child tests for hangs/cancellation, simultaneous stdout/stderr saturation, malformed/truncated JSON, exit failure, multiple attestations, token precedence, host/config overrides and log redaction. -- [ ] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::gh`; expect absent runner. -- [ ] Resolve gh once through trusted PATH/explicit trusted installer configuration, require 2.97.0, and retain that executable target. Pass fixed argument vectors with exact certificate identity; never arbitrary forwarded verifier flags. Accept explicit GH_TOKEN then GITHUB_TOKEN, pass only GH_TOKEN, isolate home/config/state, disable prompts, reject loader injection and keep provider credentials out. -- [ ] Concurrently drain bounded pipes: 120 seconds per child, 15 minutes per attempt, 128 MiB stdout/1 MiB stderr. Kill/reap on either limit/cancellation. Use real OS fake-child fixtures, not mocks that omit pipe pressure. Failed exit never yields trusted parsed claims. -- [ ] Re-run runner negatives and an approved signed public fixture under the pinned gh binary. Commit `Bound and isolate release verification subprocesses`. +- [x] Write fake-child tests for hangs/cancellation, simultaneous stdout/stderr saturation, malformed/truncated JSON, exit failure, multiple attestations, token precedence, host/config overrides and log redaction. +- [x] Run `cargo test -p trusted-server-cli --target "$HOST_TARGET" release::tests::gh`; expect absent runner. +- [x] Resolve gh once through trusted PATH/explicit trusted installer configuration, require 2.97.0, and retain that executable target. Pass fixed argument vectors with exact certificate identity; never arbitrary forwarded verifier flags. Accept explicit GH_TOKEN then GITHUB_TOKEN, pass only GH_TOKEN, isolate home/config/state, disable prompts, reject loader injection and keep provider credentials out. +- [x] Concurrently drain bounded pipes: 120 seconds per child, 15 minutes per attempt, 128 MiB stdout/1 MiB stderr. Kill/reap on either limit/cancellation. Use real OS fake-child fixtures, not mocks that omit pipe pressure. Failed exit never yields trusted parsed claims. +- [x] Re-run runner negatives and an approved signed public fixture under the pinned gh binary. Actual attest 4.2.2 provenance and SPDX signatures, isolated token-free replay, offline SPDX replay and signed release output were measured; see [consumer qualification report](../../../scripts/release/qualification/2026-10-15-consumer-real-fixtures.md). Commit `Bound and isolate release verification subprocesses`. ### Task 19: Verify signatures, evidence joins and official publication @@ -428,14 +428,14 @@ Implementation sequencing: review the local validation framework and candidate b **Modify:** CLI `src/release/mod.rs`, `src/release/tests/mod.rs`. -- [ ] Register the new evidence/github/artifact production modules and `mod verification` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. +- [x] Register the new evidence/github/artifact production modules and `mod verification` test module before the first focused run. Apply the nonzero-selection guard; missing module registration or zero matches MUST never report a passing security check. -- [ ] Add a valid graph and independently altered edges for index, all provenance subjects, SPDX subject/document/root, JS prepared/retained records, policy/source/run/caller identities, signed names, REST lengths/immutability and tag object type. -- [ ] Run verification tests; expect no implemented evidence graph. -- [ ] Verify index provenance before trusting fields. Obtain only approved official GitHub host/repository data with explicit read-only credential and bounded responses. Validate lightweight source target, source approval, independently acquired fixed-path producer policy bytes at that commit, exact signed release names/digests and actual/index/REST sizes. Primitive gh success is insufficient. -- [ ] Check verified certificate numeric IDs, exact SAN/issuer/build config/digest/trigger/run URI in both proofs, and provenance build type/caller/external parameters/resolved source/invocation. SPDX lacks SLSA caller fields; use its certificate plus subject/document constraints. -- [ ] Separate BuildEvidenceVerified, PublicationVerified and consumer ActionAuthorized states; constructors remain private. A retained verified-release-asset owns private bytes/handle and source/policy/role evidence. Read-only CLI verification returns a VerificationReport, not deployment authorization. The linked integration plan adds the private action-scoped VerifiedArtifact only after concrete context approval; an asserted path/hash cannot construct it. Stream bounded asset responses into retained files, enforcing each role limit while downloading; do not buffer a 512 MiB executable through the gh JSON pipe or check limits only after writing it. Rehash before later use and reject offline official-deploy construction. -- [ ] Exercise first-use verification using an independently trusted previous verifier or source-built verifier from independently approved source, never the downloaded executable verifying itself. Commit `Verify complete official release evidence before authorization`. +- [x] Add a valid graph and independently altered edges for index, all provenance subjects, SPDX subject/document/root, JS prepared/retained records, policy/source/run/caller identities, signed names, REST lengths/immutability and tag object type. +- [x] Run verification tests; expect no implemented evidence graph. +- [x] Verify index provenance before trusting fields. Obtain only approved official GitHub host/repository data with explicit read-only credential and bounded responses. Validate lightweight source target, source approval, independently acquired fixed-path producer policy bytes at that commit, exact signed release names/digests and actual/index/REST sizes. Primitive gh success is insufficient. +- [x] Check verified certificate numeric IDs, exact SAN/issuer/build config/digest/trigger/run URI in both proofs, and provenance build type/caller/external parameters/resolved source/invocation. SPDX lacks SLSA caller fields; use its certificate plus subject/document constraints. +- [x] Separate BuildEvidenceVerified, PublicationVerified and consumer ActionAuthorized states; constructors remain private. A retained verified-release-asset owns private bytes/handle and source/policy/role evidence. Read-only CLI verification returns a VerificationReport, not deployment authorization. The linked integration plan adds the private action-scoped VerifiedArtifact only after concrete context approval; an asserted path/hash cannot construct it. Stream bounded asset responses into retained files, enforcing each role limit while downloading; do not buffer a 512 MiB executable through the gh JSON pipe or check limits only after writing it. Rehash before later use and reject offline official-deploy construction. +- [ ] Official project first-use remains pending: upstream attest 4.2.2 formats and both real predicate signatures are measured in the report above, but third-party fixtures cannot satisfy the official repository/index policy. Exercise first-use verification using an independently trusted previous verifier or source-built verifier from independently approved source, never the downloaded executable verifying itself. Commit `Verify complete official release evidence before authorization`. ### Task 20: Expose read-only CLI verification and bootstrap instructions diff --git a/scripts/release/qualification/2026-10-15-consumer-real-fixtures.md b/scripts/release/qualification/2026-10-15-consumer-real-fixtures.md new file mode 100644 index 0000000000..31d7d0a09a --- /dev/null +++ b/scripts/release/qualification/2026-10-15-consumer-real-fixtures.md @@ -0,0 +1,150 @@ +# Consumer verifier: measured public-fixture qualification + +Measured on 2026-10-15 on native Apple Silicon macOS. This report records +actual public upstream evidence, rather than fictional examples. No release, +workflow, repository or deployment was created. Downloaded subject archives +were never executed. The retained evidence is outside the repository at +`/private/tmp/ts-real-fixture-qualification` and is temporary local evidence; +the digests below detect changes but do not authenticate a future download. + +## Trust and bootstrap + +The independently installed `/opt/homebrew/bin/gh` reported version 2.87.2. +That existing verifier, rather than the downloaded executable, successfully +verified the public attestation on `cli/cli`'s +`gh_2.97.0_macOS_arm64.zip` before its executable was run. The verified +certificate bound the archive to source/signer commit +`55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55`, workflow +`.github/workflows/deployment.yml`, `refs/heads/trunk`, and run +`30597407850/attempts/1`. The archive SHA-256 was +`a58b8fd77b417a38f47a0b54d1370c59b0fcdb324ccc9ca002b0998f7c4c999e`. +The extracted executable reported exactly 2.97.0. Version output alone was not +used as authentication. This relies on the pre-existing verifier installation +as a local trust prerequisite; this work did not independently rebuild or +qualify that installation's own historical bootstrap. + +Primary inputs: + +- [Pinned verifier release](https://github.com/cli/cli/releases/tag/v2.97.0). +- [Bootstrap signing workflow at its verified commit](https://github.com/cli/cli/blob/55dbb4dc6b7edb10b48e3d7fc5bccd32318d1b55/.github/workflows/deployment.yml). +- [Measured fixture release](https://github.com/d2lang/d2/releases/tag/v0.9.0). +- [Fixture signing workflow at its verified commit](https://github.com/d2lang/d2/blob/d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda/.github/workflows/release-archives.yml). +- [Exact attest 4.2.2 action](https://github.com/actions/attest/tree/1e69f48acb82d1966a394da916b4c1698aa569d6). + +The bootstrap release used attest 4.2.0. It was used to authenticate the +verifier, not to stand in for the required 4.2.2 signing fixture. The D2 +workflow at the verified commit explicitly pins attest 4.2.2 for both +provenance and SPDX signing. + +## Successful measurements + +The authenticated gh 2.97.0 verified both provenance and SPDX signatures for +`d2-v0.9.0-linux-arm64.tar.gz`. Its real certificates reported: + +| Field | Observed value | +| ------------------------ | -------------------------------------------------------------------------------------- | +| Issuer | `https://token.actions.githubusercontent.com` | +| SAN and build signer | `https://github.com/d2lang/d2/.github/workflows/release-archives.yml@refs/tags/v0.9.0` | +| Source and signer digest | `d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda` | +| Source repository ID | `533087958` | +| Source owner ID | `242104691` | +| Trigger / runner | `push` / `github-hosted` | +| Invocation | `https://github.com/d2lang/d2/actions/runs/34165396038/attempts/1` | + +The actual output was an array of one processing result for each +`attestation verify` invocation. The real verification media type, +flattened certificate field names, SLSA v1 provenance fields, and SPDX +`https://spdx.dev/Document/v2.3` predicate matched the implemented consumer +contract. The separately downloaded `d2.spdx.json` compared equal as parsed +JSON to the signed SPDX predicate. + +Both bundles were replayed with the production-equivalent explicit issuer, +SAN, source ref, source digest, signer digest and hosted-runner restriction. +The process environment was cleared, with only private HOME/config and +noninteractive settings supplied; no token was present. SPDX verification +also succeeded under a macOS `sandbox-exec` profile denying `network*`, +using a local bundle and independently transferred trusted roots. The same +profile caused a separate curl DNS/network attempt to fail. This is an +actual consumer offline primitive check, not qualification of producer +process cleanup or the broader macOS build boundary. + +Pinned `gh release verify --format json` also successfully verified the D2 +and CLI public release manifests. Actual output was one processing-result +object rather than the attestation command's array; the release predicate +was `https://in-toto.io/attestation/release/v0.2`. + +## Retained evidence digests + +All digests are SHA-256 of exact retained bytes. + +| File | Digest | +| ----------------------------------- | ------------------------------------------------------------------ | +| `bootstrap-proof.json` | `b53f05f983872a6f5f2d9e95d54bf371f86b012a36fc324b80be5eebbf57fc66` | +| `d2-v0.9.0-linux-arm64.tar.gz` | `ac2c028697199479acb321db1e3d68caee9f2ba492ed73caa3cd13f3829bf913` | +| `d2.spdx.json` | `efaf407d3142ee52de4f3e90864f3023110dd9f430f8220143d6f3d9de86e689` | +| `d2.provenance.sigstore.json` | `d42456c264743abc09194a548e18019e4073eef4040abd182fd1c974616a9259` | +| `d2.spdx.sigstore.json` | `26cab9234beaa45c49510924ae7c8aa4cb058a3419aff673eb6c51c33bce3ad3` | +| `d2-isolated-provenance-proof.json` | `59045bc27b9d7bbf1f6747ca7939aa8ef2c77330e86d2229d7cee9daf3c7e2ca` | +| `d2-isolated-spdx-proof.json` | `bf43a603bb1a4f83cdcfeee68ae0b69b3d23e8fb1f46a20d083dafafc4220c8a` | +| `d2-offline-spdx-proof.json` | `bf43a603bb1a4f83cdcfeee68ae0b69b3d23e8fb1f46a20d083dafafc4220c8a` | +| `d2-release-proof.json` | `a1269c4c7da96bd0e5129b1d95030cf084d91fed2b8a085ad8fdcb633b979deb` | +| `gh-release-proof.json` | `dd934980d284943275aa2d9c574fdbcc5e3713e38a616ce49ca0ac682040f07c` | +| `trusted-root.jsonl` | `65ca537f6ed8a47fd0e560c421baa1f6c1efb8b25fc200d8c5c02c0e92eb2b9c` | + +## Replay commands + +First authenticate the pinned archive with the independently installed +verifier, then extract it. Fetch the D2 archive and standalone SPDX document +from the release linked above. Use the retained signed bundles, or obtain +and verify fresh ones from the fixed public repository; a hash here cannot +replace cryptographic verification. + +```sh +set -eu +EVIDENCE=/private/tmp/ts-real-fixture-qualification +PINNED_GH="$EVIDENCE/extracted/gh_2.97.0_macOS_arm64/bin/gh" +/opt/homebrew/bin/gh attestation verify \ + "$EVIDENCE/gh_2.97.0_macOS_arm64.zip" -R cli/cli --format json +/opt/homebrew/bin/gh attestation trusted-root --hostname github.com \ + > "$EVIDENCE/trusted-root.jsonl" + +# Run once per predicate, choosing the corresponding retained bundle. +PREDICATE=https://spdx.dev/Document/v2.3 +BUNDLE="$EVIDENCE/d2.spdx.sigstore.json" +env -i HOME="$EVIDENCE/isolated-home" \ + GH_CONFIG_DIR="$EVIDENCE/isolated-home" GH_PROMPT_DISABLED=1 \ + /usr/bin/sandbox-exec -p '(version 1)(allow default)(deny network*)' \ + "$PINNED_GH" attestation verify \ + "$EVIDENCE/d2-v0.9.0-linux-arm64.tar.gz" --bundle "$BUNDLE" \ + --custom-trusted-root "$EVIDENCE/trusted-root.jsonl" \ + -R d2lang/d2 --hostname github.com \ + --cert-identity https://github.com/d2lang/d2/.github/workflows/release-archives.yml@refs/tags/v0.9.0 \ + --cert-oidc-issuer https://token.actions.githubusercontent.com \ + --source-ref refs/tags/v0.9.0 \ + --source-digest d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda \ + --signer-digest d5a51743b6d5c00f1ec6f8340003f5d5a6ba4eda \ + --deny-self-hosted-runners --predicate-type "$PREDICATE" --format json +``` + +For provenance, use `https://slsa.dev/provenance/v1` and +`d2.provenance.sigstore.json`. Preserve the exact expected source identities; +never derive acceptance policy from an unverified proof's fields. Runtime +proofs and bundles remain outside the repository; checked-in compatibility +fixtures use fictional identities and contain no signatures or trusted proof +constructors. + +## Scope and remaining first-use work + +Task 18's real pinned-verifier/signed-public-fixture primitive requirement is +now measured. Its adversarial bounded-process tests remain the separate +source-built CLI verification evidence. + +Task 19's upstream format uncertainty is resolved for both predicate kinds +and release-manifest output. The public fixture has a different repository, +workflow and product graph and cannot satisfy Trusted Server policy. No +third-party result constructs official project authorization. Full official +first-use verification remains pending until the producer emits the canonical +signed index and all required companion assets, from independently approved +source, and an independently trusted/source-built verifier checks that actual +release. This report does not enable releases or qualify authenticated +producer bootstrap, hosted build recipes, or signing workflow execution. From 2edd46ce63b5684a5a0fce976a60bb9794e1c6cf Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 18:08:13 +0530 Subject: [PATCH 059/153] Build and compare two fresh offline JavaScript installations --- .github/workflows/test.yml | 25 +- Cargo.lock | 1 + .../lib/test/observe-npm.test.mjs | 426 +++++++++ .../lib/test/release-composition.test.mjs | 19 +- crates/trusted-server-release/Cargo.toml | 1 + crates/trusted-server-release/src/boundary.rs | 33 + .../src/boundary/command.rs | 24 +- .../src/boundary/probe.rs | 17 + .../src/boundary/seccomp.rs | 17 +- .../src/boundary/tests/container.rs | 29 +- .../src/boundary/tests/linux.rs | 16 + .../src/boundary/tests/modes.rs | 27 + .../src/boundary/tests/unit.rs | 21 + .../trusted-server-release/src/documents.rs | 146 ++- .../src/documents/tests.rs | 95 ++ .../trusted-server-release/src/git_objects.rs | 1 - .../trusted-server-release/src/javascript.rs | 663 ++++++++++++++ .../src/javascript/tests.rs | 209 +++++ .../src/lifecycle_guard.rs | 86 ++ crates/trusted-server-release/src/main.rs | 4 + .../trusted-server-release/src/materials.rs | 18 + .../src/materials/extract.rs | 109 ++- .../src/materials/extract/tests.rs | 112 +++ .../src/materials/git.rs | 508 +++++++++++ .../src/materials/git/input.rs | 260 ++++++ .../src/materials/git/tests.rs | 845 ++++++++++++++++++ .../src/materials/manifest.rs | 31 + .../src/materials/npm_bundle.rs | 246 +++++ .../trusted-server-release/src/preparation.rs | 755 ++++++++++++++++ .../src/preparation/tests.rs | 116 +++ .../src/preparation/tests/full.rs | 148 +++ crates/trusted-server-release/src/source.rs | 3 + .../src/source/closure.rs | 11 +- .../src/source/materialize.rs | 212 +++++ .../src/source/tests.rs | 202 +++++ .../src/source/tests/closure.rs | 31 + .../src/source/tests/objects.rs | 4 + .../src/source/worktree.rs | 105 ++- .../trusted-server-release/tests/commands.rs | 12 + scripts/release/collect-js.mjs | 41 +- scripts/release/js-composition.md | 53 +- scripts/release/observe-npm.mjs | 690 ++++++++++++++ scripts/release/tests/js-fixture.Dockerfile | 5 + scripts/release/tests/prepare-js-fixture.mjs | 224 +++++ 44 files changed, 6536 insertions(+), 65 deletions(-) create mode 100644 crates/trusted-server-js/lib/test/observe-npm.test.mjs create mode 100644 crates/trusted-server-release/src/javascript.rs create mode 100644 crates/trusted-server-release/src/javascript/tests.rs create mode 100644 crates/trusted-server-release/src/lifecycle_guard.rs create mode 100644 crates/trusted-server-release/src/materials/git.rs create mode 100644 crates/trusted-server-release/src/materials/git/input.rs create mode 100644 crates/trusted-server-release/src/materials/git/tests.rs create mode 100644 crates/trusted-server-release/src/materials/npm_bundle.rs create mode 100644 crates/trusted-server-release/src/preparation.rs create mode 100644 crates/trusted-server-release/src/preparation/tests.rs create mode 100644 crates/trusted-server-release/src/preparation/tests/full.rs create mode 100644 crates/trusted-server-release/src/source/materialize.rs create mode 100644 scripts/release/observe-npm.mjs create mode 100644 scripts/release/tests/js-fixture.Dockerfile create mode 100644 scripts/release/tests/prepare-js-fixture.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20ed4235ed..a5a817047f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -300,11 +300,12 @@ jobs: matrix: os: [ubuntu-24.04, macos-15] runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24) with: persist-credentials: false + fetch-depth: 0 - name: Read pinned tool versions id: tools @@ -350,6 +351,28 @@ jobs: cargo clippy -p trusted-server-release --target "$HOST_TARGET" --all-targets --all-features -- -D warnings cargo doc -p trusted-server-release --target "$HOST_TARGET" --no-deps --all-features + - name: Prepare original-only full JS fixture (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + cargo build -p trusted-server-release --target "$HOST_TARGET" --locked + FIXTURE_ROOT="$RUNNER_TEMP/release-js-original-fixture" + node scripts/release/tests/prepare-js-fixture.mjs "$FIXTURE_ROOT" "target/$HOST_TARGET/debug/trusted-server-release" + echo "TS_RELEASE_JS_FIXTURE_ROOT=$FIXTURE_ROOT" >> "$GITHUB_ENV" + + - name: Prove two fresh offline JS builds (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + TEST_NAME=preparation::tests::full::real_two_fresh_js_builds + TEST_LIST="$(mktemp)" + trap 'rm -f "$TEST_LIST"' EXIT + cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one full JS execution fixture"; exit 1 } }' "$TEST_LIST" + cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture + - name: Guard and run native JS prebuilt tests shell: bash run: | diff --git a/Cargo.lock b/Cargo.lock index e7b8a16aa2..855765cb4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6956,6 +6956,7 @@ dependencies = [ "error-stack", "flate2", "hex", + "hmac", "jsonschema", "libc", "object", diff --git a/crates/trusted-server-js/lib/test/observe-npm.test.mjs b/crates/trusted-server-js/lib/test/observe-npm.test.mjs new file mode 100644 index 0000000000..f81572096d --- /dev/null +++ b/crates/trusted-server-js/lib/test/observe-npm.test.mjs @@ -0,0 +1,426 @@ +// @vitest-environment node +import crypto from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + normalizeEsbuildHardlink, + parseRequest, + sealObservation, + snapshotTree, +} from '../../../../scripts/release/observe-npm.mjs'; + +const observer = pathToFileURL( + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../scripts/release/observe-npm.mjs' + ) +).href; +const npmRoot = path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm'); +const temporary = []; +function fixture(name = 'esbuild') { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ts-observer-'))); + temporary.push(root); + const install = path.join(root, 'installation'); + const owner = path.join(install, 'node_modules', name); + fs.mkdirSync(owner, { recursive: true }); + const version = name === 'esbuild' ? '0.27.3' : '3.48.0'; + const command = + name === 'esbuild' ? 'node install.js' : 'node -e "try{require(\'./postinstall\')}catch(e){}"'; + fs.writeFileSync( + path.join(install, 'package.json'), + JSON.stringify({ name: 'example-root', version: '1.0.0' }) + ); + fs.writeFileSync( + path.join(install, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { '': {}, [`node_modules/${name}`]: { version } }, + }) + ); + const pkg = { name, version, scripts: { postinstall: command } }; + fs.writeFileSync(path.join(owner, 'package.json'), JSON.stringify(pkg)); + return { root, install, owner, pkg }; +} +function executeHook(value, body, { second = false, shadow = false, native } = {}) { + fs.writeFileSync( + path.join(value.owner, value.pkg.name === 'esbuild' ? 'install.js' : 'postinstall.js'), + body + ); + if (shadow) { + const bin = path.join(value.owner, 'node_modules', '.bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'node'), '#!/bin/sh\nexit 91\n', { mode: 0o755 }); + } + const script = ` + import { createRequire } from 'node:module'; + import { installLifecycleObserver } from ${JSON.stringify(observer)}; + const require = createRequire(import.meta.url); + const [npmRoot, installRoot, owner, second, native] = process.argv.slice(1); + const state = installLifecycleObserver({ npmRoot, installRoot, nodePath: process.execPath, nativeEsbuild: JSON.parse(native) }); + const run = require(npmRoot + '/node_modules/@npmcli/run-script/lib/run-script.js'); + const pkg = require(owner + '/package.json'); + let caught = false; + for (let index = 0; index < (second === 'true' ? 2 : 1); index++) { + try { await run({ event: 'postinstall', path: owner, pkg, stdio: 'inherit' }); } catch { caught = true; } + } + console.log('OBSERVER_TEST_RESULT ' + JSON.stringify({ caught, failed: state.failed(), events: state.events, normalizations: state.normalizations })); + `; + const child = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + script, + npmRoot, + value.install, + value.owner, + String(second), + JSON.stringify(native ?? null), + ], + { + env: { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + HOME: value.root, + TMPDIR: value.root, + }, + encoding: 'utf8', + input: 'private parent input', + timeout: 15000, + maxBuffer: 1024 * 1024, + } + ); + expect(child.status, child.stderr).toBe(0); + const result = child.stdout.split('\n').find((line) => line.startsWith('OBSERVER_TEST_RESULT ')); + expect(result, child.stderr).toBeTruthy(); + return JSON.parse(result.slice('OBSERVER_TEST_RESULT '.length)); +} + +function executeFreshCi({ optional, failHook }) { + const value = fixture(); + const original = path.join(value.root, 'original'); + const packaged = path.join(original, 'package'); + fs.mkdirSync(packaged, { recursive: true }); + fs.copyFileSync(path.join(value.owner, 'package.json'), path.join(packaged, 'package.json')); + fs.writeFileSync(path.join(packaged, 'install.js'), failHook ? 'process.exit(7);' : ''); + const archive = path.join(value.root, 'original.tgz'); + execFileSync('tar', ['czf', archive, '-C', original, 'package']); + const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(archive)).digest('base64')}`; + const pkg = { + name: 'example-root', + version: '1.0.0', + [optional ? 'optionalDependencies' : 'dependencies']: { esbuild: '0.27.3' }, + }; + fs.writeFileSync(path.join(value.install, 'package.json'), JSON.stringify(pkg)); + fs.writeFileSync( + path.join(value.install, 'package-lock.json'), + JSON.stringify({ + name: pkg.name, + version: pkg.version, + lockfileVersion: 3, + requires: true, + packages: { + '': pkg, + 'node_modules/esbuild': { + version: '0.27.3', + resolved: 'https://registry.example.com/esbuild/-/esbuild-0.27.3.tgz', + integrity, + hasInstallScript: true, + ...(optional ? { optional: true } : {}), + }, + }, + }) + ); + fs.rmSync(path.join(value.install, 'node_modules'), { recursive: true }); + const cache = path.join(value.root, 'cache'); + fs.mkdirSync(cache); + const env = { + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + HOME: value.root, + TMPDIR: value.root, + }; + const seeded = spawnSync( + process.execPath, + [ + path.join(npmRoot, 'bin/npm-cli.js'), + 'cache', + 'add', + archive, + '--offline', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--userconfig=/dev/null', + `--globalconfig=${path.join(cache, 'release-disabled-global.npmrc')}`, + '--cache', + cache, + ], + { env, encoding: 'utf8', timeout: 15000 } + ); + expect(seeded.status, seeded.stderr).toBe(0); + const script = ` + import { createRequire } from 'node:module'; + import { installLifecycleObserver } from ${JSON.stringify(observer)}; + const require = createRequire(import.meta.url); + const [npmRoot, installRoot, cache] = process.argv.slice(1); + const state = installLifecycleObserver({ npmRoot, installRoot, nodePath: process.execPath }); + process.once('exit', code => console.log('OBSERVER_TEST_RESULT ' + JSON.stringify({ code, failed: state.failed(), events: state.events }))); + process.argv = [process.execPath, npmRoot + '/bin/npm-cli.js', 'ci', '--offline', '--include=dev', '--no-audit', '--no-fund', '--foreground-scripts', '--ignore-scripts=false', '--userconfig=/dev/null', '--globalconfig=' + cache + '/release-disabled-global.npmrc', '--script-shell=/bin/sh', '--cache', cache]; + require(npmRoot + '/bin/npm-cli.js'); + `; + const child = spawnSync( + process.execPath, + ['--input-type=module', '-e', script, npmRoot, value.install, cache], + { cwd: value.install, env, encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 } + ); + expect(child.status, child.stderr).toBe(0); + const result = child.stdout.split('\n').find((line) => line.startsWith('OBSERVER_TEST_RESULT ')); + expect(result, child.stderr).toBeTruthy(); + return JSON.parse(result.slice('OBSERVER_TEST_RESULT '.length)); +} +afterEach(() => { + for (const root of temporary.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('release npm lifecycle observer', () => { + it('rejects requests without a private full-length signing key', () => { + expect(() => parseRequest(Buffer.from('{}'))).toThrow(); + }); + it('binds closed requests to disjoint canonical roots and exact original identities', () => { + const first = fixture(); + const cache = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ts-cache-'))); + temporary.push(cache); + const request = { + schema_version: 1, + key: 'a'.repeat(64), + nonce: 'b'.repeat(64), + source_commit: 'c'.repeat(40), + repo_root: first.root, + cache, + guard_path: fs.realpathSync(process.execPath), + native_esbuild: { + lock_path: 'node_modules/@esbuild/linux-x64', + sha256: 'd'.repeat(64), + size: 4, + mode: 0o755, + }, + }; + expect(parseRequest(Buffer.from(JSON.stringify(request)))).toEqual(request); + for (const change of [ + { key: 'a' }, + { nonce: 'b' }, + { source_commit: 'HEAD' }, + { cache: first.install }, + { command: 'other' }, + ]) { + expect(() => parseRequest(Buffer.from(JSON.stringify({ ...request, ...change })))).toThrow(); + } + expect(() => + parseRequest( + Buffer.from( + JSON.stringify(request).replace( + '"schema_version":1', + '"schema_version":1,"schema_version":1' + ) + ) + ) + ).toThrow(); + }); + it('observes a genuine no-output hook start and successful completion', () => { + const value = fixture('core-js'); + const result = executeHook(value, ''); + expect(result.failed).toBe(false); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'success']); + expect(result.events[1].changes).toEqual([]); + expect(result.events[0].script_sha256).toBe( + crypto.createHash('sha256').update(value.pkg.scripts.postinstall).digest('hex') + ); + }); + it('records actual changed owned files and pins node after package bin shadowing', () => { + const value = fixture(); + const result = executeHook( + value, + "require('node:fs').writeFileSync('observed.txt', process.execPath);", + { shadow: true } + ); + expect(result.failed).toBe(false); + expect(fs.readFileSync(path.join(value.owner, 'observed.txt'), 'utf8')).toBe(process.execPath); + expect(result.events[1].changes).toEqual([ + { + path: 'node_modules/esbuild/observed.txt', + before: null, + after: { + kind: 'file', + executable: false, + size: Buffer.byteLength(process.execPath), + sha256: crypto.createHash('sha256').update(process.execPath).digest('hex'), + }, + }, + ]); + }); + it('retains a failed event even when its caller swallows the optional failure', () => { + const result = executeHook(fixture(), 'process.exit(7);'); + expect(result.caught).toBe(true); + expect(result.failed).toBe(true); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'failure']); + expect(result.events.at(-1).exit_code).toBe(7); + }); + it('runs real fresh offline npm ci including its absent root lifecycle calls', () => { + const result = executeFreshCi({ optional: false, failHook: false }); + expect(result.code).toBe(0); + expect(result.failed).toBe(false); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'success']); + }); + it('detects an optional hook failure swallowed by actual npm ci and Arborist', () => { + const result = executeFreshCi({ optional: true, failHook: true }); + expect(result.code).toBe(0); + expect(result.failed).toBe(true); + expect(result.events.map((event) => event.stage)).toEqual(['start', 'failure']); + expect(result.events.at(-1).exit_code).toBe(7); + }); + it('does not pass the parent input pipe to the lifecycle child', () => { + const value = fixture(); + const result = executeHook( + value, + "require('node:fs').writeFileSync('stdin.txt', require('node:fs').readFileSync(0, 'utf8'));" + ); + expect(result.failed).toBe(false); + expect(fs.readFileSync(path.join(value.owner, 'stdin.txt'), 'utf8')).toBe(''); + }); + it('rejects writes outside the lifecycle owner and duplicate executions', () => { + const outside = executeHook( + fixture(), + "require('node:fs').writeFileSync('../../unexpected.txt', 'changed');" + ); + expect(outside.failed).toBe(true); + expect(outside.events.at(-1).stage).toBe('failure'); + const repeated = executeHook(fixture(), '', { second: true }); + expect(repeated.failed).toBe(true); + expect(repeated.events.map((event) => event.stage)).toEqual(['start', 'success']); + }); + it('rejects an unreviewed script before executing it', () => { + const value = fixture(); + value.pkg.scripts.postinstall = 'node unreviewed.js'; + fs.writeFileSync(path.join(value.owner, 'package.json'), JSON.stringify(value.pkg)); + const result = executeHook( + value, + "require('node:fs').writeFileSync('unwanted.txt', 'changed');" + ); + expect(result.failed).toBe(true); + expect(result.events).toEqual([]); + expect(fs.existsSync(path.join(value.owner, 'unwanted.txt'))).toBe(false); + }); + it('rejects hard links and escaping aliases during owned file measurement', () => { + const value = fixture(); + fs.writeFileSync(path.join(value.root, 'outside'), 'example'); + fs.linkSync(path.join(value.root, 'outside'), path.join(value.install, 'linked')); + expect(() => snapshotTree(value.install)).toThrow(); + fs.unlinkSync(path.join(value.install, 'linked')); + fs.symlinkSync('../outside', path.join(value.install, 'alias')); + expect(() => snapshotTree(value.install)).toThrow(); + }); + it('normalizes only the original-matched esbuild native hard-link pair after its real hook', () => { + const value = fixture(); + const lockPath = 'node_modules/@esbuild/linux-x64'; + const binary = path.join(value.install, lockPath, 'bin/esbuild'); + fs.mkdirSync(path.dirname(binary), { recursive: true }); + const bytes = Buffer.from('example native executable bytes'); + fs.writeFileSync(binary, bytes, { mode: 0o755 }); + const owned = path.join(value.owner, 'bin/esbuild'); + fs.mkdirSync(path.dirname(owned), { recursive: true }); + fs.writeFileSync(owned, 'example wrapper', { mode: 0o755 }); + const lock = JSON.parse(fs.readFileSync(path.join(value.install, 'package-lock.json'))); + lock.packages[lockPath] = { version: '0.27.3' }; + fs.writeFileSync(path.join(value.install, 'package-lock.json'), JSON.stringify(lock)); + const native = { + lock_path: lockPath, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + size: bytes.length, + mode: 0o755, + }; + const result = executeHook( + value, + "const fs = require('node:fs'); fs.unlinkSync('bin/esbuild'); fs.linkSync('../@esbuild/linux-x64/bin/esbuild', 'bin/esbuild');", + { native } + ); + expect(result.failed).toBe(false); + expect(result.normalizations).toEqual([ + { + kind: 'esbuild-hardlink-copy-v1', + path: 'node_modules/esbuild/bin/esbuild', + native_path: `${lockPath}/bin/esbuild`, + sha256: native.sha256, + size: bytes.length, + mode: 0o755, + }, + ]); + expect(fs.lstatSync(binary).nlink).toBe(1); + expect(fs.lstatSync(owned).nlink).toBe(1); + expect(fs.lstatSync(binary).ino).not.toBe(fs.lstatSync(owned).ino); + expect(fs.readFileSync(binary)).toEqual(bytes); + expect(fs.readFileSync(owned)).toEqual(bytes); + expect(result.events.at(-1).changes.map((change) => change.path)).toEqual([ + 'node_modules/esbuild/bin/esbuild', + ]); + expect(() => + normalizeEsbuildHardlink({ + installRoot: value.install, + native: { ...native, sha256: '0'.repeat(64) }, + }) + ).toThrow(); + fs.unlinkSync(owned); + fs.linkSync(binary, owned); + fs.linkSync(binary, path.join(value.owner, 'extra-link')); + expect(() => normalizeEsbuildHardlink({ installRoot: value.install, native })).toThrow(); + }); + it('authenticates exact summary bytes with a key absent from the encoded payload', () => { + const key = crypto.randomBytes(32).toString('hex'); + const value = { schema_version: 1, nonce: 'b'.repeat(64), success: true, events: [] }; + const sealed = sealObservation(value, key); + const [prefix, encoded, signature] = sealed.trim().split(' '); + expect(prefix).toBe('TS_RELEASE_NPM_OBSERVATION_V1'); + const bytes = Buffer.from(encoded, 'base64'); + expect(JSON.parse(bytes)).toEqual(value); + expect(bytes.includes(Buffer.from(key))).toBe(false); + expect(signature).toBe( + crypto.createHmac('sha256', Buffer.from(key, 'hex')).update(bytes).digest('hex') + ); + expect(signature).not.toBe( + crypto + .createHmac('sha256', Buffer.from(key, 'hex')) + .update(Buffer.from(JSON.stringify({ ...value, success: false }))) + .digest('hex') + ); + }); +}); + +describe('parent-derived source context', () => { + it('discovers selected module identities without a Git store in the fresh checkout', async () => { + const { expectedModuleIds } = await import('../../../../scripts/release/collect-js.mjs'); + const value = fixture(); + const contextRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ts-context-'))); + temporary.push(contextRoot); + const context = path.join(contextRoot, 'source.json'); + const source = 'c'.repeat(40); + const record = { schema_version: 1, source_commit: source, module_ids: ['core', 'example'] }; + fs.writeFileSync(context, JSON.stringify(record)); + expect(expectedModuleIds(value.install, source, context)).toEqual(['core', 'example']); + for (const change of [ + { source_commit: 'd'.repeat(40) }, + { module_ids: ['example'] }, + { module_ids: ['example', 'core'] }, + { module_ids: ['core', 'core'] }, + { module_ids: ['core', '../example'] }, + { extra: true }, + ]) { + fs.writeFileSync(context, JSON.stringify({ ...record, ...change })); + expect(() => expectedModuleIds(value.install, source, context)).toThrow(); + } + const writable = path.join(value.install, 'source.json'); + fs.writeFileSync(writable, JSON.stringify(record)); + expect(() => expectedModuleIds(value.install, source, writable)).toThrow(/outside/); + }); +}); diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs index c9acc63013..545847d645 100644 --- a/crates/trusted-server-js/lib/test/release-composition.test.mjs +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -646,17 +646,26 @@ describe('complete inventory producer', () => { }) ); // This is the fixture's source identity, not release approval. - const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: repoRoot, - encoding: 'utf8', - }).trim(); + const sourceContextPath = process.env.TRUSTED_SERVER__RELEASE_JS_SOURCE_CONTEXT; + const sourceCommit = sourceContextPath + ? JSON.parse(fs.readFileSync(sourceContextPath, 'utf8')).source_commit + : execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); vi.stubEnv('TRUSTED_SERVER__RELEASE_BUILD', '1'); vi.stubEnv('TRUSTED_SERVER__GIT_VERSION', sourceCommit); vi.stubEnv('TSJS_SKIP_BUILD', undefined); try { const inventoryPath = path.join(root, 'inventory.json'); const outDir = path.join(root, 'bundles'); - const inventory = await collectJs({ repoRoot, outDir, inventoryPath, preparationPath }); + const inventory = await collectJs({ + repoRoot, + outDir, + inventoryPath, + preparationPath, + sourceContextPath, + }); expect(Object.keys(inventory).sort()).toEqual([ 'materials', 'modules', diff --git a/crates/trusted-server-release/Cargo.toml b/crates/trusted-server-release/Cargo.toml index 63c311f70f..8d91352fed 100644 --- a/crates/trusted-server-release/Cargo.toml +++ b/crates/trusted-server-release/Cargo.toml @@ -13,6 +13,7 @@ derive_more.workspace = true error-stack.workspace = true flate2.workspace = true hex.workspace = true +hmac.workspace = true libc.workspace = true jsonschema = { version = "=0.58.6", default-features = false } object = { version = "=0.37.3", default-features = false, features = [ diff --git a/crates/trusted-server-release/src/boundary.rs b/crates/trusted-server-release/src/boundary.rs index e9750eb902..ef162dd09f 100644 --- a/crates/trusted-server-release/src/boundary.rs +++ b/crates/trusted-server-release/src/boundary.rs @@ -135,6 +135,39 @@ fn instance_fingerprint(platform: &[u8; 32]) -> Result<[u8; 32], Report); impl Boundary { + /// Returns the current instance's measured roots, controls and nonce binding. + /// + /// This binds an executed candidate; it does not qualify that candidate. + pub(crate) fn execution_fingerprint(&self) -> [u8; 32] { + self.fingerprint + } + /// Requires exact mount roles and placement for an owning recipe. + pub(crate) fn matches_roots(&self, roots: &BoundaryRoots) -> bool { + self.roots == *roots + } + + /// Reports whether container teardown owns every run descendant. + /// + /// The experimental macOS backend deliberately cannot satisfy this gate. + pub(crate) fn has_owned_descendant_cleanup(&self) -> bool { + matches!(self.platform, Platform::LinuxContainer(_)) + } + + /// Translates a selected host path into the command's execution namespace. + /// + /// # Errors + /// + /// Returns an error when the path is not inside this boundary's roots. + pub(crate) fn execution_path( + &self, + path: &Path, + ) -> Result> { + match &self.platform { + Platform::LinuxContainer(_) => docker::container_path(&self.roots, path), + Platform::MacSandbox(_) if self.roots.locate(path).is_some() => Ok(path.to_owned()), + _ => Err(boundary_error("execution path is outside boundary roots")), + } + } /// Configures the qualified macOS `sandbox-exec` boundary. /// /// # Errors diff --git a/crates/trusted-server-release/src/boundary/command.rs b/crates/trusted-server-release/src/boundary/command.rs index 6c23bf644e..0f16861779 100644 --- a/crates/trusted-server-release/src/boundary/command.rs +++ b/crates/trusted-server-release/src/boundary/command.rs @@ -1,6 +1,7 @@ //! The command description a [`Boundary`](super::Boundary) runs confined. use std::ffi::{OsStr, OsString}; +use std::fmt; use std::os::unix::ffi::OsStrExt as _; use std::path::{Path, PathBuf}; @@ -19,7 +20,6 @@ use crate::process::{BoundedCommand, Cancellation, ProcessLimits}; /// Arguments are passed verbatim. Environment values are validated by the /// same rules as [`BoundedCommand::environment`]; secret values are not /// supported because Docker would carry them in argv. -#[derive(Debug)] pub(crate) struct ConfinedCommand { pub(super) program: PathBuf, pub(super) arguments: Vec, @@ -30,6 +30,28 @@ pub(crate) struct ConfinedCommand { pub(super) cancellation: Option, } +impl fmt::Debug for ConfinedCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConfinedCommand") + .field("program", &self.program) + .field("arguments", &self.arguments) + .field("working_directory", &self.working_directory) + .field( + "environment_names", + &self + .environment + .iter() + .map(|(name, _)| name) + .collect::>(), + ) + .field("stdin_bytes", &self.stdin.as_ref().map(Vec::len)) + .field("limits", &self.limits) + .field("cancellation", &self.cancellation) + .finish() + } +} + fn invalid_path(path: &Path) -> Report { Report::new(ReleaseError::Boundary).attach(format!( "confined path {} must be absolute with only normal components", diff --git a/crates/trusted-server-release/src/boundary/probe.rs b/crates/trusted-server-release/src/boundary/probe.rs index 425ee5d803..a3992c885e 100644 --- a/crates/trusted-server-release/src/boundary/probe.rs +++ b/crates/trusted-server-release/src/boundary/probe.rs @@ -111,6 +111,23 @@ pub(crate) struct ProbeProgram { } impl ProbeProgram { + /// Selects an ordinary CI copy of the source-built producer's fixed probe. + /// + /// # Errors + /// + /// Returns an error when the staged executable cannot be resolved. This + /// test-only selector supplies no production tool authentication. + #[cfg(test)] + pub(crate) fn from_staged_release_binary(path: &Path) -> Result> { + let program = + std::fs::canonicalize(path).map_err(|error| boundary_error(error.to_string()))?; + Ok(Self { + program, + prefix: RELEASE_PROBE_ARGUMENTS.iter().map(OsString::from).collect(), + environment: Vec::new(), + output: ProbeOutput::Exact, + }) + } /// Selects this release binary's documented `boundary-probe connect` /// subcommand. /// diff --git a/crates/trusted-server-release/src/boundary/seccomp.rs b/crates/trusted-server-release/src/boundary/seccomp.rs index c557d8ec14..3dc31f7c25 100644 --- a/crates/trusted-server-release/src/boundary/seccomp.rs +++ b/crates/trusted-server-release/src/boundary/seccomp.rs @@ -4,7 +4,8 @@ //! profile retained in `scripts/release/qualification/`. Its SHA-256 is //! checked before use. The effective profile preserves the baseline's default //! `SCMP_ACT_ERRNO`/`EPERM` action, native architectures and every other -//! syscall rule. It empties every `subArchitectures` list, so non-native ABIs +//! syscall rule except process-memory and descriptor-transfer allowances. +//! It empties every `subArchitectures` list, so non-native ABIs //! (x86 and x32 on `x86_64`, where sockets go through `socketcall`; 32-bit //! ARM on `aarch64`) are not admitted by the filter at all and fail with the //! filter's bad-architecture action. It removes every `socket`, `socketpair` @@ -27,6 +28,15 @@ pub(crate) const BASELINE_SHA256: &str = "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74"; /// Socket-creating syscalls whose baseline allowances are removed. const SOCKET_SYSCALLS: [&str; 3] = ["socket", "socketpair", "socketcall"]; +/// An install hook must not inspect or modify its trusted observer's memory. +const PROCESS_ACCESS_SYSCALLS: [&str; 6] = [ + "ptrace", + "process_vm_readv", + "process_vm_writev", + "pidfd_getfd", + "kcmp", + "process_madvise", +]; /// `AF_UNIX` on every Linux architecture. const AF_UNIX: u64 = 1; @@ -61,8 +71,9 @@ fn strip_socket_names(rule: &mut Value) -> Result> { .and_then(Value::as_array_mut) .ok_or_else(|| seccomp_error("seccomp rule has no names array"))?; names.retain(|name| { - name.as_str() - .is_none_or(|name| !SOCKET_SYSCALLS.contains(&name)) + name.as_str().is_none_or(|name| { + !SOCKET_SYSCALLS.contains(&name) && !PROCESS_ACCESS_SYSCALLS.contains(&name) + }) }); Ok(!names.is_empty()) } diff --git a/crates/trusted-server-release/src/boundary/tests/container.rs b/crates/trusted-server-release/src/boundary/tests/container.rs index e5c0f7cdba..8e96f7fbe4 100644 --- a/crates/trusted-server-release/src/boundary/tests/container.rs +++ b/crates/trusted-server-release/src/boundary/tests/container.rs @@ -61,8 +61,8 @@ fn effective_seccomp_denies_inet_sockets_and_preserves_baseline() { .expect("should have syscalls"); assert_eq!( rules.len(), - original_rules.len() - 3 + 1, - "should drop only the three socket-only rules and add one" + original_rules.len() - 5 + 1, + "should drop three socket-only and two process-memory rules, then add the Unix socket rule" ); let mut allowing = original.clone(); allowing["defaultAction"] = Value::from("SCMP_ACT_ALLOW"); @@ -477,3 +477,28 @@ fn effective_seccomp_admits_only_native_architectures() { ); } } + +#[test] +fn effective_seccomp_has_no_process_memory_or_descriptor_transfer_allowance() { + let effective: Value = serde_json::from_slice( + &seccomp::effective(seccomp::baseline().expect("should authenticate baseline")) + .expect("should derive effective profile"), + ) + .expect("should parse profile"); + for rule in effective["syscalls"].as_array().expect("should list rules") { + for name in rule["names"].as_array().expect("should list syscall names") { + assert!( + ![ + "ptrace", + "process_vm_readv", + "process_vm_writev", + "pidfd_getfd", + "kcmp", + "process_madvise" + ] + .contains(&name.as_str().expect("should have syscall name")), + "should remove process-memory and descriptor access from every allowance" + ); + } + } +} diff --git a/crates/trusted-server-release/src/boundary/tests/linux.rs b/crates/trusted-server-release/src/boundary/tests/linux.rs index 0fc6b1b765..3d29cc3025 100644 --- a/crates/trusted-server-release/src/boundary/tests/linux.rs +++ b/crates/trusted-server-release/src/boundary/tests/linux.rs @@ -180,6 +180,22 @@ impl Fixture { } } +#[test] +fn container_denies_process_memory_access() { + assert_eq!( + super::modes::process_memory_read(), + 0, + "should permit the real self-memory operation outside confinement" + ); + let fixture = Fixture::new(); + let proof = fixture.prove(); + assert_eq!( + fixture.run(&proof, "process-memory-read", &[]), + "denied errno=1", + "should deny the same process-memory syscall with real seccomp EPERM" + ); +} + #[test] fn container_denial_proof_and_confined_execution() { let fixture = Fixture::new(); diff --git a/crates/trusted-server-release/src/boundary/tests/modes.rs b/crates/trusted-server-release/src/boundary/tests/modes.rs index fb50eba2bb..a5036b3b3b 100644 --- a/crates/trusted-server-release/src/boundary/tests/modes.rs +++ b/crates/trusted-server-release/src/boundary/tests/modes.rs @@ -53,6 +53,8 @@ pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { ), "signal" => signal(argument(0), argument(1)), "signal-own-child" => signal_own_child(), + #[cfg(target_os = "linux")] + "process-memory-read" => process_memory_read(), "wait-for-eof" => match std::io::copy(&mut std::io::stdin(), &mut std::io::sink()) { Ok(_) => 0, Err(_) => HELPER_SETUP_FAILED, @@ -78,6 +80,31 @@ pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { } } +/// Attempts a valid self-memory read, which the confined filter must deny. +#[cfg(target_os = "linux")] +pub(super) fn process_memory_read() -> i32 { + let remote = [0x5a_u8]; + let mut local = [0_u8]; + let local = libc::iovec { + iov_base: local.as_mut_ptr().cast(), + iov_len: 1, + }; + let remote = libc::iovec { + iov_base: remote.as_ptr().cast_mut().cast(), + iov_len: 1, + }; + // SAFETY: Both one-byte buffers remain live, and their iovecs describe + // valid memory in this process for the duration of the synchronous call. + let result = unsafe { libc::process_vm_readv(libc::getpid(), &local, 1, &remote, 1, 0) }; + if result == 1 { + report("accessed", 0) + } else if result < 0 { + report_errno("accessed", std::io::Error::last_os_error().raw_os_error()) + } else { + HELPER_SETUP_FAILED + } +} + fn connect(address: &str) -> i32 { let Ok(address) = parse_loopback(address) else { return HELPER_SETUP_FAILED; diff --git a/crates/trusted-server-release/src/boundary/tests/unit.rs b/crates/trusted-server-release/src/boundary/tests/unit.rs index 1fa5cfa72b..e8ba855a88 100644 --- a/crates/trusted-server-release/src/boundary/tests/unit.rs +++ b/crates/trusted-server-release/src/boundary/tests/unit.rs @@ -509,3 +509,24 @@ fn sibling_lifetime_is_owned_by_the_parent_pipe() { "should exit successfully when the parent closes the pipe" ); } + +#[test] +fn confined_debug_does_not_expose_private_stdin() { + let private = b"fictional-private-parent-input"; + let command = ConfinedCommand::new( + Path::new("/example/tool"), + Path::new("/example/work"), + limits(), + ) + .expect("should describe command") + .stdin_bytes(private.to_vec()); + let debug = format!("{command:?}"); + assert!( + !debug.contains("fictional-private-parent-input"), + "should omit private stdin from diagnostics" + ); + assert!( + !debug.contains("102, 105, 99, 116"), + "should not render private stdin as decimal bytes" + ); +} diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs index d1304beec4..96bb37cb99 100644 --- a/crates/trusted-server-release/src/documents.rs +++ b/crates/trusted-server-release/src/documents.rs @@ -30,6 +30,7 @@ const COMPANION_SUFFIXES: [(&str, &str); 6] = [ #[derive(Clone, Copy, Debug)] pub(crate) enum Role { Metadata, + Source, Companion, Bundle, Spdx, @@ -40,6 +41,7 @@ impl Role { fn limit(self) -> u64 { match self { Self::Metadata => 1024 * 1024, + Self::Source => 64 * 1024 * 1024, Self::Companion | Self::Bundle => 32 * 1024 * 1024, Self::Spdx => 16 * 1024 * 1024, Self::Executable => 512 * 1024 * 1024, @@ -71,15 +73,72 @@ pub(crate) struct RawBytes { pub(crate) sha256: String, } +/// Exact bounded file bytes measured without retaining their body in memory. +pub(crate) struct FileDigest { + pub(crate) sha256: String, + pub(crate) size: u64, + pub(crate) mode: u32, +} + /// Reads one unlinked regular file without following a leaf symlink. pub(crate) fn read_bounded(path: &Path, role: Role) -> Result> { - let file = OpenOptions::new() + read_bounded_with_check(path, role, &|| Ok(())) +} + +/// Reads bounded bytes while checking the owning attempt between 64 KiB chunks. +/// +/// # Errors +/// +/// Returns an error for cancellation, expired limits, unsafe files, changed +/// metadata or paths, or bytes exceeding the role bound before allocation. +pub(crate) fn read_bounded_with_check( + path: &Path, + role: Role, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + let file = open_input(path)?; + let before = file.metadata().map_err(invalid)?; + let raw = read_held_with_check(&file, &before, role, check)?; + verify_input_path(path, &before)?; + check()?; + Ok(raw) +} + +/// Hashes a bounded held file with constant-sized buffers and attempt checks. +/// +/// # Errors +/// +/// Returns an error for unsafe files, changed metadata or paths, an exceeded +/// byte limit, cancellation or an expired owning attempt. +pub(crate) fn hash_bounded_with_check( + path: &Path, + limit: u64, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + let file = open_input(path)?; + let before = file.metadata().map_err(invalid)?; + let digest = consume_held(&file, &before, limit, check, &mut |_| Ok(()))?; + verify_input_path(path, &before)?; + check()?; + Ok(digest) +} + +fn open_input(path: &Path) -> Result> { + OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) .open(path) - .map_err(invalid)?; - let before = file.metadata().map_err(invalid)?; - read_held(&file, &before, role) + .map_err(invalid) +} + +fn verify_input_path(path: &Path, before: &std::fs::Metadata) -> Result<(), Report> { + let current = std::fs::symlink_metadata(path).map_err(invalid)?; + if !current.is_file() || stamp(before) != stamp(¤t) { + return Err(invalid("input path changes while reading held descriptor")); + } + Ok(()) } fn read_held( @@ -87,28 +146,82 @@ fn read_held( before: &std::fs::Metadata, role: Role, ) -> Result> { + read_held_with_check(file, before, role, &|| Ok(())) +} + +fn read_held_with_check( + file: &File, + before: &std::fs::Metadata, + role: Role, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + check()?; + validate_held(before, role.limit())?; + let capacity = usize::try_from(before.len()).map_err(invalid)?; + let mut bytes = Vec::new(); + bytes.try_reserve_exact(capacity).map_err(invalid)?; + let digest = consume_held(file, before, role.limit(), check, &mut |chunk| { + bytes.extend_from_slice(chunk); + Ok(()) + })?; + Ok(RawBytes { + bytes, + sha256: digest.sha256, + }) +} + +fn validate_held(before: &std::fs::Metadata, limit: u64) -> Result<(), Report> { if !before.is_file() || before.nlink() != 1 { return Err(invalid("input must be a regular file with one hard link")); } - if before.len() > role.limit() { + if before.len() > limit { return Err(invalid("input exceeds role byte limit")); } - let mut bytes = Vec::new(); - file.take(role.limit() + 1) - .read_to_end(&mut bytes) - .map_err(invalid)?; + Ok(()) +} + +fn consume_held( + file: &File, + before: &std::fs::Metadata, + limit: u64, + check: &dyn Fn() -> Result<(), Report>, + consume: &mut impl FnMut(&[u8]) -> Result<(), Report>, +) -> Result> { + check()?; + validate_held(before, limit)?; + let mut input = file; + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + let mut digest = Sha256::new(); + loop { + check()?; + let count = input.read(&mut buffer).map_err(invalid)?; + check()?; + if count == 0 { + break; + } + size = size + .checked_add(count as u64) + .ok_or_else(|| invalid("input length overflows"))?; + if size > before.len() || size > limit { + return Err(invalid("input grows while reading held descriptor")); + } + digest.update(&buffer[..count]); + consume(&buffer[..count])?; + } let after = file.metadata().map_err(invalid)?; - if stamp(before) != stamp(&after) || bytes.len() as u64 != before.len() { + if stamp(before) != stamp(&after) || size != before.len() { return Err(invalid("input changes while reading held descriptor")); } - if bytes.len() as u64 > role.limit() { - return Err(invalid("input exceeds role byte limit")); - } - let sha256 = hex::encode(Sha256::digest(&bytes)); - Ok(RawBytes { bytes, sha256 }) + check()?; + Ok(FileDigest { + sha256: hex::encode(digest.finalize()), + size, + mode: before.permissions().mode(), + }) } -fn stamp(metadata: &std::fs::Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64) { +fn stamp(metadata: &std::fs::Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64, u32) { ( metadata.dev(), metadata.ino(), @@ -118,6 +231,7 @@ fn stamp(metadata: &std::fs::Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i6 metadata.mtime_nsec(), metadata.ctime(), metadata.ctime_nsec(), + metadata.permissions().mode(), ) } diff --git a/crates/trusted-server-release/src/documents/tests.rs b/crates/trusted-server-release/src/documents/tests.rs index a5666d8a4a..0f782ffe3d 100644 --- a/crates/trusted-server-release/src/documents/tests.rs +++ b/crates/trusted-server-release/src/documents/tests.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::ffi::CString; use std::fs; use std::os::unix::ffi::OsStrExt as _; @@ -9,6 +10,100 @@ use tempfile::tempdir; use super::*; +#[test] +fn controlled_reads_cancel_before_and_between_bounded_chunks() { + let root = tempdir().expect("should create controlled input"); + let path = root.path().join("input"); + fs::write(&path, vec![7_u8; 4 * 64 * 1024]).expect("should write multiple chunks"); + let calls = Cell::new(0); + let check = || { + calls.set(calls.get() + 1); + if calls.get() >= 6 { + return Err(invalid("cancelled fixture")); + } + Ok(()) + }; + assert!( + read_bounded_with_check(&path, Role::Executable, &check).is_err(), + "should cancel during bounded read" + ); + assert_eq!( + calls.get(), + 6, + "should stop between the first and second chunks" + ); + let before = || Err(invalid("already cancelled fixture")); + assert!( + hash_bounded_with_check(&path, 1024 * 1024, &before).is_err(), + "should reject before hashing" + ); +} + +#[test] +fn controlled_stream_hash_enforces_limit_and_exact_digest() { + let root = tempdir().expect("should create hash input"); + let path = root.path().join("input"); + let bytes = vec![9_u8; 3 * 64 * 1024]; + fs::write(&path, &bytes).expect("should write hash input"); + let measured = hash_bounded_with_check(&path, bytes.len() as u64, &|| Ok(())) + .expect("should stream exact bytes"); + assert_eq!( + measured.sha256, + hex::encode(Sha256::digest(&bytes)), + "should hash all original bytes" + ); + assert_eq!( + measured.size, + bytes.len() as u64, + "should retain original size" + ); + assert_eq!( + measured.mode, + fs::metadata(&path) + .expect("should inspect measured mode") + .mode(), + "should retain mode from the same held input" + ); + assert!( + hash_bounded_with_check(&path, bytes.len() as u64 - 1, &|| Ok(())).is_err(), + "should reject oversized files before reading" + ); +} + +#[test] +fn controlled_stream_hash_detects_mutation_and_leaf_replacement() { + let root = tempdir().expect("should create changing input"); + let path = root.path().join("input"); + let bytes = vec![7_u8; 3 * 64 * 1024]; + fs::write(&path, &bytes).expect("should write original input"); + let calls = Cell::new(0); + let result = hash_bounded_with_check(&path, bytes.len() as u64, &|| { + calls.set(calls.get() + 1); + if calls.get() == 4 { + fs::write(&path, b"changed").expect("should mutate held input"); + } + Ok(()) + }); + assert!( + result.is_err(), + "should reject mutations between hash chunks" + ); + fs::write(&path, &bytes).expect("should restore input fixture"); + let calls = Cell::new(0); + let result = hash_bounded_with_check(&path, bytes.len() as u64, &|| { + calls.set(calls.get() + 1); + if calls.get() == 4 { + fs::rename(&path, root.path().join("retained")).expect("should replace leaf name"); + fs::write(&path, &bytes).expect("should write identical replacement bytes"); + } + Ok(()) + }); + assert!( + result.is_err(), + "should reject changed path identity even with matching bytes" + ); +} + fn parse(bytes: &[u8]) -> Result> { parse_json(bytes, Role::Metadata) } diff --git a/crates/trusted-server-release/src/git_objects.rs b/crates/trusted-server-release/src/git_objects.rs index d2aea4cfc3..7002bdb584 100644 --- a/crates/trusted-server-release/src/git_objects.rs +++ b/crates/trusted-server-release/src/git_objects.rs @@ -29,7 +29,6 @@ impl ObjectReader { } /// Creates one Cargo collection policy: 32 MiB per object, 512 MiB total. - #[allow(dead_code, reason = "consumed by Task 7 Cargo acquisition")] pub(crate) fn cargo_materials() -> Self { Self { object_limit: 32 * 1024 * 1024, diff --git a/crates/trusted-server-release/src/javascript.rs b/crates/trusted-server-release/src/javascript.rs new file mode 100644 index 0000000000..ad3330b749 --- /dev/null +++ b/crates/trusted-server-release/src/javascript.rs @@ -0,0 +1,663 @@ +//! Full observed two-installation JS builds and private retained completion. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::boundary::{Boundary, DenialProof}; +use crate::documents::{RawBytes, Role, parse_json, read_bounded_with_check}; +use crate::error::ReleaseError; +use crate::preparation::{JsAttempt, PreparedJsBuild, preparation_error}; +use crate::process::ProcessOutcome; +use crate::source::join_inventory; + +/// Exact retained module facts produced by the actual two-side comparison. +struct Module { + filename: String, + size: u64, + sha256: String, +} + +/// Captured immediately after the collector exits, before later writable stages. +struct CapturedInventory { + raw: RawBytes, + modules: BTreeMap, +} + +fn verify_captured_output( + output: &Path, + captured: &CapturedInventory, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(), Report> { + check()?; + let current = + read_bounded_with_check(&output.join("tsjs-inventory.json"), Role::Metadata, check)?; + if current.bytes != captured.raw.bytes || current.sha256 != captured.raw.sha256 { + return Err(preparation_error( + "original collector inventory changed after measurement", + )); + } + let bundles = output.join("bundles"); + let expected: BTreeSet<_> = captured + .modules + .values() + .map(|module| module.filename.clone()) + .collect(); + if filenames(&bundles, expected.len())? != expected { + return Err(preparation_error("original collector module set changed")); + } + for module in captured.modules.values() { + let current = + read_bounded_with_check(&bundles.join(&module.filename), Role::Bundle, check)?; + if current.sha256 != module.sha256 || current.bytes.len() as u64 != module.size { + return Err(preparation_error("original collector module bytes changed")); + } + } + check() +} + +fn settings_digest(fingerprints: &[[u8; 32]; 2]) -> String { + let mut digest = Sha256::new(); + digest.update(b"source-owned-js-two-install-v2\nnode24.12.0\nnpm11.6.2\nLinux-owned-PID-cleanup\noffline-include-dev\ninstall-umask022\nLANG=C.UTF-8\nLC_ALL=C.UTF-8\nTZ=UTC\nrelease-tests-1\nfull-registered-typecheck\nfull-final-module-bytes\n"); + for fingerprint in fingerprints { + digest.update(fingerprint); + } + hex::encode(digest.finalize()) +} + +fn finalize_execution( + execution: Result>, + cleanup: Result<(), Report>, +) -> Result> { + match (execution, cleanup) { + (Ok(completed), Ok(())) => { + completed.rehash_for_handoff()?; + Ok(completed) + } + (Ok(_), Err(error)) => Err(error), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(cleanup)) => { + Err(error.attach(format!("private JS cleanup also failed: {cleanup:?}"))) + } + } +} + +/// A completed private execution, unavailable from JSON assertions or paths. +/// +/// Only the actual successful preparation/install/build/test/negative/control/ +/// equality path below constructs this value. Production tool selection remains +/// unavailable until authenticated bootstrap and qualification are complete. +pub(crate) struct JsBuildCompleted { + directory: tempfile::TempDir, + source_commit: String, + tools_sha256: String, + settings_sha256: String, + inventory_sha256: String, + observation_sha256: String, + modules: BTreeMap, + tests: [u64; 2], + attempt: JsAttempt, +} + +impl JsBuildCompleted { + /// Returns the approved source bound to the executed build. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + /// Returns the exact selected native tool-input digest. + pub(crate) fn tools_sha256(&self) -> &str { + &self.tools_sha256 + } + /// Returns the fixed recipe and both actual execution-boundary settings digest. + pub(crate) fn settings_sha256(&self) -> &str { + &self.settings_sha256 + } + /// Returns the raw original A inventory digest. + pub(crate) fn inventory_sha256(&self) -> &str { + &self.inventory_sha256 + } + /// Returns the independent A/B successful registered test counts. + pub(crate) fn test_counts(&self) -> [u64; 2] { + self.tests + } + + /// Rejoins the retained A inventory and all exact module bytes before handoff. + /// + /// # Errors + /// + /// Returns an error if an owned retained file changed, vanished or appeared. + pub(crate) fn rehash_for_handoff(&self) -> Result<&Path, Report> { + self.attempt.check()?; + let expected = BTreeSet::from([ + "bundles".to_owned(), + "tsjs-inventory.json".to_owned(), + "npm-observation.json".to_owned(), + ]); + if filenames(self.directory.path(), expected.len())? != expected { + return Err(preparation_error( + "retained JS root contains an unrecorded entry", + )); + } + let inventory = read_bounded_with_check( + &self.directory.path().join("tsjs-inventory.json"), + Role::Metadata, + &|| self.attempt.check(), + )?; + if inventory.sha256 != self.inventory_sha256 { + return Err(preparation_error("retained original JS inventory changed")); + } + let observation = read_bounded_with_check( + &self.directory.path().join("npm-observation.json"), + Role::Metadata, + &|| self.attempt.check(), + )?; + if observation.sha256 != self.observation_sha256 { + return Err(preparation_error("retained actual npm observation changed")); + } + let bundles = self.directory.path().join("bundles"); + let expected: BTreeSet<_> = self + .modules + .values() + .map(|module| module.filename.clone()) + .collect(); + let actual = filenames(&bundles, expected.len())?; + if actual != expected { + return Err(preparation_error("retained JS module set changed")); + } + for module in self.modules.values() { + let raw = + read_bounded_with_check(&bundles.join(&module.filename), Role::Bundle, &|| { + self.attempt.check() + })?; + if raw.sha256 != module.sha256 || raw.bytes.len() as u64 != module.size { + return Err(preparation_error("retained JS module changed")); + } + } + Ok(self.directory.path()) + } +} + +fn filenames(root: &Path, limit: usize) -> Result, Report> { + if !fs::symlink_metadata(root) + .map_err(preparation_error)? + .is_dir() + { + return Err(preparation_error( + "retained JS directory changed type or became an alias", + )); + } + let mut names = BTreeSet::new(); + for entry in fs::read_dir(root).map_err(preparation_error)? { + let name = entry + .map_err(preparation_error)? + .file_name() + .into_string() + .map_err(|_| preparation_error("bundle name is not UTF-8"))?; + if !names.insert(name) || names.len() > limit { + return Err(preparation_error("unexpected retained module entries")); + } + } + Ok(names) +} + +fn inventory_modules( + bytes: &[u8], + expected: &[String], +) -> Result, Report> { + let inventory = parse_json(bytes, Role::Metadata)?; + let array = inventory["modules"] + .as_array() + .ok_or_else(|| preparation_error("JS inventory has no module table"))?; + let mut modules = BTreeMap::new(); + let mut previous = None::; + for entry in array { + let id = entry["id"] + .as_str() + .ok_or_else(|| preparation_error("JS module has no id"))?; + if !expected.iter().any(|expected| expected == id) + || previous.as_deref().is_some_and(|previous| previous >= id) + { + return Err(preparation_error( + "JS module set is untracked, duplicate or unordered", + )); + } + let filename = entry["filename"] + .as_str() + .ok_or_else(|| preparation_error("JS module has no filename"))?; + if filename != format!("tsjs-{id}.js") { + return Err(preparation_error( + "JS module filename differs from tracked identity", + )); + } + let size = entry["size"] + .as_u64() + .filter(|size| *size > 0 && *size <= 32 * 1024 * 1024) + .ok_or_else(|| preparation_error("JS module size is invalid"))?; + let sha256 = entry["sha256"] + .as_str() + .filter(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + .ok_or_else(|| preparation_error("JS module digest is invalid"))?; + modules.insert( + id.to_owned(), + Module { + filename: filename.to_owned(), + size, + sha256: sha256.to_owned(), + }, + ); + previous = Some(id.to_owned()); + } + if modules.keys().ne(expected.iter()) { + return Err(preparation_error( + "JS inventory does not cover every tracked module", + )); + } + Ok(modules) +} + +fn compare_modules( + first: &Path, + second: &Path, + modules: &BTreeMap, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(), Report> { + let expected: BTreeSet<_> = modules + .values() + .map(|module| module.filename.clone()) + .collect(); + if filenames(first, expected.len())? != expected + || filenames(second, expected.len())? != expected + { + return Err(preparation_error( + "A/B final bundle sets differ from approved source", + )); + } + for module in modules.values() { + check()?; + let first = read_bounded_with_check(&first.join(&module.filename), Role::Bundle, check)?; + let second = read_bounded_with_check(&second.join(&module.filename), Role::Bundle, check)?; + if first.bytes != second.bytes + || first.sha256 != module.sha256 + || first.bytes.len() as u64 != module.size + { + return Err(preparation_error("full A/B final JS module bytes differ")); + } + } + Ok(()) +} + +fn test_report( + bytes: &[u8], + expected: &BTreeSet, + execution_library: &Path, +) -> Result> { + let report = parse_json(bytes, Role::Companion)?; + let total = report["numTotalTests"] + .as_u64() + .filter(|total| *total > 0) + .ok_or_else(|| preparation_error("registered JS test suite selected zero tests"))?; + if report["success"] != true + || report["numFailedTests"] != 0 + || report["numPassedTests"] != total + || report["numPendingTests"] != 0 + { + return Err(preparation_error( + "full registered JS tests did not all pass", + )); + } + let results = report["testResults"] + .as_array() + .ok_or_else(|| preparation_error("JS tests omitted per-file results"))?; + let mut actual = BTreeSet::new(); + for result in results { + let name = result["name"] + .as_str() + .ok_or_else(|| preparation_error("JS test result lacks its file"))?; + let relative = Path::new(name) + .strip_prefix(execution_library) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("JS test result is not UTF-8"))?; + if result["status"] != "passed" || !expected.contains(relative) { + return Err(preparation_error( + "JS test file failed or is not registered", + )); + } + actual.insert(relative.to_owned()); + } + if actual != *expected { + return Err(preparation_error( + "JS test/type-check report omits registered files", + )); + } + Ok(total) +} + +struct MissingFiles { + paths: Vec<(PathBuf, PathBuf)>, + _directory: tempfile::TempDir, +} +impl MissingFiles { + fn remove(paths: &[PathBuf]) -> Result> { + let directory = tempfile::tempdir().map_err(preparation_error)?; + let mut missing = Self { + paths: Vec::new(), + _directory: directory, + }; + for (index, path) in paths.iter().enumerate() { + let destination = missing._directory.path().join(index.to_string()); + fs::rename(path, &destination).map_err(preparation_error)?; + missing.paths.push((path.clone(), destination)); + } + Ok(missing) + } + fn restore(&mut self) -> Result<(), Report> { + while let Some((original, temporary)) = self.paths.last() { + fs::rename(temporary, original).map_err(preparation_error)?; + self.paths.pop(); + } + Ok(()) + } +} +impl Drop for MissingFiles { + fn drop(&mut self) { + let _ = self.restore(); + } +} + +impl PreparedJsBuild<'_> { + fn prohibited_install_download_probe( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + captured: &CapturedInventory, + ) -> Result<(), Report> { + let side = &self.sides[index]; + let probe = side + .state + .path() + .join("install-download/node_modules/esbuild"); + fs::create_dir_all(probe.join("lib")).map_err(preparation_error)?; + let mut originals = BTreeMap::new(); + for relative in ["install.js", "package.json", "lib/main.js"] { + let raw = read_bounded_with_check( + &side.library().join("node_modules/esbuild").join(relative), + Role::Bundle, + &|| self.attempt.check(), + )?; + fs::write(probe.join(relative), &raw.bytes).map_err(preparation_error)?; + originals.insert(relative, raw.sha256); + } + // This separate tree contains neither a native package nor an optimized + // binary. Omitting npm from PATH exercises esbuild's actual direct + // download fallback without waiting through npm's network retries. + let command = self + .command_with_npm_path(index, boundary, false)? + .arguments([boundary + .execution_path(&probe.join("install.js"))? + .into_os_string()]); + let result = boundary.run_confined(&command, proof)?; + verify_captured_output(&side.output(), captured, &|| self.attempt.check())?; + let stderr = std::str::from_utf8(result.stderr()).map_err(preparation_error)?; + if result.outcome() != (ProcessOutcome::Exited { code: 1 }) + || !stderr.contains("[esbuild] Trying to download ") + || !stderr.contains("Failed to install package") + { + return Err(preparation_error( + "actual missing-native install download did not fail under denial", + )); + } + let mut pending = vec![probe.clone()]; + let mut remaining = originals; + while let Some(directory) = pending.pop() { + self.attempt.check()?; + for entry in fs::read_dir(directory).map_err(preparation_error)? { + let entry = entry.map_err(preparation_error)?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(preparation_error)?; + if metadata.is_dir() { + pending.push(path); + } else { + let relative = path + .strip_prefix(&probe) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("probe member is not UTF-8"))?; + let expected = remaining.remove(relative).ok_or_else(|| { + preparation_error("denied install produced a replacement native tool") + })?; + if read_bounded_with_check(&path, Role::Bundle, &|| self.attempt.check())? + .sha256 + != expected + { + return Err(preparation_error("denied install changed probe inputs")); + } + } + } + } + if !remaining.is_empty() { + return Err(preparation_error("denied install removed probe inputs")); + } + side.verify_source()?; + self.check() + } + + fn missing_tool_probes( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + captured: &CapturedInventory, + ) -> Result<(), Report> { + let side = &self.sides[index]; + let architecture = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => { + return Err(preparation_error( + "unsupported native JS probe architecture", + )); + } + }; + let cases = [ + (vec![side.library().join(format!("node_modules/@esbuild/linux-{architecture}/bin/esbuild")), side.library().join("node_modules/esbuild/bin/esbuild"), side.library().join("node_modules/.bin/esbuild")], "const e=require('esbuild'); e.transformSync('const x=1',{minify:true})"), + (vec![side.library().join(format!("node_modules/@rollup/rollup-linux-{architecture}-gnu/rollup.linux-{architecture}-gnu.node"))], "require('rollup')"), + ]; + for (paths, operation) in cases { + let mut missing = MissingFiles::remove(&paths)?; + let script = format!( + "try{{{operation};process.exitCode=0}}catch{{process.stdout.write('TS_RELEASE_MISSING_TOOL_DENIED\\n');process.exitCode=73}}" + ); + let command = self + .command(index, boundary)? + .arguments([OsString::from("--eval"), OsString::from(script)]); + let result = boundary.run_confined(&command, proof); + missing.restore()?; + let result = result?; + verify_captured_output(&side.output(), captured, &|| self.attempt.check())?; + if result.outcome() != (ProcessOutcome::Exited { code: 73 }) + || result.stdout() != b"TS_RELEASE_MISSING_TOOL_DENIED\n" + { + return Err(preparation_error( + "missing native tool fetched a substitute or lacked an actual denied failure", + )); + } + side.verify_source()?; + self.check()?; + } + Ok(()) + } + + /// Executes both independent full builds under their exact proven boundaries. + /// + /// # Errors + /// + /// Returns an error for any input mutation, installation/hook/control failure, + /// failed or incomplete test/type-check suite, or any final module difference. + pub(crate) fn run( + self, + boundaries: [&Boundary; 2], + proofs: [&DenialProof; 2], + ) -> Result> { + let execution = self.execute(boundaries, proofs); + let cleanup = self.cleanup(); + finalize_execution(execution, cleanup) + } + + fn execute( + &self, + boundaries: [&Boundary; 2], + proofs: [&DenialProof; 2], + ) -> Result> { + for (index, boundary) in boundaries.iter().enumerate() { + if !boundary.matches_roots(&self.boundary_roots(index)?) + || !boundary.has_owned_descendant_cleanup() + { + return Err(preparation_error( + "JS execution requires independent exact Linux boundary roots", + )); + } + } + let mut counts = [0_u64; 2]; + let mut observations = Vec::new(); + let mut captured_inventories = Vec::new(); + for index in 0..2 { + observations.push(self.install(index, boundaries[index], proofs[index])?); + self.run_command( + index, + boundaries[index], + proofs[index], + self.collector_arguments(index, boundaries[index])?, + )?; + let side = &self.sides[index]; + let raw = read_bounded_with_check( + &side.output().join("tsjs-inventory.json"), + Role::Metadata, + &|| self.attempt.check(), + )?; + join_inventory(self.source, &raw.bytes)?; + let modules = inventory_modules(&raw.bytes, &self.module_ids)?; + let captured = CapturedInventory { raw, modules }; + verify_captured_output(&side.output(), &captured, &|| self.attempt.check())?; + self.missing_tool_probes(index, boundaries[index], proofs[index], &captured)?; + self.prohibited_install_download_probe( + index, + boundaries[index], + proofs[index], + &captured, + )?; + let arguments = vec![ + boundaries[index] + .execution_path(&side.library().join("node_modules/vitest/vitest.mjs"))? + .into_os_string(), + OsString::from("run"), + OsString::from("--reporter=json"), + OsString::from("--outputFile"), + boundaries[index] + .execution_path(&side.output().join("vitest.json"))? + .into_os_string(), + ]; + self.run_command(index, boundaries[index], proofs[index], arguments)?; + verify_captured_output(&side.output(), &captured, &|| self.attempt.check())?; + let report = read_bounded_with_check( + &side.output().join("vitest.json"), + Role::Companion, + &|| self.attempt.check(), + )?; + counts[index] = test_report( + &report.bytes, + &side.expected_tests, + &boundaries[index].execution_path(&side.library())?, + )?; + side.verify_source()?; + captured_inventories.push(captured); + } + self.check()?; + for (index, captured) in captured_inventories.iter().enumerate() { + verify_captured_output(&self.sides[index].output(), captured, &|| { + self.attempt.check() + })?; + } + let [first, second]: [CapturedInventory; 2] = captured_inventories + .try_into() + .map_err(|_| preparation_error("both original collector inventories are required"))?; + let modules = first.modules; + for (id, first) in &modules { + let last = second + .modules + .get(id) + .ok_or_else(|| preparation_error("B omitted a tracked module"))?; + if first.sha256 != last.sha256 || first.size != last.size { + return Err(preparation_error( + "A/B inventory module measurements differ", + )); + } + } + compare_modules( + &self.sides[0].output().join("bundles"), + &self.sides[1].output().join("bundles"), + &modules, + &|| self.attempt.check(), + )?; + let directory = tempfile::tempdir().map_err(preparation_error)?; + fs::create_dir(directory.path().join("bundles")).map_err(preparation_error)?; + fs::write( + directory.path().join("tsjs-inventory.json"), + &first.raw.bytes, + ) + .map_err(preparation_error)?; + let observation = &observations + .first() + .ok_or_else(|| preparation_error("actual A installation observation is absent"))? + .raw; + fs::write(directory.path().join("npm-observation.json"), observation) + .map_err(preparation_error)?; + for module in modules.values() { + let raw = read_bounded_with_check( + &self.sides[0] + .output() + .join("bundles") + .join(&module.filename), + Role::Bundle, + &|| self.attempt.check(), + )?; + if raw.sha256 != module.sha256 || raw.bytes.len() as u64 != module.size { + return Err(preparation_error("A output changed before retention")); + } + fs::write( + directory.path().join("bundles").join(&module.filename), + raw.bytes, + ) + .map_err(preparation_error)?; + } + let completed = JsBuildCompleted { + directory, + source_commit: self.source.commit().as_str().to_owned(), + tools_sha256: self.tools_digest.clone(), + settings_sha256: settings_digest(&[ + boundaries[0].execution_fingerprint(), + boundaries[1].execution_fingerprint(), + ]), + inventory_sha256: first.raw.sha256, + observation_sha256: hex::encode(Sha256::digest(observation)), + modules, + tests: counts, + attempt: self.attempt.clone(), + }; + completed.rehash_for_handoff()?; + Ok(completed) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/javascript/tests.rs b/crates/trusted-server-release/src/javascript/tests.rs new file mode 100644 index 0000000000..42f3382e1c --- /dev/null +++ b/crates/trusted-server-release/src/javascript/tests.rs @@ -0,0 +1,209 @@ +use super::*; +use serde_json::{Value, json}; +use std::os::unix::fs::symlink; + +#[test] +fn one_byte_final_module_difference_rejects_the_complete_comparison() { + let first = tempfile::tempdir().expect("should create A outputs"); + let second = tempfile::tempdir().expect("should create B outputs"); + fs::write(first.path().join("tsjs-core.js"), b"one").expect("should write A module"); + fs::write(second.path().join("tsjs-core.js"), b"one").expect("should write B module"); + let modules = BTreeMap::from([( + "core".to_owned(), + Module { + filename: "tsjs-core.js".to_owned(), + size: 3, + sha256: hex::encode(Sha256::digest(b"one")), + }, + )]); + compare_modules(first.path(), second.path(), &modules, &|| Ok(())) + .expect("should compare complete equal bytes"); + fs::write(second.path().join("tsjs-core.js"), b"onE").expect("should change one final byte"); + assert!( + compare_modules(first.path(), second.path(), &modules, &|| Ok(())).is_err(), + "should reject one differing final byte" + ); +} +#[test] +fn full_suite_report_rejects_zero_selection_or_missing_typecheck_file() { + let expected = BTreeSet::from([ + "test/example.test.ts".to_owned(), + "test/types.test.ts".to_owned(), + ]); + let mut report = json!({"success":true,"numTotalTests":2,"numPassedTests":2,"numFailedTests":0,"numPendingTests":0,"testResults":[{"name":"/example/lib/test/example.test.ts","status":"passed"},{"name":"/example/lib/test/types.test.ts","status":"passed"}]}); + let encode = |value: &Value| serde_json::to_vec(value).expect("should encode test report"); + assert_eq!( + test_report(&encode(&report), &expected, Path::new("/example/lib")) + .expect("should join all registered results"), + 2, + "should retain nonzero passed count" + ); + report["numTotalTests"] = json!(0); + assert!( + test_report(&encode(&report), &expected, Path::new("/example/lib")).is_err(), + "should reject zero selected tests" + ); + report["numTotalTests"] = json!(2); + report["testResults"] + .as_array_mut() + .expect("should retain array") + .pop(); + assert!( + test_report(&encode(&report), &expected, Path::new("/example/lib")).is_err(), + "should reject an omitted registered type-check suite" + ); +} +#[test] +fn parsed_inventory_cannot_choose_an_untracked_module_or_filename() { + let mut inventory = json!({"modules":[{"id":"core","filename":"tsjs-core.js","size":1,"sha256":"a".repeat(64)}]}); + let expected = vec!["core".to_owned()]; + let encode = + |value: &Value| serde_json::to_vec(value).expect("should encode inventory fixture"); + inventory_modules(&encode(&inventory), &expected).expect("should parse tracked module facts"); + inventory["modules"][0]["filename"] = json!("../outside"); + assert!( + inventory_modules(&encode(&inventory), &expected).is_err(), + "should reject path-selected bundle output" + ); +} + +fn retained_fixture() -> JsBuildCompleted { + let directory = tempfile::tempdir().expect("should create retained fixture"); + fs::create_dir(directory.path().join("bundles")).expect("should create bundle root"); + let inventory = b"fictional original inventory"; + let observation = b"fictional private observation"; + let module = b"fictional module"; + fs::write(directory.path().join("tsjs-inventory.json"), inventory) + .expect("should write exact fixture inventory"); + fs::write(directory.path().join("npm-observation.json"), observation) + .expect("should write exact fixture observation"); + fs::write(directory.path().join("bundles/tsjs-core.js"), module) + .expect("should write fixture module"); + JsBuildCompleted { + directory, + source_commit: "a".repeat(40), + tools_sha256: "b".repeat(64), + settings_sha256: "c".repeat(64), + inventory_sha256: hex::encode(Sha256::digest(inventory)), + observation_sha256: hex::encode(Sha256::digest(observation)), + modules: BTreeMap::from([( + "core".to_owned(), + Module { + filename: "tsjs-core.js".to_owned(), + size: module.len() as u64, + sha256: hex::encode(Sha256::digest(module)), + }, + )]), + tests: [1, 1], + attempt: JsAttempt::new( + std::time::Instant::now() + std::time::Duration::from_secs(30), + crate::process::Cancellation::default(), + ), + } +} + +#[test] +fn retained_handoff_rejects_extra_root_files() { + let completed = retained_fixture(); + completed + .rehash_for_handoff() + .expect("should rejoin exact fixture bytes"); + fs::write(completed.directory.path().join("unrecorded"), b"extra") + .expect("should inject an extra retained file"); + assert!( + completed.rehash_for_handoff().is_err(), + "should reject unrecorded retained root entries" + ); +} + +#[test] +fn retained_handoff_rejects_identical_directory_aliases() { + for alias_bundles in [false, true] { + let completed = retained_fixture(); + let external = tempfile::tempdir().expect("should create replacement directory"); + let original = if alias_bundles { + completed.directory.path().join("bundles") + } else { + completed.directory.path().to_owned() + }; + let destination = external.path().join("retained"); + fs::rename(&original, &destination).expect("should relocate identical retained bytes"); + symlink(&destination, &original).expect("should alias identical retained directory"); + assert!( + completed.rehash_for_handoff().is_err(), + "should reject directory aliases despite identical bytes" + ); + } +} + +#[test] +fn original_collector_bytes_cannot_be_replaced_by_later_child_output() { + let completed = retained_fixture(); + let output = completed.directory.path(); + let raw = read_bounded_with_check(&output.join("tsjs-inventory.json"), Role::Metadata, &|| { + Ok(()) + }) + .expect("should capture original collector bytes"); + let captured = CapturedInventory { + raw, + modules: completed.modules, + }; + verify_captured_output(output, &captured, &|| Ok(())) + .expect("should rejoin original collector outputs"); + fs::write(output.join("tsjs-inventory.json"), b"replacement inventory") + .expect("should simulate later child inventory replacement"); + assert!( + verify_captured_output(output, &captured, &|| Ok(())).is_err(), + "should reject later inventory replacement before retaining it as original" + ); +} + +#[test] +fn completion_settings_bind_both_actual_boundary_instances() { + let first = settings_digest(&[[1_u8; 32], [2_u8; 32]]); + assert_ne!( + first, + settings_digest(&[[3_u8; 32], [2_u8; 32]]), + "should bind A controls" + ); + assert_ne!( + first, + settings_digest(&[[1_u8; 32], [3_u8; 32]]), + "should bind B controls" + ); + assert_ne!( + first, + settings_digest(&[[2_u8; 32], [1_u8; 32]]), + "should bind comparison-side ordering" + ); +} + +#[test] +fn failed_cleanup_cannot_return_or_retain_a_completed_build() { + let completed = retained_fixture(); + let retained = completed.directory.path().to_owned(); + assert!( + finalize_execution( + Ok(completed), + Err(preparation_error("fictional cleanup failure")) + ) + .is_err(), + "should deny completion when private resource cleanup fails" + ); + assert!( + !retained.exists(), + "should discard private retained output when cleanup fails" + ); + let error = finalize_execution( + Err(preparation_error("fictional execution failure")), + Err(preparation_error("fictional cleanup failure")), + ) + .err() + .expect("should reject both failures"); + let message = format!("{error:?}"); + assert!( + message.contains("fictional execution failure") + && message.contains("fictional cleanup failure"), + "should retain both failure contexts" + ); +} diff --git a/crates/trusted-server-release/src/lifecycle_guard.rs b/crates/trusted-server-release/src/lifecycle_guard.rs new file mode 100644 index 0000000000..2262a4176b --- /dev/null +++ b/crates/trusted-server-release/src/lifecycle_guard.rs @@ -0,0 +1,86 @@ +//! Linux-only N-API guard for the trusted npm lifecycle observer. +//! +//! Compile this approved source with the selected native Rust toolchain using +//! `--crate-type cdylib --edition 2024`. The resulting `.node` file is a measured +//! tool input, loaded before the observer receives its private authentication +//! key. It is not a standalone executable or a qualification receipt. + +#![deny(unsafe_op_in_unsafe_fn)] + +#[cfg(not(target_os = "linux"))] +compile_error!("the npm lifecycle guard requires Linux"); + +use core::ffi::{c_char, c_int, c_ulong, c_void}; +use core::ptr; + +const PR_SET_DUMPABLE: c_int = 4; +const PR_GET_DUMPABLE: c_int = 3; + +unsafe extern "C" { + fn prctl(option: c_int, ...) -> c_int; + fn umask(mask: u32) -> u32; + fn napi_throw_error(env: *mut c_void, code: *const c_char, message: *const c_char) -> c_int; + fn napi_get_boolean(env: *mut c_void, value: bool, result: *mut *mut c_void) -> c_int; + fn napi_set_named_property( + env: *mut c_void, + object: *mut c_void, + name: *const c_char, + value: *mut c_void, + ) -> c_int; +} + +/// Protects the observer from same-user process-memory and `/proc` descriptor +/// access before it accepts sensitive input. +/// +/// # Safety +/// +/// Node must call this N-API registration entry with a live environment and +/// exports object. The library must come from the selected read-only tool root. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn napi_register_module_v1( + env: *mut c_void, + exports: *mut c_void, +) -> *mut c_void { + // SAFETY: umask accepts any mode mask. This dedicated observer process + // selects npm's fixed extraction mode before spawning any install hooks. + unsafe { + umask(0o022); + } + // SAFETY: Linux prctl accepts these fixed options and zero arguments. + let protected = unsafe { + prctl( + PR_SET_DUMPABLE, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + ) == 0 + && prctl( + PR_GET_DUMPABLE, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + 0 as c_ulong, + ) == 0 + }; + let mut value = ptr::null_mut(); + // SAFETY: Node supplies valid N-API handles; all strings are static C strings. + let exported = protected + && unsafe { + napi_get_boolean(env, true, &mut value) == 0 + && napi_set_named_property(env, exports, c"protected".as_ptr(), value) == 0 + }; + if !exported { + // SAFETY: The live Node environment and static NUL-terminated message + // meet N-API's preconditions. A pending exception rejects require(). + unsafe { + napi_throw_error( + env, + ptr::null(), + c"Cannot protect lifecycle observer".as_ptr(), + ); + } + return ptr::null_mut(); + } + exports +} diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 46c1b22005..0e6ba108fd 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -8,10 +8,14 @@ mod error; mod git_objects; #[allow(dead_code, reason = "consumed by Task 9 recipes")] mod inspect; +#[allow(dead_code, reason = "production JS tool qualification remains closed")] +mod javascript; #[allow(dead_code, reason = "consumed by Task 7 material preparation")] mod materials; mod policy; mod preflight; +#[allow(dead_code, reason = "production JS tool qualification remains closed")] +mod preparation; // `expect` would be unfulfilled in the test build, where the tests use it. #[allow(dead_code, reason = "consumed by Task 7 boundary and preparation")] mod process; diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs index 804a0333f6..40cc103b56 100644 --- a/crates/trusted-server-release/src/materials.rs +++ b/crates/trusted-server-release/src/materials.rs @@ -6,8 +6,10 @@ mod archive; mod extract; +mod git; mod locked; mod manifest; +mod npm_bundle; use std::collections::BTreeMap; use std::path::Path; @@ -24,6 +26,7 @@ pub(crate) use archive::VerifiedOriginal; pub(crate) use extract::ExtractedOriginal; use locked::{CargoMaterials, NpmMaterial, parse_cargo, parse_npm}; pub(crate) use manifest::{ReviewedNpmManifest, RootManifestReview}; +pub(crate) use npm_bundle::PreparedNpmOriginals; const NPM_LOCK: &str = "crates/trusted-server-js/lib/package-lock.json"; @@ -43,6 +46,21 @@ pub(crate) struct LockedMaterials { } impl LockedMaterials { + /// Starts one bounded collection of locked Git object snapshots. + /// + /// This selects a local executable; it does not qualify a release tool. + /// + /// # Errors + /// + /// Returns an error for an unsafe executable or expired collection. + pub(crate) fn git_collection( + &self, + program: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result, Report> { + git::GitCollection::new(self, program, deadline, cancellation) + } /// Reviews approved root declarations without executing npm lifecycle hooks. /// /// # Errors diff --git a/crates/trusted-server-release/src/materials/extract.rs b/crates/trusted-server-release/src/materials/extract.rs index ab7f017c61..7c15ba0b5c 100644 --- a/crates/trusted-server-release/src/materials/extract.rs +++ b/crates/trusted-server-release/src/materials/extract.rs @@ -12,7 +12,7 @@ use sha2::{Digest as _, Sha256}; use tempfile::TempDir; use super::{VerifiedOriginal, material_error}; -use crate::documents::{Role, read_bounded}; +use crate::documents::{Role, hash_bounded_with_check, read_bounded}; use crate::error::ReleaseError; #[derive(Clone, Copy)] @@ -41,9 +41,108 @@ pub(crate) struct ExtractedOriginal { root: PathBuf, files: BTreeMap, root_name: String, + directories: BTreeSet, } impl ExtractedOriginal { + /// Returns a rechecked regular member's original digest, length and mode. + /// + /// # Errors + /// + /// Returns an error if the tree changed or the member was not recorded. + pub(super) fn member_facts( + &self, + relative: &str, + ) -> Result<(String, u64, u32), Report> { + self.verify_unchanged()?; + let facts = self + .files + .get(&format!("{}/{relative}", self.root_name)) + .ok_or_else(|| { + material_error("required native tool is not an authenticated archive member") + })?; + Ok((facts.sha256.clone(), facts.size, facts.mode)) + } + /// Rejoins the entire private extraction before it is consumed again. + /// + /// # Errors + /// + /// Returns an error for a changed, missing or added member, unsafe type, + /// changed executable mode, or an expired attempt. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + self.original.check()?; + let mut remaining: BTreeSet<_> = self.files.keys().cloned().collect(); + let mut remaining_directories = self.directories.clone(); + let mut pending = vec![self.root_name.clone()]; + while let Some(directory) = pending.pop() { + self.original.check()?; + let metadata = fs::symlink_metadata(self.directory.path().join(&directory)) + .map_err(material_error)?; + if !metadata.is_dir() { + return Err(material_error( + "extracted directory changed type or became an alias", + )); + } + remaining_directories.remove(&directory); + let entries = + fs::read_dir(self.directory.path().join(&directory)).map_err(material_error)?; + for entry in entries { + self.original.check()?; + let entry = entry.map_err(material_error)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| material_error("extracted member is not UTF-8"))?; + let key = format!("{directory}/{name}"); + let metadata = fs::symlink_metadata(entry.path()).map_err(material_error)?; + if metadata.is_dir() { + if !self.directories.contains(&key) { + return Err(material_error("unrecorded extracted directory appeared")); + } + pending.push(key); + } else { + let expected = self + .files + .get(&key) + .ok_or_else(|| material_error("unrecorded extracted member appeared"))?; + if !metadata.is_file() + || metadata.len() != expected.size + || metadata.permissions().mode() & 0o7777 != 0o600 | (expected.mode & 0o111) + { + return Err(material_error( + "extracted member type, size or permissions changed", + )); + } + let raw = hash_bounded_with_check(&entry.path(), MEMBER_LIMIT, &|| { + self.original.check() + })?; + if raw.sha256 != expected.sha256 + || raw.size != expected.size + || raw.mode & 0o7777 != 0o600 | (expected.mode & 0o111) + { + return Err(material_error("extracted member bytes changed")); + } + remaining.remove(&key); + } + } + } + if !remaining.is_empty() || !remaining_directories.is_empty() { + return Err(material_error("recorded extracted member is missing")); + } + self.original.check() + } + + /// Copies only the retained authenticated compressed original. + /// + /// # Errors + /// + /// Returns an error if the retained original changed or the copy fails. + pub(super) fn copy_original( + &mut self, + destination: &mut impl io::Write, + ) -> Result<(), Report> { + self.original.copy_verified(destination) + } /// Returns the locked acquisition identity supplying this extracted tree. pub(super) fn identity(&self) -> &str { self.original.identity() @@ -406,12 +505,20 @@ fn extract_bounded( if !root.is_dir() || state.files.is_empty() { return Err(material_error("archive has no package files")); } + for file in state.files.keys() { + let mut current = file.as_str(); + while let Some((parent, _)) = current.rsplit_once('/') { + state.directories.insert(parent.to_owned()); + current = parent; + } + } Ok(ExtractedOriginal { root_name: selection.root.to_owned(), original, root, directory: state.directory, files: state.files, + directories: state.directories, }) } diff --git a/crates/trusted-server-release/src/materials/extract/tests.rs b/crates/trusted-server-release/src/materials/extract/tests.rs index 13a49d56ba..3afd8abef1 100644 --- a/crates/trusted-server-release/src/materials/extract/tests.rs +++ b/crates/trusted-server-release/src/materials/extract/tests.rs @@ -457,3 +457,115 @@ fn enforces_decoded_byte_and_entry_budgets() { "should stop unbounded decoded expansion" ); } + +#[test] +fn extracted_tree_review_rejects_changed_or_added_members() { + let bytes = compressed(&tar_bytes(&[( + "package/index.js", + b'0', + 0o644, + b"original", + )])); + let extracted = extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + }, + ) + .expect("should extract original"); + extracted + .verify_unchanged() + .expect("should verify original tree"); + fs::write(extracted.root().join("added.js"), b"added").expect("should add unrecorded file"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject unrecorded member" + ); + fs::remove_file(extracted.root().join("added.js")).expect("should remove extra file"); + fs::write(extracted.root().join("index.js"), b"changed").expect("should change recorded file"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject changed recorded member" + ); +} + +#[test] +fn npm_bundle_rechecks_and_retains_originals_for_independent_caches() { + let manifest = serde_json::to_vec(&json!({"name":"fictional-package","version":"1.0.0"})) + .expect("should encode manifest"); + let bytes = compressed(&tar_bytes(&[( + "package/package.json", + b'0', + 0o644, + &manifest, + )])); + let catalog = catalog(&bytes); + let inputs = tempfile::tempdir().expect("should create input directory"); + let name = format!("{}.tgz", hex::encode(Sha512::digest(&bytes))); + fs::write(inputs.path().join(&name), &bytes).expect("should write original"); + let prepared = catalog + .prepare_npm_originals( + inputs.path(), + Instant::now() + Duration::from_secs(10), + &Cancellation::default(), + ) + .expect("should authenticate complete original set"); + assert_eq!( + prepared.archive_paths().len(), + 1, + "should retain every locked original" + ); + assert_eq!( + fs::read(&prepared.archive_paths()[0]).expect("should read retained original"), + bytes, + "should retain compressed identity rather than repacked bytes" + ); + fs::write(inputs.path().join(name), b"changed").expect("should replace caller input"); + prepared + .verify_unchanged() + .expect("should own originals independently of caller input"); + assert!( + catalog + .prepare_npm_originals( + inputs.path(), + Instant::now() + Duration::from_secs(10), + &Cancellation::default() + ) + .is_err(), + "should reject changed original on next preparation" + ); +} + +#[test] +fn extraction_recheck_rejects_root_alias_and_missing_empty_directory() { + let bytes = compressed(&tar_bytes(&[ + ("package/index.js", b'0', 0o644, b"original"), + ("package/empty", b'5', 0o755, b""), + ])); + let extracted = extract( + original(&bytes), + Selection { + root: "package", + index_alias: false, + }, + ) + .expect("should extract original"); + extracted + .verify_unchanged() + .expect("should recheck explicit empty directory"); + fs::remove_dir(extracted.root().join("empty")).expect("should remove empty directory"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject missing recorded empty directory" + ); + fs::create_dir(extracted.root().join("empty")).expect("should restore directory"); + let saved = extracted.directory.path().join("saved"); + fs::rename(extracted.root(), &saved).expect("should move original root"); + std::os::unix::fs::symlink("saved", extracted.root()) + .expect("should substitute matching root alias"); + assert!( + extracted.verify_unchanged().is_err(), + "should reject replaced extraction root even with identical members" + ); +} diff --git a/crates/trusted-server-release/src/materials/git.rs b/crates/trusted-server-release/src/materials/git.rs new file mode 100644 index 0000000000..b56e71c01e --- /dev/null +++ b/crates/trusted-server-release/src/materials/git.rs @@ -0,0 +1,508 @@ +//! Locked Git commit snapshots, distinct from acquisition and Cargo checkouts. + +mod input; + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use error_stack::Report; +use flate2::Compression; +use flate2::write::ZlibEncoder; +use sha2::{Digest as _, Sha256}; + +use super::{LockedMaterials, material_error}; +use crate::error::ReleaseError; +use crate::git_objects::ObjectReader; +use crate::process::{BoundedCommand, Cancellation, ProcessLimits, ProcessResult}; + +const TREE_DEPTH: usize = 256; +const TREE_ENTRIES: usize = 100_000; + +/// One bounded collection shares its decoded object budget across dependencies. +/// +/// A locally selected executable is not an authenticated release-tool selection. +pub(crate) struct GitCollection<'a> { + locked: &'a LockedMaterials, + program: PathBuf, + program_sha256: String, + deadline: Instant, + cancellation: Cancellation, + objects: ObjectReader, +} + +/// A selected commit's complete verified closure in an owned private object store. +/// +/// Parent history and unselected objects are deliberately absent. This value +/// proves an object snapshot, not an installed Cargo checkout or tool qualification. +pub(crate) struct VerifiedGitMaterial { + source_commit: String, + cargo_lock_sha256: String, + selector: String, + revision: String, + root_tree: String, + store: tempfile::TempDir, + object_ids: BTreeSet, +} + +impl VerifiedGitMaterial { + /// Returns the independently approved source binding. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + /// Returns the approved raw Cargo lockfile digest. + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.cargo_lock_sha256 + } + /// Returns the exact locked Git source selector. + pub(crate) fn selector(&self) -> &str { + &self.selector + } + /// Returns the exact locked commit identity. + pub(crate) fn revision(&self) -> &str { + &self.revision + } + /// Returns the selected commit's collision-checked root tree identity. + pub(crate) fn root_tree(&self) -> &str { + &self.root_tree + } + /// Borrows the owned verified-only object directory, never the input store. + pub(crate) fn object_store(&self) -> &Path { + self.store.path() + } + /// Returns the verified selected closure's distinct object count. + pub(crate) fn object_count(&self) -> usize { + self.object_ids.len() + } +} + +fn program_digest( + path: &Path, + deadline: Instant, + cancellation: &Cancellation, +) -> Result> { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(material_error)?; + let metadata = file.metadata().map_err(material_error)?; + if !metadata.is_file() + || metadata.permissions().mode() & 0o111 == 0 + || metadata.len() > 64 * 1024 * 1024 + { + return Err(material_error( + "selected Git must be a bounded regular executable", + )); + } + let mut digest = Sha256::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error( + "Git executable verification cancelled or timed out", + )); + } + let count = file.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + length += count as u64; + if length > metadata.len() { + return Err(material_error("selected Git executable grew")); + } + digest.update(&buffer[..count]); + } + let after = file.metadata().map_err(material_error)?; + let stamp = |value: &std::fs::Metadata| { + ( + value.dev(), + value.ino(), + value.nlink(), + value.len(), + value.mode(), + value.mtime(), + value.mtime_nsec(), + value.ctime(), + value.ctime_nsec(), + ) + }; + if length != metadata.len() || stamp(&metadata) != stamp(&after) { + return Err(material_error("selected Git executable changed")); + } + Ok(hex::encode(digest.finalize())) +} + +impl<'a> GitCollection<'a> { + pub(super) fn new( + locked: &'a LockedMaterials, + program: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + if !program.is_absolute() { + return Err(material_error("selected Git path must be absolute")); + } + let program = fs::canonicalize(program).map_err(material_error)?; + let program_sha256 = program_digest(&program, deadline, cancellation)?; + let collection = Self { + locked, + program, + program_sha256, + deadline, + cancellation: cancellation.clone(), + objects: ObjectReader::cargo_materials(), + }; + collection.check()?; + Ok(collection) + } + + fn check(&self) -> Result<(), Report> { + if self.cancellation.is_cancelled() || Instant::now() >= self.deadline { + return Err(material_error( + "Git material verification cancelled or timed out", + )); + } + Ok(()) + } + + fn execute( + &self, + repository: &Path, + arguments: &[&str], + input: Vec, + limit: u64, + ) -> Result, Report> { + self.check()?; + if program_digest(&self.program, self.deadline, &self.cancellation)? != self.program_sha256 + { + return Err(material_error("selected Git executable changed")); + } + let home = repository.join("home"); + let mut git_dir = OsString::from("--git-dir="); + git_dir.push(repository); + let mut fixed = vec![git_dir, OsString::from("--no-replace-objects")]; + fixed.extend( + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "-c", + "protocol.allow=never", + ] + .into_iter() + .chain(arguments.iter().copied()) + .map(OsString::from), + ); + let wall = self + .deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| material_error("Git material deadline expired"))?; + let output = BoundedCommand::new( + &self.program, + repository, + ProcessLimits { + wall: wall.min(Duration::from_secs(120)), + stdout_max: limit, + stderr_max: 64 * 1024, + }, + )? + .arguments(fixed) + .environment("HOME", &home)? + .environment( + "PATH", + self.program + .parent() + .ok_or_else(|| material_error("Git has no parent"))?, + )? + .environment("GIT_CONFIG_NOSYSTEM", "1")? + .environment("GIT_NO_REPLACE_OBJECTS", "1")? + .environment("GIT_TERMINAL_PROMPT", "0")? + .environment("GIT_OPTIONAL_LOCKS", "0")? + .environment("LC_ALL", "C")? + .stdin_bytes(input) + .cancellation(self.cancellation.clone()) + .run() + .and_then(ProcessResult::require_success) + .map_err(|report| report.change_context(ReleaseError::Material))?; + self.check()?; + Ok(output) + } + + fn read( + &self, + repository: &Path, + kind: &'static str, + oid: &str, + ) -> Result, Report> { + self.check()?; + let mut body = Vec::new(); + self.objects + .read_objects( + kind, + &[oid], + &mut |arguments, input, limit| self.execute(repository, arguments, input, limit), + |_, bytes| { + body.extend_from_slice(bytes); + Ok(()) + }, + ) + .map_err(|report| report.change_context(ReleaseError::Material))?; + Ok(body) + } + + fn retain( + &self, + store: &Path, + kind: &str, + oid: &str, + bytes: &[u8], + ) -> Result<(), Report> { + self.check()?; + let directory = store.join(&oid[..2]); + match fs::create_dir(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(material_error(error)), + } + let output = File::create_new(directory.join(&oid[2..])).map_err(material_error)?; + let mut encoded = ZlibEncoder::new(output, Compression::default()); + encoded + .write_all(format!("{kind} {}\0", bytes.len()).as_bytes()) + .map_err(material_error)?; + for chunk in bytes.chunks(64 * 1024) { + self.check()?; + encoded.write_all(chunk).map_err(material_error)?; + } + encoded.finish().map_err(material_error)?; + self.check() + } + + /// Verifies only an approved locked selector and its full commit identity. + /// + /// Input objects are copied as untrusted data. Their repository configuration, + /// refs, hooks and alternates are never consulted. All selected trees and blobs + /// are independently rehashed, including subtrees outside Cargo package roots. + /// + /// # Errors + /// + /// Returns an error for an unlocked selection, unsafe input, changed executable, + /// missing/corrupt objects, unsafe tree entry or exceeded shared limits. + pub(crate) fn verify( + &self, + selector: &str, + revision: &str, + objects: &Path, + ) -> Result> { + self.check()?; + if !lower_oid(revision.as_bytes()) { + return Err(material_error( + "Git revision must be a full canonical object identity", + )); + } + if !self + .locked + .cargo + .git + .contains(&(selector.to_owned(), revision.to_owned())) + { + return Err(material_error( + "Git selection is absent from approved Cargo lockfile", + )); + } + let repository = tempfile::Builder::new() + .prefix("release-git-input-") + .tempdir() + .map_err(material_error)?; + fs::create_dir(repository.path().join("objects")).map_err(material_error)?; + fs::create_dir(repository.path().join("refs")).map_err(material_error)?; + fs::create_dir(repository.path().join("home")).map_err(material_error)?; + fs::write(repository.path().join("HEAD"), b"ref: refs/heads/unused\n") + .map_err(material_error)?; + fs::write( + repository.path().join("config"), + b"[core]\nrepositoryformatversion = 0\nbare = true\n", + ) + .map_err(material_error)?; + input::copy(objects, &repository.path().join("objects"), self)?; + let store = tempfile::Builder::new() + .prefix("release-git-verified-") + .tempdir() + .map_err(material_error)?; + let commit = self.read(repository.path(), "commit", revision)?; + let root_tree = commit_tree(&commit)?; + self.retain(store.path(), "commit", revision, &commit)?; + let mut object_kinds = BTreeMap::from([(revision.to_owned(), "commit")]); + let mut trees = BTreeMap::>::new(); + let mut pending = vec![(root_tree.clone(), 1_usize, 0_usize)]; + let mut entry_count = 0_usize; + while let Some((oid, depth, prefix_length)) = pending.pop() { + self.check()?; + if depth > TREE_DEPTH { + return Err(material_error("Git dependency tree exceeds depth limit")); + } + let entries = if let Some(entries) = trees.get(&oid) { + entries.clone() + } else { + let bytes = self.read(repository.path(), "tree", &oid)?; + let entries = parse_tree(&bytes)?; + self.retain(store.path(), "tree", &oid, &bytes)?; + if object_kinds.insert(oid.clone(), "tree").is_some() { + return Err(material_error( + "Git object is referenced with conflicting types", + )); + } + trees.insert(oid, entries.clone()); + entries + }; + entry_count = entry_count + .checked_add(entries.len()) + .ok_or_else(|| material_error("tree entry count overflow"))?; + if entry_count > TREE_ENTRIES { + return Err(material_error("Git dependency tree exceeds entry limit")); + } + for entry in entries { + let path_length = prefix_length + entry.name_length; + if path_length > 4096 { + return Err(material_error("Git dependency path exceeds byte limit")); + } + if entry.tree { + pending.push((entry.oid, depth + 1, path_length + 1)); + } else if let Some(kind) = object_kinds.get(&entry.oid) { + if *kind != "blob" { + return Err(material_error( + "Git object is referenced with conflicting types", + )); + } + } else { + let bytes = self.read(repository.path(), "blob", &entry.oid)?; + self.retain(store.path(), "blob", &entry.oid, &bytes)?; + object_kinds.insert(entry.oid, "blob"); + } + } + } + self.check()?; + Ok(VerifiedGitMaterial { + source_commit: self.locked.source_commit.clone(), + cargo_lock_sha256: self.locked.cargo_lock_sha256.clone(), + selector: selector.to_owned(), + revision: revision.to_owned(), + root_tree, + store, + object_ids: object_kinds.into_keys().collect(), + }) + } +} + +fn lower_oid(bytes: &[u8]) -> bool { + bytes.len() == 40 + && bytes + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) +} + +fn commit_tree(bytes: &[u8]) -> Result> { + if bytes.contains(&0) { + return Err(material_error("Git commit contains a NUL")); + } + let end = bytes + .windows(2) + .position(|window| window == b"\n\n") + .ok_or_else(|| material_error("Git commit has no header terminator"))?; + let mut headers = bytes[..end].split(|byte| *byte == b'\n'); + let tree = headers + .next() + .and_then(|line| line.strip_prefix(b"tree ")) + .filter(|oid| lower_oid(oid)) + .ok_or_else(|| material_error("Git commit has no canonical root tree"))?; + if headers.any(|line| line.starts_with(b"tree ")) { + return Err(material_error("Git commit repeats its root tree")); + } + String::from_utf8(tree.to_vec()).map_err(material_error) +} + +#[derive(Clone)] +struct Entry { + tree: bool, + oid: String, + name_length: usize, +} + +fn parse_tree(mut bytes: &[u8]) -> Result, Report> { + let mut names = BTreeSet::new(); + let mut previous = None::>; + let mut entries = Vec::new(); + while !bytes.is_empty() { + let space = bytes + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| material_error("malformed Git tree mode"))?; + let mode = &bytes[..space]; + let tree = match mode { + b"40000" => true, + b"100644" | b"100755" => false, + _ => { + return Err(material_error( + "Git dependency symlinks, submodules and noncanonical modes are unreviewed", + )); + } + }; + let rest = &bytes[space + 1..]; + let nul = rest + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| material_error("malformed Git tree name"))?; + let name = &rest[..nul]; + if name.is_empty() + || !name.is_ascii() + || name + .iter() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'/' | b'\\' | b':')) + || matches!(name, b"." | b"..") + || name.eq_ignore_ascii_case(b".git") + || name.ends_with(b".") + || name.ends_with(b" ") + || name.len() > 255 + { + return Err(material_error("unsafe Git dependency tree name")); + } + let folded: Vec<_> = name.iter().map(u8::to_ascii_lowercase).collect(); + if !names.insert(folded) { + return Err(material_error( + "Git dependency tree aliases or repeats a name", + )); + } + let mut key = name.to_vec(); + if tree { + key.push(b'/'); + } + if previous.as_ref().is_some_and(|previous| *previous >= key) { + return Err(material_error("Git dependency tree order is noncanonical")); + } + previous = Some(key); + let raw_oid = rest + .get(nul + 1..nul + 21) + .ok_or_else(|| material_error("truncated Git tree identity"))?; + entries.push(Entry { + tree, + oid: hex::encode(raw_oid), + name_length: name.len(), + }); + if entries.len() > TREE_ENTRIES { + return Err(material_error("Git tree exceeds entry limit")); + } + bytes = &rest[nul + 21..]; + } + Ok(entries) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/git/input.rs b/crates/trusted-server-release/src/materials/git/input.rs new file mode 100644 index 0000000000..149973f16e --- /dev/null +++ b/crates/trusted-server-release/src/materials/git/input.rs @@ -0,0 +1,260 @@ +//! Object-only copies anchored to held descriptors, never input Git config. + +use std::collections::BTreeSet; +use std::ffi::{CStr, CString}; +use std::fs::{self, File, Metadata}; +use std::io::{Read as _, Write as _}; +use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::MetadataExt as _; +use std::path::{Component, Path}; + +use error_stack::Report; + +use super::{GitCollection, material_error}; +use crate::error::ReleaseError; + +const RAW_LIMIT: u64 = 512 * 1024 * 1024; +const ENTRY_LIMIT: usize = 100_000; + +fn stamp(metadata: &Metadata) -> (u64, u64, u64, u64, i64, i64, i64, i64) { + ( + metadata.dev(), + metadata.ino(), + metadata.nlink(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + ) +} + +/// Opens each ancestor without following a symlink or accepting relative syntax. +fn anchor(path: &Path) -> Result> { + if !path.is_absolute() || path.as_os_str().as_bytes().contains(&0) { + return Err(material_error( + "Git object root must be an absolute canonical path", + )); + } + let mut directory = File::open("/").map_err(material_error)?; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + directory = open_at(&directory, name.as_bytes(), true)?; + } + _ => return Err(material_error("noncanonical Git object root")), + } + } + Ok(directory) +} + +fn open_at(parent: &File, name: &[u8], directory: bool) -> Result> { + let name = CString::new(name).map_err(material_error)?; + let flags = libc::O_RDONLY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC + | if directory { libc::O_DIRECTORY } else { 0 }; + // The parent descriptor stays alive throughout openat, and name is NUL terminated. + let descriptor = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + return Err(material_error(std::io::Error::last_os_error())); + } + // A successful openat transfers one fresh descriptor to this File. + Ok(unsafe { File::from_raw_fd(descriptor) }) +} + +struct Directory(*mut libc::DIR); + +impl Drop for Directory { + fn drop(&mut self) { + // fdopendir returned this stream, which is closed exactly once. + unsafe { libc::closedir(self.0) }; + } +} + +#[cfg(target_os = "macos")] +fn errno() -> *mut libc::c_int { + // libc exposes the calling thread's errno location. + unsafe { libc::__error() } +} + +#[cfg(target_os = "linux")] +fn errno() -> *mut libc::c_int { + // libc exposes the calling thread's errno location. + unsafe { libc::__errno_location() } +} + +fn names( + directory: &File, + collection: &GitCollection<'_>, +) -> Result, Report> { + // A duplicate keeps fdopendir ownership separate from the held anchor. + let descriptor = unsafe { libc::fcntl(directory.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if descriptor < 0 { + return Err(material_error(std::io::Error::last_os_error())); + } + // A successful fcntl returns a fresh owned descriptor. + let owned = unsafe { OwnedFd::from_raw_fd(descriptor) }; + // fdopendir takes ownership only on success. + let stream = unsafe { libc::fdopendir(owned.as_raw_fd()) }; + if stream.is_null() { + return Err(material_error(std::io::Error::last_os_error())); + } + std::mem::forget(owned); + let stream = Directory(stream); + let mut output = Vec::new(); + let mut seen = BTreeSet::new(); + loop { + collection.check()?; + // Reset errno before readdir so EOF can be distinguished from failure. + unsafe { *errno() = 0 }; + // The stream remains owned until all returned name bytes are copied. + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + // readdir sets errno on error and leaves the cleared value at EOF. + if unsafe { *errno() } != 0 { + return Err(material_error(std::io::Error::last_os_error())); + } + break; + } + // readdir supplies a NUL-terminated d_name valid until its next call. + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if matches!(name, b"." | b"..") { + continue; + } + let name = std::str::from_utf8(name) + .map_err(material_error)? + .to_owned(); + if !seen.insert(name.clone()) || output.len() >= ENTRY_LIMIT { + return Err(material_error( + "Git object directory repeats names or exceeds entry limit", + )); + } + output.push(name); + } + output.sort(); + Ok(output) +} + +fn hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +struct CopyBudget { + bytes: u64, + entries: usize, +} + +fn copy_file( + parent: &File, + name: &str, + destination: &Path, + budget: &mut CopyBudget, + collection: &GitCollection<'_>, +) -> Result<(), Report> { + collection.check()?; + let mut input = open_at(parent, name.as_bytes(), false)?; + let before = input.metadata().map_err(material_error)?; + if !before.is_file() || before.nlink() != 1 || before.len() > RAW_LIMIT - budget.bytes { + return Err(material_error( + "Git object input must be a bounded regular single-link file", + )); + } + budget.entries += 1; + if budget.entries > ENTRY_LIMIT { + return Err(material_error("Git object input exceeds entry limit")); + } + let mut output = File::create_new(destination).map_err(material_error)?; + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + collection.check()?; + let count = input.read(&mut buffer).map_err(material_error)?; + if count == 0 { + break; + } + copied += count as u64; + if copied > before.len() { + return Err(material_error("Git object input grew")); + } + output.write_all(&buffer[..count]).map_err(material_error)?; + } + if copied != before.len() || stamp(&before) != stamp(&input.metadata().map_err(material_error)?) + { + return Err(material_error("Git object input changed while copying")); + } + budget.bytes += copied; + Ok(()) +} + +/// Copies only canonical loose and pack objects under independent raw limits. +pub(super) fn copy( + input: &Path, + destination: &Path, + collection: &GitCollection<'_>, +) -> Result<(), Report> { + collection.check()?; + let root = anchor(input)?; + let before = root.metadata().map_err(material_error)?; + let mut budget = CopyBudget { + bytes: 0, + entries: 0, + }; + for name in names(&root, collection)? { + if !(hex(&name, 2) || name == "pack" || name == "info") { + return Err(material_error("unsupported Git object input entry")); + } + let directory = open_at(&root, name.as_bytes(), true)?; + let directory_before = directory.metadata().map_err(material_error)?; + let children = names(&directory, collection)?; + fs::create_dir(destination.join(&name)).map_err(material_error)?; + let mut pack = BTreeSet::new(); + let mut index = BTreeSet::new(); + for child in children { + if name == "info" { + return Err(material_error("Git object info/indirection is forbidden")); + } + if name == "pack" { + let (base, extension) = child + .rsplit_once('.') + .ok_or_else(|| material_error("noncanonical Git pack name"))?; + if !base.strip_prefix("pack-").is_some_and(|oid| hex(oid, 40)) + || !matches!(extension, "pack" | "idx" | "rev") + { + return Err(material_error("unsupported Git pack entry")); + } + if extension == "pack" { + pack.insert(base.to_owned()); + } + if extension == "idx" { + index.insert(base.to_owned()); + } + } else if !hex(&child, 38) { + return Err(material_error("noncanonical loose Git object name")); + } + copy_file( + &directory, + &child, + &destination.join(&name).join(&child), + &mut budget, + collection, + )?; + } + if name == "pack" && pack != index { + return Err(material_error("Git pack and index sets differ")); + } + if stamp(&directory_before) != stamp(&directory.metadata().map_err(material_error)?) { + return Err(material_error("Git object directory changed while copying")); + } + } + if stamp(&before) != stamp(&root.metadata().map_err(material_error)?) { + return Err(material_error("Git object root changed while copying")); + } + collection.check() +} diff --git a/crates/trusted-server-release/src/materials/git/tests.rs b/crates/trusted-server-release/src/materials/git/tests.rs new file mode 100644 index 0000000000..aab4e0bae6 --- /dev/null +++ b/crates/trusted-server-release/src/materials/git/tests.rs @@ -0,0 +1,845 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::Write as _; +use std::os::unix::fs::MetadataExt as _; +use std::path::Path; +use std::time::{Duration, Instant}; + +use flate2::Compression; +use flate2::write::ZlibEncoder; + +use super::*; +use crate::git_objects::checked_sha1; +use crate::materials::locked::CargoMaterials; + +const SELECTOR: &str = "git+https://example.com/fictional?rev=locked"; + +fn temporary() -> tempfile::TempDir { + let root = fs::canonicalize(std::env::temp_dir()).expect("should resolve temporary parent"); + tempfile::tempdir_in(root).expect("should create canonical temporary directory") +} + +fn object(root: &Path, kind: &str, bytes: &[u8]) -> String { + let header = format!("{kind} {}\0", bytes.len()); + let oid = checked_sha1(&[header.as_bytes(), bytes]).expect("should hash fixture object"); + let path = root.join(&oid[..2]).join(&oid[2..]); + fs::create_dir_all(path.parent().expect("should have object parent")) + .expect("should create object directory"); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(header.as_bytes()) + .expect("should encode header"); + encoder.write_all(bytes).expect("should encode body"); + fs::write(path, encoder.finish().expect("should finish object")) + .expect("should write fixture object"); + oid +} + +fn fixture(root: &Path) -> String { + let blob = object(root, "blob", b"fictional dependency\n"); + let mut tree = b"100644 Cargo.toml\0".to_vec(); + tree.extend(hex::decode(blob).expect("should decode object id")); + let tree = object(root, "tree", &tree); + object(root, "commit", format!("tree {tree}\nauthor Fictional 1 +0000\ncommitter Fictional 1 +0000\n\nfictional commit\n").as_bytes()) +} + +fn locked(revision: &str) -> LockedMaterials { + LockedMaterials { + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + npm_lock_sha256: "c".repeat(64), + cargo: CargoMaterials { + registry: BTreeMap::new(), + git: BTreeSet::from([(SELECTOR.to_owned(), revision.to_owned())]), + }, + npm: BTreeMap::new(), + } +} + +#[test] +fn complete_loose_snapshot_is_verified_from_locked_commit() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let snapshot = locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should select local Git fixture") + .verify(SELECTOR, &revision, input.path()) + .expect("should verify all selected objects"); + assert_eq!( + snapshot.source_commit(), + "a".repeat(40), + "should bind source" + ); + assert_eq!( + snapshot.cargo_lock_sha256(), + "b".repeat(64), + "should bind raw lockfile" + ); + assert_eq!(snapshot.selector(), SELECTOR, "should bind exact selector"); + assert_eq!(snapshot.revision(), revision, "should bind locked commit"); + assert_eq!( + snapshot.root_tree().len(), + 40, + "should retain root identity" + ); + assert_eq!(snapshot.object_count(), 3, "should retain the full closure"); + assert_ne!( + snapshot.object_store(), + input.path(), + "should own a new store" + ); +} + +fn collection(locked: &LockedMaterials) -> GitCollection<'_> { + locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(60), + &Cancellation::default(), + ) + .expect("should select Git for a bounded local fixture") +} + +fn commit(root: &Path, tree: &str) -> String { + object(root, "commit", format!("tree {tree}\nauthor Fictional 1 +0000\ncommitter Fictional 1 +0000\n\nfixture\n").as_bytes()) +} + +fn tree(root: &Path, entries: &[(&[u8], &[u8], &str)]) -> String { + let mut bytes = Vec::new(); + for (mode, name, oid) in entries { + bytes.extend_from_slice(mode); + bytes.push(b' '); + bytes.extend_from_slice(name); + bytes.push(0); + bytes.extend(hex::decode(oid).expect("should decode fixture identity")); + } + object(root, "tree", &bytes) +} + +#[test] +fn unselected_objects_are_not_retained_and_input_replacement_is_irrelevant() { + let input = temporary(); + let revision = fixture(input.path()); + let extra = object(input.path(), "blob", b"unselected material"); + let locked = locked(&revision); + let snapshot = collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .expect("should verify selected closure"); + assert!( + !snapshot + .object_store() + .join(&extra[..2]) + .join(&extra[2..]) + .exists(), + "should exclude unselected objects" + ); + let retained = snapshot + .object_store() + .join(&revision[..2]) + .join(&revision[2..]); + let before = fs::read(&retained).expect("should read retained commit"); + fs::write( + input.path().join(&revision[..2]).join(&revision[2..]), + b"changed input", + ) + .expect("should replace untrusted input"); + assert_eq!( + fs::read(retained).expect("should read independent retained commit"), + before, + "should own an independent verified snapshot" + ); +} + +#[test] +fn caller_cannot_choose_an_unlocked_selector_or_revision() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let collection = collection(&locked); + assert!( + collection + .verify("git+https://example.com/other", &revision, input.path()) + .is_err(), + "should reject an unlocked selector" + ); + assert!( + collection + .verify(SELECTOR, &"f".repeat(40), input.path()) + .is_err(), + "should reject an unlocked revision" + ); +} + +#[test] +fn locked_revision_must_select_a_commit_object() { + let input = temporary(); + let revision = object(input.path(), "blob", b"not a commit"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject an object of the wrong type" + ); +} + +#[test] +fn changed_blob_bytes_fail_the_selected_object_identity() { + let input = temporary(); + let revision = fixture(input.path()); + let blob = object(input.path(), "blob", b"fictional dependency\n"); + let different = object(input.path(), "blob", b"changed dependency\n"); + fs::copy( + input.path().join(&different[..2]).join(&different[2..]), + input.path().join(&blob[..2]).join(&blob[2..]), + ) + .expect("should replace blob bytes under old name"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should rehash blob content independently of its filename" + ); +} + +#[test] +fn unsafe_entry_outside_cargo_package_root_still_rejects() { + let input = temporary(); + let blob = object(input.path(), "blob", b"fictional dependency"); + let unrelated = tree(input.path(), &[(b"120000", b"unreviewed-link", &blob)]); + let root = tree( + input.path(), + &[ + (b"100644", b"Cargo.toml", &blob), + (b"40000", b"unrelated", &unrelated), + ], + ); + let revision = commit(input.path(), &root); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should walk every selected subtree" + ); +} + +#[test] +fn missing_blob_in_unrelated_subtree_still_rejects() { + let input = temporary(); + let blob = object(input.path(), "blob", b"fictional dependency"); + let unrelated = tree(input.path(), &[(b"100644", b"missing", &"d".repeat(40))]); + let root = tree( + input.path(), + &[ + (b"100644", b"Cargo.toml", &blob), + (b"40000", b"unrelated", &unrelated), + ], + ); + let revision = commit(input.path(), &root); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should authenticate blobs outside selected crate roots" + ); +} + +#[test] +fn unsafe_or_noncanonical_tree_entries_reject() { + let oid = "d".repeat(40); + for (mode, name) in [ + (&b"120000"[..], &b"link"[..]), + (b"160000", b"submodule"), + (b"100664", b"mode"), + (b"100644", b".."), + (b"100644", b".Git"), + (b"100644", b"back\\slash"), + (b"100644", b"line\nbreak"), + (b"100644", b"a/b"), + (b"100644", b"trailing."), + (b"100644", b"trailing "), + (b"100644", b"colon:name"), + (b"100644", b"\xc3\xa9"), + ] { + let mut bytes = mode.to_vec(); + bytes.push(b' '); + bytes.extend_from_slice(name); + bytes.push(0); + bytes.extend(hex::decode(&oid).expect("should decode fixture id")); + assert!( + parse_tree(&bytes).is_err(), + "should reject unsafe tree entry" + ); + } + for names in [["A", "a"], ["a", "a"], ["z", "a"]] { + let mut bytes = Vec::new(); + for name in names { + bytes.extend_from_slice(format!("100644 {name}\0").as_bytes()); + bytes.extend(hex::decode(&oid).expect("should decode fixture id")); + } + assert!( + parse_tree(&bytes).is_err(), + "should reject aliases, duplicate names and noncanonical ordering" + ); + } +} + +#[test] +fn input_configuration_and_alternates_never_supply_objects() { + let input = temporary(); + let revision = fixture(input.path()); + fs::create_dir(input.path().join("info")).expect("should create object info fixture"); + fs::write( + input.path().join("info/alternates"), + b"/fictional/untrusted\n", + ) + .expect("should write forbidden indirection"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject object-store indirection" + ); + fs::remove_file(input.path().join("info/alternates")) + .expect("should remove forbidden indirection"); + fs::write( + input.path().join("config"), + b"[core]\nrepositoryformatversion=999\n", + ) + .expect("should write forbidden configuration"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject configuration inside object input" + ); +} + +#[test] +fn input_ancestor_symlink_and_object_hardlink_are_rejected() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + let locked = locked(&revision); + let alias = parent.path().join("alias"); + std::os::unix::fs::symlink(&input, &alias).expect("should create ancestor alias"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &alias) + .is_err(), + "should reject aliased input ancestors" + ); + let original = input.join(&revision[..2]).join(&revision[2..]); + fs::hard_link(&original, parent.path().join("hardlink")) + .expect("should create outside hardlink"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &input) + .is_err(), + "should reject hard-linked input files" + ); +} + +#[test] +fn cancelled_and_expired_collections_fail_closed() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let cancellation = Cancellation::default(); + let collection = locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(60), + &cancellation, + ) + .expect("should create live collection"); + cancellation.cancel(); + assert!( + collection + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject cancelled work" + ); + assert!( + locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() - Duration::from_secs(1), + &Cancellation::default() + ) + .is_err(), + "should reject an expired collection" + ); +} + +fn pack(input: &Path, revision: &str) { + let repository = input.parent().expect("should have repository parent"); + fs::create_dir(repository.join("refs")).expect("should create bare refs"); + fs::write(repository.join("HEAD"), b"ref: refs/heads/unused\n") + .expect("should write bare HEAD"); + fs::write( + repository.join("config"), + b"[core]\nbare=true\nrepositoryformatversion=0\n", + ) + .expect("should write bare config"); + fs::create_dir(input.join("pack")).expect("should create pack directory"); + let output = BoundedCommand::new( + Path::new("/usr/bin/git"), + repository, + ProcessLimits { + wall: Duration::from_secs(30), + stdout_max: 1024, + stderr_max: 4096, + }, + ) + .expect("should describe real Git fixture") + .arguments([ + OsString::from("--git-dir"), + repository.as_os_str().to_owned(), + OsString::from("pack-objects"), + OsString::from("--revs"), + input.join("pack/pack").into_os_string(), + ]) + .environment("HOME", repository) + .expect("should pin HOME") + .environment("GIT_CONFIG_NOSYSTEM", "1") + .expect("should disable system config") + .stdin_bytes(format!("{revision}\n").into_bytes()) + .run() + .and_then(ProcessResult::require_success) + .expect("should create real Git pack and index"); + assert_eq!(output.len(), 41, "should emit one pack identity"); + for entry in fs::read_dir(input).expect("should read fixture object root") { + let entry = entry.expect("should read object entry"); + if entry.file_name().len() == 2 { + fs::remove_dir_all(entry.path()).expect("should remove loose originals"); + } + } +} + +#[test] +fn real_packed_objects_are_verified_and_reemitted_as_selected_loose_objects() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + pack(&input, &revision); + let locked = locked(&revision); + let snapshot = collection(&locked) + .verify(SELECTOR, &revision, &input) + .expect("should independently verify real packed objects"); + assert_eq!( + snapshot.object_count(), + 3, + "should retain all selected objects" + ); + assert!( + !snapshot.object_store().join("pack").exists(), + "should not retain incoming pack extras" + ); +} + +#[test] +fn malformed_packed_material_fails_closed() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + pack(&input, &revision); + let index = fs::read_dir(input.join("pack")) + .expect("should list packs") + .map(|entry| entry.expect("should read pack entry").path()) + .find(|path| path.extension().is_some_and(|extension| extension == "idx")) + .expect("should have pack index"); + fs::remove_file(&index).expect("should remove read-only fixture index"); + fs::write(index, b"truncated index").expect("should corrupt pack index"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &input) + .is_err(), + "should reject malformed packed material" + ); +} + +#[test] +fn decoded_budget_is_shared_across_separate_dependency_verifications() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let collection = collection(&locked); + collection + .verify(SELECTOR, &revision, input.path()) + .expect("should verify first snapshot"); + collection.objects.set_budget_for_tests(1); + assert!( + collection + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should preserve collection budget instead of resetting per repository" + ); +} + +#[test] +fn object_symlink_and_fifo_are_rejected_without_blocking() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let path = input.path().join(&revision[..2]).join(&revision[2..]); + fs::remove_file(&path).expect("should remove selected loose object"); + std::os::unix::fs::symlink("/fictional/untrusted", &path) + .expect("should create dangling object alias"); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject symlink input objects" + ); + fs::remove_file(&path).expect("should remove object alias"); + let raw = std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str())) + .expect("should encode FIFO path"); + // The NUL-terminated path stays alive while mkfifo creates a test-only inode. + assert_eq!( + unsafe { libc::mkfifo(raw.as_ptr(), 0o600) }, + 0, + "should create FIFO fixture" + ); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject FIFO objects without waiting for a writer" + ); +} + +#[test] +fn changed_selected_git_is_rejected_before_execution() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let tool_root = temporary(); + let program = tool_root.path().join("git"); + fs::copy("/usr/bin/git", &program).expect("should copy selected executable fixture"); + let collection = locked + .git_collection( + &program, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should select fixture executable"); + fs::write(&program, b"changed selected executable").expect("should mutate executable fixture"); + assert!( + collection + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should rejoin selected executable bytes before spawning" + ); +} + +#[test] +fn deeply_nested_selected_tree_rejects_under_depth_bound() { + let input = temporary(); + let mut root = object(input.path(), "tree", b""); + for _ in 0..TREE_DEPTH { + root = tree(input.path(), &[(b"40000", b"d", &root)]); + } + let revision = commit(input.path(), &root); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject a selected tree deeper than the fixed bound" + ); +} + +#[test] +fn truncated_pack_body_is_rejected() { + let parent = temporary(); + let input = parent.path().join("objects"); + fs::create_dir(&input).expect("should create object root"); + let revision = fixture(&input); + pack(&input, &revision); + let body = fs::read_dir(input.join("pack")) + .expect("should list packs") + .map(|entry| entry.expect("should read pack entry").path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "pack") + }) + .expect("should have pack body"); + fs::remove_file(&body).expect("should remove read-only fixture pack"); + fs::write(body, b"PACK").expect("should truncate pack body"); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, &input) + .is_err(), + "should reject truncated packed material" + ); +} + +#[test] +fn duplicate_root_tree_header_is_not_accepted() { + let input = temporary(); + let tree = object(input.path(), "tree", b""); + let revision = object( + input.path(), + "commit", + format!("tree {tree}\ntree {tree}\n\nfixture\n").as_bytes(), + ); + let locked = locked(&revision); + assert!( + collection(&locked) + .verify(SELECTOR, &revision, input.path()) + .is_err(), + "should reject an ambiguous root-tree binding" + ); +} + +#[test] +fn fifo_replacement_of_selected_git_does_not_wait_for_a_writer() { + let input = temporary(); + let revision = fixture(input.path()); + let locked = locked(&revision); + let tools = temporary(); + let program = tools.path().join("git"); + fs::copy("/usr/bin/git", &program).expect("should copy executable fixture"); + let collection = locked + .git_collection( + &program, + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should select copied executable"); + fs::remove_file(&program).expect("should remove selected executable"); + let raw = std::ffi::CString::new(std::os::unix::ffi::OsStrExt::as_bytes(program.as_os_str())) + .expect("should encode FIFO path"); + // The test replaces an owned executable path with a test-only FIFO. + assert_eq!( + unsafe { libc::mkfifo(raw.as_ptr(), 0o700) }, + 0, + "should create FIFO replacement" + ); + std::thread::scope(|scope| { + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = scope.spawn(move || { + let rejected = collection + .verify(SELECTOR, &revision, input.path()) + .is_err(); + sender + .send(rejected) + .expect("should report the FIFO verification outcome"); + }); + let outcome = receiver.recv_timeout(Duration::from_secs(10)); + // A read/write nonblocking descriptor also releases a regressed reader + // that reaches open after the timeout, without relying on timer ordering. + let rescued = if outcome.is_err() { + Some( + std::fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&program) + .expect("should release a regressed blocking FIFO reader"), + ) + } else { + None + }; + worker + .join() + .expect("should join the FIFO verification worker"); + let rejected = outcome + .unwrap_or_else(|_| receiver.recv().expect("should receive the rescued outcome")); + assert!(rejected, "should reject FIFO executable replacement"); + assert!( + rescued.is_none(), + "should reject without requiring a FIFO writer" + ); + }); +} + +fn independent_object_copy(input: &Path, output: &Path) { + let mut total = 0_u64; + let mut count = 0_usize; + for entry in fs::read_dir(input).expect("should list original object data") { + let entry = entry.expect("should read original object directory"); + assert!( + fs::symlink_metadata(entry.path()) + .expect("should inspect original object directory") + .is_dir(), + "should copy object directories only" + ); + let name = entry.file_name(); + let name = name.to_str().expect("should use canonical object names"); + assert!( + name == "pack" + || name == "info" + || super::lower_oid(format!("{name}{}", "0".repeat(38)).as_bytes()), + "should reject unrelated object-store entries" + ); + fs::create_dir(output.join(name)).expect("should create independent object directory"); + for member in fs::read_dir(entry.path()).expect("should list object directory") { + let member = member.expect("should read original object member"); + let metadata = + fs::symlink_metadata(member.path()).expect("should inspect original object member"); + assert!(metadata.is_file(), "should not follow original aliases"); + total += metadata.len(); + count += 1; + assert!( + total <= 512 * 1024 * 1024 && count <= 100_000, + "should bound diagnostic input copying before original verification" + ); + let destination = output.join(name).join(member.file_name()); + fs::copy(member.path(), &destination).expect("should independently copy object bytes"); + assert_eq!( + fs::metadata(destination) + .expect("should inspect private copy") + .nlink(), + 1, + "should detach ambient cache hard links" + ); + } + } +} + +#[test] +#[ignore = "requires explicitly selected current-lock Git object data; no acquisition qualification"] +fn actual_current_lock_git_material_closure() { + let input = std::env::var_os("TS_RELEASE_CARGO_GIT_FIXTURE") + .map(PathBuf::from) + .expect("should explicitly select original Git object data"); + let bytes = fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.lock")) + .expect("should read the actual current source lockfile"); + let cargo = crate::materials::locked::parse_cargo(&bytes) + .expect("should parse all current locked identities"); + assert_eq!( + cargo.git.len(), + 1, + "should explicitly review the current unique Git material" + ); + let (selector, revision) = cargo + .git + .iter() + .next() + .expect("should select locked Git material") + .clone(); + let locked = LockedMaterials { + // This diagnostic fixture supplies no official source approval. + source_commit: "a".repeat(40), + cargo_lock_sha256: hex::encode(Sha256::digest(&bytes)), + npm_lock_sha256: "b".repeat(64), + cargo, + npm: BTreeMap::new(), + }; + let copied = temporary(); + independent_object_copy(&input, copied.path()); + let snapshot = locked + .git_collection( + Path::new("/usr/bin/git"), + Instant::now() + Duration::from_secs(180), + &Cancellation::default(), + ) + .expect("should select bounded local diagnostic Git") + .verify(&selector, &revision, copied.path()) + .expect("should verify the actual current locked whole-tree material"); + assert_eq!( + snapshot.revision(), + revision, + "should retain the exact current locked revision" + ); + assert_eq!( + snapshot.selector(), + selector, + "should retain the exact current locked selector" + ); + assert_eq!( + snapshot.object_count(), + 557, + "should cover the independently measured current whole closure" + ); + assert_eq!( + snapshot.cargo_lock_sha256(), + hex::encode(Sha256::digest(&bytes)), + "should bind exact current raw lock bytes" + ); + let mut total = 0_u64; + let mut largest = 0_u64; + let mut blob_path = None; + for directory in + fs::read_dir(snapshot.object_store()).expect("should inspect retained object directories") + { + for member in fs::read_dir(directory.expect("should read retained directory").path()) + .expect("should inspect retained objects") + { + let path = member.expect("should read retained object").path(); + let decoder = flate2::read::ZlibDecoder::new( + fs::File::open(&path).expect("should open verified loose object"), + ); + let mut decoded = Vec::new(); + decoder + .take(32 * 1024 * 1024 + 128) + .read_to_end(&mut decoded) + .expect("should decode bounded verified loose object"); + let end = decoded + .iter() + .position(|byte| *byte == 0) + .expect("should retain canonical object header"); + let size = (decoded.len() - end - 1) as u64; + assert!( + size <= 32 * 1024 * 1024, + "should preserve the Cargo per-object bound" + ); + total += size; + largest = largest.max(size); + if decoded.starts_with(b"blob ") { + blob_path = Some(path); + } + } + } + assert_eq!( + total, 6_334_558, + "should retain exactly the independently measured decoded closure bytes" + ); + assert_eq!( + largest, 636_919, + "should retain the independently measured largest object" + ); + assert!( + total <= 512 * 1024 * 1024, + "should remain within the shared Cargo budget" + ); + let altered = temporary(); + independent_object_copy(snapshot.object_store(), altered.path()); + let blob = blob_path.expect("should retain at least one real blob"); + let relative = blob + .strip_prefix(snapshot.object_store()) + .expect("should select a private retained object"); + fs::write(altered.path().join(relative), b"corrupt object") + .expect("should corrupt only independently copied data"); + assert!( + collection(&locked) + .verify(&selector, &revision, altered.path()) + .is_err(), + "should reject changed actual selected object bytes" + ); + let root = snapshot.root_tree(); + fs::remove_file(altered.path().join(&root[..2]).join(&root[2..])) + .expect("should remove the actual selected root tree from private data"); + assert!( + collection(&locked) + .verify(&selector, &revision, altered.path()) + .is_err(), + "should reject missing actual selected root tree" + ); +} diff --git a/crates/trusted-server-release/src/materials/manifest.rs b/crates/trusted-server-release/src/materials/manifest.rs index e5060ef9be..eaacb0c538 100644 --- a/crates/trusted-server-release/src/materials/manifest.rs +++ b/crates/trusted-server-release/src/materials/manifest.rs @@ -76,6 +76,37 @@ pub(crate) struct ReviewedNpmManifest { } impl ReviewedNpmManifest { + /// Returns exact original member facts after rechecking retained extraction. + /// + /// # Errors + /// + /// Returns an error for a stale extraction or absent member. + pub(super) fn member_facts( + &self, + relative: &str, + ) -> Result<(String, u64, u32), Report> { + self.extracted.member_facts(relative) + } + /// Rechecks all archive members before preparation consumes this review. + /// + /// # Errors + /// + /// Returns an error if the retained extraction changed or expired. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + self.extracted.verify_unchanged() + } + + /// Copies the authenticated compressed original for a new private cache. + /// + /// # Errors + /// + /// Returns an error if retained original bytes changed or copying fails. + pub(super) fn copy_original( + &mut self, + destination: &mut impl std::io::Write, + ) -> Result<(), Report> { + self.extracted.copy_original(destination) + } /// Returns declarations only, without claiming any hook was executed. pub(crate) fn hooks(&self) -> &[DeclaredHook] { &self.hooks diff --git a/crates/trusted-server-release/src/materials/npm_bundle.rs b/crates/trusted-server-release/src/materials/npm_bundle.rs new file mode 100644 index 0000000000..a7f38826ff --- /dev/null +++ b/crates/trusted-server-release/src/materials/npm_bundle.rs @@ -0,0 +1,246 @@ +//! Original-only inputs shared read-only by fresh npm installations. + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; +use std::fs::File; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; +use tempfile::TempDir; + +use super::{LockedMaterials, ReviewedNpmManifest, material_error}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +/// Retained originals and manifest reviews, without installed build authority. +pub(crate) struct PreparedNpmOriginals { + directory: TempDir, + originals: BTreeMap, + reviews: BTreeMap, + source_commit: String, + lock_sha256: String, + deadline: Instant, + cancellation: Cancellation, +} + +impl PreparedNpmOriginals { + /// Measures the selected Linux esbuild executable from its original archive. + /// + /// # Errors + /// + /// Returns an error for unsupported architectures, absent originals or a + /// stale extraction. These are original facts, not installed observations. + pub(crate) fn native_esbuild( + &self, + architecture: &str, + ) -> Result> { + let architecture = match architecture { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + }; + if !matches!(architecture, "x64" | "arm64") { + return Err(material_error( + "unsupported Linux JS preparation architecture", + )); + } + let location = format!("node_modules/@esbuild/linux-{architecture}"); + let review = self + .reviews + .get(&location) + .ok_or_else(|| material_error("required native esbuild original is absent"))?; + let (sha256, size, mode) = review.member_facts("bin/esbuild")?; + Ok(serde_json::json!({"lock_path":location,"sha256":sha256,"size":size,"mode":mode})) + } + /// Returns exact reviewed declarations for joining real lifecycle events. + pub(crate) fn declared_hooks(&self) -> BTreeMap<(String, String), String> { + self.reviews + .iter() + .flat_map(|(location, review)| { + review.hooks().iter().map(|hook| { + ( + (location.clone(), hook.event().to_owned()), + hook.script_sha256().to_owned(), + ) + }) + }) + .collect() + } + /// Returns the private original archive root for a read-only mount. + pub(crate) fn root(&self) -> &Path { + self.directory.path() + } + + /// Returns sorted authenticated archive paths, never extracted replacements. + pub(crate) fn archive_paths(&self) -> Vec { + self.originals + .keys() + .map(|name| self.root().join(name)) + .collect() + } + + /// Returns the approved source and raw lockfile bindings. + pub(crate) fn source_binding(&self) -> (&str, &str) { + (&self.source_commit, &self.lock_sha256) + } + + /// Rechecks every retained original and reviewed extraction before use. + /// + /// # Errors + /// + /// Returns an error for changed originals, stale extractions or expiration. + pub(crate) fn verify_unchanged(&self) -> Result<(), Report> { + let check = || check_attempt(self.deadline, &self.cancellation); + check()?; + for (name, expected) in &self.originals { + let raw = hash_bounded_with_check(&self.root().join(name), 64 * 1024 * 1024, &check)?; + if raw.sha256 != *expected { + return Err(material_error("retained npm original changed")); + } + } + for review in self.reviews.values() { + check()?; + review.verify_unchanged()?; + } + check() + } +} + +impl LockedMaterials { + /// Authenticates and reviews all locked npm originals into private state. + /// + /// # Errors + /// + /// Returns an error for any absent or changed archive, unsafe extraction, + /// unreviewed manifest, canceled attempt or failed private write. Optional + /// platform packages receive the same original checks before npm selects + /// which ones to install. Input filenames come only from locked SHA-512. + pub(crate) fn prepare_npm_originals( + &self, + inputs: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + let directory = tempfile::tempdir().map_err(material_error)?; + let mut originals = BTreeMap::new(); + let mut reviews = BTreeMap::new(); + for (location, locked) in &self.npm { + check_attempt(deadline, cancellation)?; + let name = format!("{}.tgz", hex::encode(locked.sha512)); + let verified = + self.verify_npm(location, &inputs.join(&name), deadline, cancellation)?; + let expected_sha256 = verified.sha256().to_owned(); + let extracted = self.extract_npm(location, verified)?; + let mut reviewed = self.review_npm(location, extracted)?; + reviewed.verify_unchanged()?; + if let Entry::Vacant(entry) = originals.entry(name) { + let name = entry.key(); + let path = directory.path().join(name); + let mut file = File::create_new(&path).map_err(material_error)?; + reviewed.copy_original(&mut file)?; + drop(file); + let measured = hash_bounded_with_check(&path, 64 * 1024 * 1024, &|| { + check_attempt(deadline, cancellation) + })?; + if measured.sha256 != expected_sha256 { + return Err(material_error( + "copied npm original differs from authenticated bytes", + )); + } + entry.insert(expected_sha256); + } + reviews.insert(location.to_owned(), reviewed); + } + let prepared = PreparedNpmOriginals { + directory, + originals, + reviews, + source_commit: self.source_commit.clone(), + lock_sha256: self.npm_lock_sha256.clone(), + deadline, + cancellation: cancellation.clone(), + }; + prepared.verify_unchanged()?; + Ok(prepared) + } +} + +fn check_attempt( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error( + "npm original collection cancelled or timed out", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::Duration; + + use sha2::{Digest as _, Sha256}; + + use super::*; + + fn fixture() -> PreparedNpmOriginals { + let directory = tempfile::tempdir().expect("should create retained originals"); + fs::write(directory.path().join("original.tgz"), b"original") + .expect("should retain fixture original"); + PreparedNpmOriginals { + directory, + originals: BTreeMap::from([( + "original.tgz".to_owned(), + hex::encode(Sha256::digest(b"original")), + )]), + reviews: BTreeMap::new(), + source_commit: "a".repeat(40), + lock_sha256: "b".repeat(64), + deadline: Instant::now() + Duration::from_secs(30), + cancellation: Cancellation::default(), + } + } + + #[test] + fn retained_original_rechecks_obey_attempt_controls() { + let prepared = fixture(); + prepared + .verify_unchanged() + .expect("should hash retained original"); + prepared.cancellation.cancel(); + assert!( + prepared.verify_unchanged().is_err(), + "should reject retained canceled attempts" + ); + let mut prepared = fixture(); + prepared.deadline = Instant::now(); + assert!( + prepared.verify_unchanged().is_err(), + "should reject retained expired attempts" + ); + } + + #[test] + fn retained_original_rechecks_keep_the_archive_bound_and_digest() { + let prepared = fixture(); + let path = prepared.archive_paths().remove(0); + fs::write(&path, b"tampered").expect("should change retained bytes"); + assert!( + prepared.verify_unchanged().is_err(), + "should reject changed retained digest" + ); + fs::File::create(path) + .expect("should create sparse oversized archive") + .set_len(64 * 1024 * 1024 + 1) + .expect("should exceed original archive limit"); + assert!( + prepared.verify_unchanged().is_err(), + "should reject oversized retained archives before allocation" + ); + } +} diff --git a/crates/trusted-server-release/src/preparation.rs b/crates/trusted-server-release/src/preparation.rs new file mode 100644 index 0000000000..3212d2ad20 --- /dev/null +++ b/crates/trusted-server-release/src/preparation.rs @@ -0,0 +1,755 @@ +//! Fresh, independently owned npm installation state and authenticated observations. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use error_stack::Report; +use hmac::{Hmac, Mac as _}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, DenialProof}; +use crate::documents::{Role, hash_bounded_with_check, parse_json}; +use crate::error::ReleaseError; +use crate::materials::{LockedMaterials, PreparedNpmOriginals, RootManifestReview}; +use crate::process::{Cancellation, ProcessLimits}; +use crate::source::{ApprovedSource, FreshSource}; + +pub(super) const LIB: &str = "crates/trusted-server-js/lib"; +const PREFIX: &[u8] = b"TS_RELEASE_NPM_OBSERVATION_V1 "; + +pub(super) fn preparation_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Material).attach(detail.to_string()) +} + +/// Authenticated and qualified JS tool selections are supplied by Task 3/8. +/// +/// None is qualified yet; ordinary CI fixtures use an explicit test constructor. +#[derive(Clone, Copy)] +pub(crate) enum QualifiedJsTools {} + +/// Fixed Node/npm/guard/probe layout, retained as a measured local candidate. +pub(crate) struct SelectedJsTools { + root: PathBuf, +} + +impl SelectedJsTools { + /// Accepts only a qualified selection; production construction stays closed. + pub(crate) fn qualified(_root: &Path, qualification: QualifiedJsTools) -> Self { + match qualification {} + } + + /// Selects ordinary CI tools without asserting release qualification. + /// + /// # Errors + /// + /// Returns an error for absent or unsupported candidate tool paths. + #[cfg(test)] + pub(crate) fn for_tests(root: &Path) -> Result> { + let root = fs::canonicalize(root).map_err(preparation_error)?; + for relative in [ + "bin/node", + "lib/node_modules/npm/bin/npm-cli.js", + "guard.node", + "probe", + ] { + hash_bounded_with_check(&root.join(relative), 512 * 1024 * 1024, &|| Ok(()))?; + } + Ok(Self { root }) + } + + pub(super) fn node(&self) -> PathBuf { + self.root.join("bin/node") + } + fn npm(&self) -> PathBuf { + self.root.join("lib/node_modules/npm/bin/npm-cli.js") + } + fn guard(&self) -> PathBuf { + self.root.join("guard.node") + } + + fn digest(&self, attempt: &JsAttempt) -> Result> { + let mut digest = Sha256::new(); + let mut total_bytes = 0_u64; + let mut pending = vec![self.root.clone()]; + let mut facts = BTreeMap::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).map_err(preparation_error)? { + attempt.check()?; + let entry = entry.map_err(preparation_error)?; + let path = entry.path(); + let relative = path + .strip_prefix(&self.root) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("tool path is not UTF-8"))? + .to_owned(); + let metadata = fs::symlink_metadata(&path).map_err(preparation_error)?; + let fact = if metadata.is_dir() { + pending.push(path.clone()); + format!("directory:{}", metadata.permissions().mode() & 0o7777) + } else if metadata.is_file() && metadata.nlink() == 1 { + let raw = + hash_bounded_with_check(&path, 512 * 1024 * 1024, &|| attempt.check())?; + total_bytes += raw.size; + if total_bytes > 2 * 1024 * 1024 * 1024 { + return Err(preparation_error( + "selected tool tree exceeds total byte limit", + )); + } + format!( + "file:{}:{}:{}", + metadata.permissions().mode() & 0o7777, + raw.size, + raw.sha256 + ) + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(preparation_error)?; + if target.is_absolute() + || !fs::canonicalize(&path) + .map_err(preparation_error)? + .starts_with(&self.root) + { + return Err(preparation_error( + "selected tool alias escapes its owned root", + )); + } + format!( + "alias:{}", + target + .to_str() + .ok_or_else(|| preparation_error("tool alias is not UTF-8"))? + ) + } else { + return Err(preparation_error( + "selected tool contains an unsafe file type or hardlink", + )); + }; + facts.insert(relative, fact); + if facts.len() > 100_000 { + return Err(preparation_error("selected tool tree exceeds file limit")); + } + } + } + for (relative, fact) in facts { + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(fact.as_bytes()); + digest.update([0]); + } + Ok(hex::encode(digest.finalize())) + } +} + +/// One shared bounded preparation attempt, independent of inherited environment. +#[derive(Clone)] +pub(crate) struct JsAttempt { + pub(super) deadline: Instant, + pub(super) cancellation: Cancellation, +} + +impl JsAttempt { + /// Creates a bounded attempt using an explicitly selected deadline and cancellation. + pub(crate) fn new(deadline: Instant, cancellation: Cancellation) -> Self { + Self { + deadline, + cancellation, + } + } + pub(super) fn check(&self) -> Result<(), Report> { + if self.cancellation.is_cancelled() || Instant::now() >= self.deadline { + return Err(preparation_error("JS preparation cancelled or timed out")); + } + Ok(()) + } +} + +/// One side has its own source, installation, cache, home, temporary and output roots. +pub(super) struct Side { + pub(super) source: FreshSource, + pub(super) state: tempfile::TempDir, + metadata: tempfile::TempDir, + pub(super) expected_tests: BTreeSet, +} + +impl Side { + fn new( + source: FreshSource, + module_ids: &[String], + attempt: &JsAttempt, + ) -> Result> { + let state = tempfile::tempdir().map_err(preparation_error)?; + let metadata = tempfile::tempdir().map_err(preparation_error)?; + for relative in ["cache", "home", "tmp", "output", "output/bundles"] { + fs::create_dir(state.path().join(relative)).map_err(preparation_error)?; + } + let context = + json!({"schema_version":1,"source_commit":source.commit(),"module_ids":module_ids}); + fs::write( + metadata.path().join("source-context.json"), + serde_json::to_vec(&context).map_err(preparation_error)?, + ) + .map_err(preparation_error)?; + let expected_tests = discover_tests(&source.root().join(LIB), attempt)?; + Ok(Self { + source, + state, + metadata, + expected_tests, + }) + } + pub(super) fn output(&self) -> PathBuf { + self.state.path().join("output") + } + pub(super) fn library(&self) -> PathBuf { + self.source.root().join(LIB) + } + pub(super) fn verify_source(&self) -> Result<(), Report> { + self.source + .verify_unchanged(&["crates/trusted-server-js/lib/node_modules"]) + } +} + +fn discover_tests( + root: &Path, + attempt: &JsAttempt, +) -> Result, Report> { + let mut pending = vec![root.join("test")]; + let mut tests = BTreeSet::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).map_err(preparation_error)? { + attempt.check()?; + let entry = entry.map_err(preparation_error)?; + let metadata = fs::symlink_metadata(entry.path()).map_err(preparation_error)?; + if metadata.is_dir() { + pending.push(entry.path()); + } else if metadata.is_file() { + let path = entry.path(); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| preparation_error("test path is not UTF-8"))?; + if [ + ".test.ts", + ".test.mjs", + ".test.js", + ".spec.ts", + ".spec.mjs", + ".spec.js", + ] + .iter() + .any(|suffix| name.ends_with(suffix)) + { + tests.insert( + path.strip_prefix(root) + .map_err(preparation_error)? + .to_str() + .ok_or_else(|| preparation_error("test path is not UTF-8"))? + .to_owned(), + ); + } + } else { + return Err(preparation_error( + "test registration contains an unsafe file type", + )); + } + } + } + if tests.is_empty() || !tests.iter().any(|path| path.ends_with("types.test.ts")) { + return Err(preparation_error( + "full JS and registered type-check suites are required", + )); + } + Ok(tests) +} + +/// Fresh two-side state, without any installation or completion authority yet. +pub(crate) struct PreparedJsBuild<'a> { + pub(super) source: &'a ApprovedSource, + pub(super) originals: &'a PreparedNpmOriginals, + pub(super) tools: SelectedJsTools, + pub(super) tools_digest: String, + pub(super) module_ids: Vec, + pub(super) sides: [Side; 2], + pub(super) attempt: JsAttempt, + declarations: BTreeMap<(String, String), String>, + _root_review: RootManifestReview, +} + +/// Private measured installation data, constructed only after HMAC and joins. +pub(super) struct ObservedInstall { + preparation: Value, + pub(super) raw: Vec, +} + +impl<'a> PreparedJsBuild<'a> { + /// Closes every independent source, installation and metadata root, even after a failure. + /// + /// # Errors + /// + /// Returns all observed cleanup failures; expiration never prevents cleanup. + pub(super) fn cleanup(self) -> Result<(), Report> { + let mut failure = None::>; + for side in self.sides { + for result in [ + side.source.close(), + side.state.close().map_err(preparation_error), + side.metadata.close().map_err(preparation_error), + ] { + if let Err(error) = result { + failure = Some(match failure.take() { + Some(previous) => { + previous.attach(format!("another private JS cleanup failed: {error:?}")) + } + None => error.attach("private JS resource cleanup failed"), + }); + } + } + } + match failure { + Some(error) => Err(error), + None => Ok(()), + } + } + /// Creates both independent source/state trees from approved tracked bytes. + /// + /// # Errors + /// + /// Returns an error for changed/unjoined inputs, root hooks or failed private state. + pub(crate) fn new( + source: &'a ApprovedSource, + locked: &LockedMaterials, + originals: &'a PreparedNpmOriginals, + tools: SelectedJsTools, + attempt: JsAttempt, + ) -> Result> { + attempt.check()?; + let root_review = locked.review_root(source)?; + let lock = source.read_tracked( + "crates/trusted-server-js/lib/package-lock.json", + Role::Metadata, + )?; + if originals.source_binding() + != ( + source.commit().as_str(), + hex::encode(Sha256::digest(lock)).as_str(), + ) + { + return Err(preparation_error( + "npm originals are bound to different approved source or lockfile", + )); + } + originals.verify_unchanged()?; + let first = source.materialize(attempt.deadline, &attempt.cancellation)?; + let module_ids = first.module_ids(); + if module_ids.is_empty() || !module_ids.iter().any(|id| id == "core") { + return Err(preparation_error( + "approved source lists no full JS module set", + )); + } + let sides = [ + Side::new(first, &module_ids, &attempt)?, + Side::new( + source.materialize(attempt.deadline, &attempt.cancellation)?, + &module_ids, + &attempt, + )?, + ]; + let tools_digest = tools.digest(&attempt)?; + let declarations = originals.declared_hooks(); + Ok(Self { + source, + originals, + tools, + tools_digest, + module_ids, + sides, + attempt, + declarations, + _root_review: root_review, + }) + } + + /// Returns exactly one side's roots; the other side is never mounted. + /// + /// # Errors + /// + /// Returns an error for an unknown side or unsafe/overlapping roots. + pub(crate) fn boundary_roots( + &self, + index: usize, + ) -> Result> { + let side = self + .sides + .get(index) + .ok_or_else(|| preparation_error("unknown JS comparison side"))?; + BoundaryRoots::new( + &[ + self.originals.root().to_owned(), + side.metadata.path().to_owned(), + ], + std::slice::from_ref(&self.tools.root), + &[side.source.root().to_owned(), side.state.path().to_owned()], + ) + } + + pub(super) fn check(&self) -> Result<(), Report> { + self.attempt.check()?; + self.originals.verify_unchanged()?; + if self.tools.digest(&self.attempt)? != self.tools_digest { + return Err(preparation_error("selected JS tools changed")); + } + Ok(()) + } + + pub(super) fn command( + &self, + index: usize, + boundary: &Boundary, + ) -> Result> { + self.command_with_npm_path(index, boundary, true) + } + + pub(super) fn command_with_npm_path( + &self, + index: usize, + boundary: &Boundary, + include_npm: bool, + ) -> Result> { + self.check()?; + let side = &self.sides[index]; + let remaining = self + .attempt + .deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| preparation_error("JS deadline expired"))?; + let bin = boundary + .execution_path(&self.tools.node())? + .parent() + .ok_or_else(|| preparation_error("selected Node has no parent"))? + .to_owned(); + let path = if include_npm { + format!("{}:/usr/bin:/bin", bin.display()) + } else { + "/usr/bin:/bin".to_owned() + }; + ConfinedCommand::new( + &self.tools.node(), + &side.library(), + ProcessLimits { + wall: remaining.min(Duration::from_secs(1200)), + stdout_max: 16 * 1024 * 1024, + stderr_max: 16 * 1024 * 1024, + }, + )? + .environment("PATH", path)? + .environment( + "HOME", + boundary.execution_path(&side.state.path().join("home"))?, + )? + .environment( + "TMPDIR", + boundary.execution_path(&side.state.path().join("tmp"))?, + )? + .environment("LANG", "C.UTF-8")? + .environment("LC_ALL", "C.UTF-8")? + .environment("TZ", "UTC")? + .environment("TRUSTED_SERVER__RELEASE_BUILD", "1")? + .environment("TRUSTED_SERVER__GIT_VERSION", self.source.commit().as_str())? + .environment("TRUSTED_SERVER__RELEASE_JS_TESTS", "1") + .and_then(|command| { + command.environment( + "TRUSTED_SERVER__RELEASE_JS_SOURCE_CONTEXT", + boundary.execution_path(&side.metadata.path().join("source-context.json"))?, + ) + }) + .map(|command| command.cancellation(self.attempt.cancellation.clone())) + } + + pub(super) fn run_command( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + arguments: Vec, + ) -> Result, Report> { + let command = self.command(index, boundary)?.arguments(arguments); + let output = boundary.run_confined(&command, proof)?.require_success()?; + self.sides[index].verify_source()?; + self.check()?; + Ok(output) + } + + pub(super) fn install( + &self, + index: usize, + boundary: &Boundary, + proof: &DenialProof, + ) -> Result> { + if !boundary.matches_roots(&self.boundary_roots(index)?) + || !boundary.has_owned_descendant_cleanup() + { + return Err(preparation_error( + "JS installation requires exact side roots and owned descendant cleanup", + )); + } + let side = &self.sides[index]; + let npm = boundary.execution_path(&self.tools.npm())?; + let cache = boundary.execution_path(&side.state.path().join("cache"))?; + let versions = + self.run_command(index, boundary, proof, vec![OsString::from("--version")])?; + if versions != b"v24.12.0\n" { + return Err(preparation_error( + "selected Node version differs from approved pin", + )); + } + let version = self.run_command( + index, + boundary, + proof, + vec![npm.as_os_str().to_owned(), OsString::from("--version")], + )?; + if version != b"11.6.2\n" { + return Err(preparation_error( + "selected npm version differs from approved pin", + )); + } + for archives in self.originals.archive_paths().chunks(32) { + let mut arguments = vec![ + npm.as_os_str().to_owned(), + OsString::from("cache"), + OsString::from("add"), + ]; + for archive in archives { + arguments.push(boundary.execution_path(archive)?.into_os_string()); + } + arguments.extend( + [ + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--progress=false", + "--userconfig=/dev/null", + ] + .map(OsString::from), + ); + arguments.push(OsString::from(format!( + "--globalconfig={}/release-disabled-global.npmrc", + cache.display() + ))); + arguments.extend([OsString::from("--cache"), cache.as_os_str().to_owned()]); + self.run_command(index, boundary, proof, arguments)?; + } + let mut key = [0_u8; 32]; + let mut nonce = [0_u8; 32]; + File::open("/dev/urandom") + .and_then(|mut file| { + file.read_exact(&mut key)?; + file.read_exact(&mut nonce) + }) + .map_err(preparation_error)?; + let guard = boundary.execution_path(&self.tools.guard())?; + let request = json!({"schema_version":1,"key":hex::encode(key),"nonce":hex::encode(nonce),"source_commit":self.source.commit().as_str(),"repo_root":boundary.execution_path(side.source.root())?,"cache":cache,"guard_path":guard,"native_esbuild":self.originals.native_esbuild(std::env::consts::ARCH)?}); + let command = self + .command(index, boundary)? + .arguments([ + boundary + .execution_path(&side.source.root().join("scripts/release/observe-npm.mjs"))? + .into_os_string(), + OsString::from("--guard"), + guard.into_os_string(), + ]) + .stdin_bytes(serde_json::to_vec(&request).map_err(preparation_error)?); + let output = boundary.run_confined(&command, proof)?.require_success()?; + let observation = authenticate_observation(&output, &key, &request, &self.declarations)?; + fs::write( + side.metadata.path().join("preparation.json"), + serde_json::to_vec(&observation.preparation).map_err(preparation_error)?, + ) + .map_err(preparation_error)?; + side.verify_source()?; + self.check()?; + Ok(observation) + } + + pub(super) fn collector_arguments( + &self, + index: usize, + boundary: &Boundary, + ) -> Result, Report> { + let side = &self.sides[index]; + Ok(vec![ + boundary + .execution_path(&side.source.root().join("scripts/release/collect-js.mjs"))? + .into_os_string(), + OsString::from("--out-dir"), + boundary + .execution_path(&side.output().join("bundles"))? + .into_os_string(), + OsString::from("--inventory"), + boundary + .execution_path(&side.output().join("tsjs-inventory.json"))? + .into_os_string(), + OsString::from("--preparation"), + boundary + .execution_path(&side.metadata.path().join("preparation.json"))? + .into_os_string(), + OsString::from("--source-context"), + boundary + .execution_path(&side.metadata.path().join("source-context.json"))? + .into_os_string(), + ]) + } +} + +fn authenticate_observation( + output: &[u8], + key: &[u8; 32], + request: &Value, + declarations: &BTreeMap<(String, String), String>, +) -> Result> { + let mut accepted = None; + for line in output.split(|byte| *byte == b'\n') { + let Some(encoded) = line.strip_prefix(PREFIX) else { + continue; + }; + let Some(space) = encoded.iter().position(|byte| *byte == b' ') else { + continue; + }; + let Ok(bytes) = STANDARD.decode(&encoded[..space]) else { + continue; + }; + let Ok(signature) = hex::decode(&encoded[space + 1..]) else { + continue; + }; + let mut mac = Hmac::::new_from_slice(key).map_err(preparation_error)?; + mac.update(&bytes); + if mac.verify_slice(&signature).is_err() { + continue; + } + if accepted.is_some() { + return Err(preparation_error("multiple authenticated npm observations")); + } + accepted = Some((parse_json(&bytes, Role::Metadata)?, bytes)); + } + let (observation, raw) = + accepted.ok_or_else(|| preparation_error("no authenticated npm lifecycle observation"))?; + let object = observation + .as_object() + .ok_or_else(|| preparation_error("npm observation is not an object"))?; + let expected_keys = BTreeSet::from([ + "schema_version", + "nonce", + "source_commit", + "repo_root", + "cache", + "guard_path", + "native_esbuild", + "normalizations", + "success", + "npm_exit_code", + "events", + "preparation", + ]); + if object.keys().map(String::as_str).collect::>() != expected_keys + || observation["success"] != true + || observation["npm_exit_code"] != 0 + { + return Err(preparation_error( + "npm observation did not prove complete successful execution", + )); + } + for field in [ + "schema_version", + "nonce", + "source_commit", + "repo_root", + "cache", + "guard_path", + "native_esbuild", + ] { + if observation.get(field) != request.get(field) { + return Err(preparation_error( + "npm observation belongs to another private request", + )); + } + } + let events = observation["events"] + .as_array() + .ok_or_else(|| preparation_error("npm observation has no event array"))?; + let mut states = BTreeMap::<(String, String), u8>::new(); + for event in events { + let text = |name: &str| { + event + .get(name) + .and_then(Value::as_str) + .ok_or_else(|| preparation_error("lifecycle event lacks a required string")) + }; + let identity = (text("lock_path")?.to_owned(), text("event")?.to_owned()); + if declarations.get(&identity).map(String::as_str) != Some(text("script_sha256")?) { + return Err(preparation_error( + "observed hook differs from authenticated declaration", + )); + } + let state = states.entry(identity).or_default(); + match (text("stage")?, *state) { + ("start", 0) => *state = 1, + ("success", 1) if event["exit_code"] == 0 && event["signal"].is_null() => *state = 2, + _ => { + return Err(preparation_error( + "hook failed, repeated or lacks successful completion", + )); + } + } + } + if states.len() != declarations.len() || states.values().any(|state| *state != 2) { + return Err(preparation_error( + "not every reviewed hook executed successfully", + )); + } + if !observation["preparation"].is_object() || !observation["normalizations"].is_array() { + return Err(preparation_error( + "npm observation lacks measured preparation", + )); + } + let normalizations = observation["normalizations"] + .as_array() + .ok_or_else(|| preparation_error("normalizations must be an array"))?; + if normalizations.len() > 1 { + return Err(preparation_error( + "native tool was normalized more than once", + )); + } + for normalization in normalizations { + let native = &request["native_esbuild"]; + let lock_path = native["lock_path"] + .as_str() + .ok_or_else(|| preparation_error("native expectation lacks locked package identity"))?; + let expected = json!({ + "kind":"esbuild-hardlink-copy-v1", + "path":"node_modules/esbuild/bin/esbuild", + "native_path":format!("{lock_path}/bin/esbuild"), + "sha256":native["sha256"],"size":native["size"],"mode":native["mode"], + }); + if *normalization != expected { + return Err(preparation_error( + "native normalization differs from independently authenticated original", + )); + } + } + Ok(ObservedInstall { + preparation: observation["preparation"].clone(), + raw, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/preparation/tests.rs b/crates/trusted-server-release/src/preparation/tests.rs new file mode 100644 index 0000000000..62ab23dbb9 --- /dev/null +++ b/crates/trusted-server-release/src/preparation/tests.rs @@ -0,0 +1,116 @@ +use super::*; + +fn request() -> Value { + json!({"schema_version":1,"nonce":"a".repeat(64),"source_commit":"b".repeat(40),"repo_root":"/example/source","cache":"/example/cache","guard_path":"/example/tools/guard.node","native_esbuild":{"lock_path":"node_modules/@esbuild/linux-x64","sha256":"c".repeat(64),"size":1,"mode":493}}) +} +fn summary() -> Value { + let mut value = request(); + value["success"] = json!(true); + value["npm_exit_code"] = json!(0); + value["events"] = json!([]); + value["normalizations"] = json!([]); + value["preparation"] = json!({"schema_version":1}); + value +} +fn seal(value: &Value, key: &[u8; 32]) -> Vec { + let bytes = serde_json::to_vec(value).expect("should encode fixture observation"); + let mut mac = + Hmac::::new_from_slice(key).expect("should initialize fixture authenticator"); + mac.update(&bytes); + format!( + "{}{} {}\n", + std::str::from_utf8(PREFIX).expect("should decode fixed prefix"), + STANDARD.encode(bytes), + hex::encode(mac.finalize().into_bytes()) + ) + .into_bytes() +} +#[test] +fn unauthenticated_child_output_cannot_construct_an_install_observation() { + let key = [7_u8; 32]; + assert!( + authenticate_observation( + &seal(&summary(), &[8_u8; 32]), + &key, + &request(), + &BTreeMap::new() + ) + .is_err(), + "should reject a child-forged observation" + ); +} +#[test] +fn observation_requires_one_successful_private_request() { + let key = [7_u8; 32]; + authenticate_observation(&seal(&summary(), &key), &key, &request(), &BTreeMap::new()) + .expect("should accept an authenticated protocol fixture"); + for field in [ + "nonce", + "source_commit", + "repo_root", + "cache", + "guard_path", + "native_esbuild", + "schema_version", + "success", + "npm_exit_code", + ] { + let mut changed = summary(); + changed[field] = Value::Null; + assert!( + authenticate_observation(&seal(&changed, &key), &key, &request(), &BTreeMap::new()) + .is_err(), + "should reject an altered binding or failed observation" + ); + } + let mut duplicate = seal(&summary(), &key); + duplicate.extend(seal(&summary(), &key)); + assert!( + authenticate_observation(&duplicate, &key, &request(), &BTreeMap::new()).is_err(), + "should reject repeated authenticated completion" + ); +} +#[test] +fn hook_start_does_not_substitute_for_successful_terminal_execution() { + let key = [7_u8; 32]; + let digest = "d".repeat(64); + let declarations = BTreeMap::from([( + ("node_modules/example".to_owned(), "postinstall".to_owned()), + digest.clone(), + )]); + let start = json!({"lock_path":"node_modules/example","event":"postinstall","script_sha256":digest,"stage":"start","changes":[]}); + let mut success = start.clone(); + success["stage"] = json!("success"); + success["exit_code"] = json!(0); + success["signal"] = Value::Null; + let mut value = summary(); + value["events"] = json!([start.clone(), success.clone()]); + authenticate_observation(&seal(&value, &key), &key, &request(), &declarations) + .expect("should join start and successful completion"); + for events in [ + json!([]), + json!([start.clone()]), + json!([success]), + json!([start.clone(), start]), + ] { + value["events"] = events; + assert!( + authenticate_observation(&seal(&value, &key), &key, &request(), &declarations).is_err(), + "should reject missing, repeated or out-of-order hook execution" + ); + } +} + +#[cfg(target_os = "linux")] +mod full; + +#[test] +fn normalization_receipt_cannot_assert_another_native_tool_identity() { + let key = [7_u8; 32]; + let mut value = summary(); + value["normalizations"] = json!([{"kind":"esbuild-hardlink-copy-v1","path":"node_modules/esbuild/bin/esbuild","native_path":"node_modules/@esbuild/linux-x64/bin/esbuild","sha256":"f".repeat(64),"size":1,"mode":493}]); + assert!( + authenticate_observation(&seal(&value, &key), &key, &request(), &BTreeMap::new()).is_err(), + "should bind normalization to independently authenticated native bytes" + ); +} diff --git a/crates/trusted-server-release/src/preparation/tests/full.rs b/crates/trusted-server-release/src/preparation/tests/full.rs new file mode 100644 index 0000000000..a89e40b430 --- /dev/null +++ b/crates/trusted-server-release/src/preparation/tests/full.rs @@ -0,0 +1,148 @@ +//! Explicit real Linux fixture; never selected by a normal focused unit run. + +use super::*; +use crate::boundary::{DockerSettings, ImageSelection, ProbeProgram}; +use crate::documents::read_bounded; +use crate::preflight::audit_environment; +use crate::source::{CommitId, authenticate}; + +fn host_limits() -> ProcessLimits { + ProcessLimits { + wall: Duration::from_secs(300), + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + } +} + +#[test] +#[ignore = "requires explicit original-only fixture and Linux Docker; run separately in producer CI"] +fn real_two_fresh_js_builds() { + let fixture = std::env::var_os("TS_RELEASE_JS_FIXTURE_ROOT") + .map(PathBuf::from) + .expect("should explicitly supply original-only fixture root"); + let fixture = fs::canonicalize(fixture).expect("should resolve fixture root"); + let context = read_bounded(&fixture.join("fixture.json"), Role::Metadata) + .expect("should read fixture context"); + let context = parse_json(&context.bytes, Role::Metadata).expect("should parse fixture context"); + let commit = CommitId::parse( + context["source_commit"] + .as_str() + .expect("should retain source identity"), + ) + .expect("should parse independently selected source identity"); + let environment = + audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) + .expect("should audit minimal source environment"); + let source = authenticate(&fixture.join("source"), &commit, &environment) + .expect("should authenticate fixture tracked source"); + let locked = + LockedMaterials::from_source(&source).expect("should derive approved locked npm materials"); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(3600); + let originals = locked + .prepare_npm_originals(&fixture.join("originals"), deadline, &cancellation) + .expect("should authenticate every original archive and review declarations"); + let tools = SelectedJsTools::for_tests(&fixture.join("tools")) + .expect("should measure ordinary CI candidate tools"); + let prepared = PreparedJsBuild::new( + &source, + &locked, + &originals, + tools, + JsAttempt::new(deadline, cancellation), + ) + .expect("should own two independent fresh installations"); + let host = tempfile::tempdir().expect("should create disjoint boundary host state"); + let image = ImageSelection::Test { + id: context["image_id"] + .as_str() + .expect("should select the already-built ordinary CI fixture image") + .to_owned(), + }; + // These IDs only select the existing unprivileged CI worker account. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + let settings = DockerSettings { + client: PathBuf::from("/usr/bin/docker"), + cpus: 1, + memory_bytes: 4 * 1024 * 1024 * 1024, + pids_limit: 512, + tmpfs_bytes: 256 * 1024 * 1024, + user, + stop_timeout_seconds: 5, + host_limits: host_limits(), + }; + let first = Boundary::linux_container( + prepared + .boundary_roots(0) + .expect("should select only A roots"), + settings.clone(), + image.clone(), + host.path(), + ) + .expect("should configure A owned PID/network boundary"); + let second = Boundary::linux_container( + prepared + .boundary_roots(1) + .expect("should select only B roots"), + settings, + image, + host.path(), + ) + .expect("should configure B disjoint boundary"); + let probe = ProbeProgram::from_staged_release_binary(&fixture.join("tools/probe")) + .expect("should select staged native producer probe"); + let limits = ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }; + let first_proof = first + .establish_denial(&probe, limits) + .expect("should establish real A positive and denied socket controls"); + let second_proof = second + .establish_denial(&probe, limits) + .expect("should establish independent real B controls"); + let owned_roots: Vec<_> = prepared + .sides + .iter() + .flat_map(|side| { + [ + side.source.root().to_owned(), + side.state.path().to_owned(), + side.metadata.path().to_owned(), + ] + }) + .collect(); + let completed=prepared.run([&first,&second],[&first_proof,&second_proof]).expect("should complete both actual fresh offline installations, lifecycle observations, negative tool controls, full suites and final-byte equality"); + assert!( + owned_roots.iter().all(|root| !root.exists()), + "should close all six private A/B source, state and metadata roots before completion" + ); + assert_eq!( + completed.source_commit(), + commit.as_str(), + "should bind actual approved source" + ); + assert!( + completed.test_counts().iter().all(|count| *count > 0), + "should execute nonzero full registered suites on both sides" + ); + assert_eq!( + completed.tools_sha256().len(), + 64, + "should bind measured complete tool tree" + ); + assert_eq!( + completed.settings_sha256().len(), + 64, + "should bind fixed execution settings" + ); + assert_eq!( + completed.inventory_sha256().len(), + 64, + "should retain original A inventory digest" + ); + completed + .rehash_for_handoff() + .expect("should rejoin retained A bytes before downstream handoff"); +} diff --git a/crates/trusted-server-release/src/source.rs b/crates/trusted-server-release/src/source.rs index ded2a4714f..3ccc41bd5e 100644 --- a/crates/trusted-server-release/src/source.rs +++ b/crates/trusted-server-release/src/source.rs @@ -27,9 +27,12 @@ use worktree::compare_worktree; mod closure; mod config; mod git; +mod materialize; mod tree; mod worktree; +pub(crate) use materialize::FreshSource; + /// Repository channels that rewrite history, objects or checkout attributes. const FORBIDDEN_GIT_PATHS: [&str; 7] = [ "commondir", diff --git a/crates/trusted-server-release/src/source/closure.rs b/crates/trusted-server-release/src/source/closure.rs index 6e17905184..c6811710fb 100644 --- a/crates/trusted-server-release/src/source/closure.rs +++ b/crates/trusted-server-release/src/source/closure.rs @@ -11,13 +11,17 @@ use crate::error::ReleaseError; const RELEASE_CRATE_PREFIX: &str = "crates/trusted-server-release/"; /// Root files always included in the helper-source closure. -const CLOSURE_FILES: [&str; 6] = [ +const CLOSURE_FILES: [&str; 10] = [ ".cargo/config.toml", ".tool-versions", "Cargo.lock", "Cargo.toml", "rust-toolchain.toml", "scripts/release/bootstrap.sh", + "scripts/release/build-js.sh", + "scripts/release/build.sh", + "scripts/release/collect-js.mjs", + "scripts/release/observe-npm.mjs", ]; /// Path-dependency manifests that must be listed as workspace members. @@ -43,7 +47,10 @@ pub(super) fn helper_closure( paths.extend( records .keys() - .filter(|path| path.starts_with(RELEASE_CRATE_PREFIX)) + .filter(|path| { + path.starts_with(RELEASE_CRATE_PREFIX) + || path.starts_with("crates/trusted-server-js/lib/") + }) .cloned(), ); let digest = digest_records(paths.iter().filter_map(|path| records.get_key_value(path))); diff --git a/crates/trusted-server-release/src/source/materialize.rs b/crates/trusted-server-release/src/source/materialize.rs new file mode 100644 index 0000000000..473cdfa6e9 --- /dev/null +++ b/crates/trusted-server-release/src/source/materialize.rs @@ -0,0 +1,212 @@ +//! Fresh owned source copies contain approved tracked bytes only. + +use std::fs; +use std::io::Write as _; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::Path; +use std::time::Instant; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::{ApprovedSource, Mode, Records, source_error}; +use crate::documents::{Role, read_bounded_with_check}; +use crate::error::ReleaseError; +use crate::process::Cancellation; + +/// Owns one independent working tree, with no inherited Git configuration. +pub(crate) struct FreshSource { + directory: TempDir, + records: Records, + commit: String, + deadline: Instant, + cancellation: Cancellation, +} + +impl FreshSource { + /// Removes the owned tree and reports cleanup failure to the recipe. + /// + /// # Errors + /// + /// Returns an error if the private source copy cannot be removed. Cleanup + /// remains available after cancellation or expiration. + pub(crate) fn close(self) -> Result<(), Report> { + self.directory + .close() + .map_err(|error| source_error(error.to_string())) + } + /// Returns the private tracked-source root. + pub(crate) fn root(&self) -> &Path { + self.directory.path() + } + + /// Returns the source identity whose bytes were copied. + pub(crate) fn commit(&self) -> &str { + &self.commit + } + + /// Returns the complete approved JS entry-point set in sorted order. + pub(crate) fn module_ids(&self) -> Vec { + let prefix = "crates/trusted-server-js/lib/src/"; + let mut ids: Vec<_> = self + .records + .keys() + .filter_map(|path| { + let path = path.strip_prefix(prefix)?; + if path == "core/index.ts" { + return Some("core".to_owned()); + } + let integration = path + .strip_prefix("integrations/")? + .strip_suffix("/index.ts")?; + (!integration.contains('/')).then(|| integration.to_owned()) + }) + .collect(); + ids.sort(); + ids + } + + /// Rechecks every tracked path and rejects unexpected source files. + /// + /// # Errors + /// + /// Returns an error for changed source, unexpected files or output roots + /// overlapping approved tracked paths. Output trees are checked separately + /// by preparation; this method grants no authority to their contents. + pub(crate) fn verify_unchanged(&self, outputs: &[&str]) -> Result<(), Report> { + let check = || check_attempt(self.deadline, &self.cancellation); + check()?; + let root = + fs::symlink_metadata(self.root()).map_err(|error| source_error(error.to_string()))?; + if !root.is_dir() { + return Err(source_error( + "fresh source root changed type or became an alias", + )); + } + if fs::symlink_metadata(self.root().join(".git")).is_ok() { + return Err(source_error( + "fresh source must not acquire repository configuration", + )); + } + super::worktree::compare_with_outputs_controlled( + self.root(), + &self.records, + outputs, + &check, + ) + } +} + +impl ApprovedSource { + /// Copies approved tracked bytes into a new independently owned directory. + /// + /// # Errors + /// + /// Returns an error for changed original source, failed private writes or a + /// copied path that does not rejoin its approved mode and digest. No cache, + /// installed dependencies, generated outputs or Git configuration is copied. + pub(crate) fn materialize( + &self, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + self.materialize_with_check(deadline, cancellation, &|| { + check_attempt(deadline, cancellation) + }) + } + + /// Copies under the same checked attempt; the callback also supports deterministic cancellation fixtures. + /// + /// # Errors + /// + /// Returns an error for failed attempt checks, changed source or failed private writes. + pub(super) fn materialize_with_check( + &self, + deadline: Instant, + cancellation: &Cancellation, + check: &dyn Fn() -> Result<(), Report>, + ) -> Result> { + check()?; + super::worktree::compare_with_outputs_controlled( + &self.snapshot.root, + &self.snapshot.records, + &[], + check, + )?; + let directory = tempfile::tempdir().map_err(|error| source_error(error.to_string()))?; + for (relative, (mode, expected)) in &self.snapshot.records { + check()?; + let destination = directory.path().join(relative); + fs::create_dir_all( + destination + .parent() + .ok_or_else(|| source_error("missing source parent"))?, + ) + .map_err(|error| source_error(error.to_string()))?; + match mode { + Mode::Symlink => { + let target = fs::read_link(self.snapshot.root.join(relative)) + .map_err(|error| source_error(error.to_string()))?; + if hex::encode(Sha256::digest(target.as_os_str().as_bytes())) != *expected { + return Err(source_error("approved source alias changed during copying")); + } + symlink(target, destination) + .map_err(|error| source_error(error.to_string()))?; + } + Mode::Regular | Mode::Executable => { + let raw = read_bounded_with_check( + &self.snapshot.root.join(relative), + Role::Source, + check, + )?; + if raw.sha256 != *expected { + return Err(source_error("approved source bytes changed during copying")); + } + let mut output = fs::File::create_new(&destination) + .map_err(|error| source_error(error.to_string()))?; + for chunk in raw.bytes.chunks(64 * 1024) { + check()?; + output + .write_all(chunk) + .map_err(|error| source_error(error.to_string()))?; + } + let permissions = if *mode == Mode::Executable { + 0o700 + } else { + 0o600 + }; + fs::set_permissions(destination, fs::Permissions::from_mode(permissions)) + .map_err(|error| source_error(error.to_string()))?; + } + } + } + super::worktree::compare_with_outputs_controlled( + &self.snapshot.root, + &self.snapshot.records, + &[], + check, + )?; + let fresh = FreshSource { + directory, + records: self.snapshot.records.clone(), + commit: self.commit.as_str().to_owned(), + deadline, + cancellation: cancellation.clone(), + }; + check()?; + fresh.verify_unchanged(&[])?; + Ok(fresh) + } +} + +fn check_attempt( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(source_error("source preparation cancelled or timed out")); + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/source/tests.rs b/crates/trusted-server-release/src/source/tests.rs index fa7db0272b..0bf69ac15a 100644 --- a/crates/trusted-server-release/src/source/tests.rs +++ b/crates/trusted-server-release/src/source/tests.rs @@ -1,7 +1,9 @@ +use std::cell::Cell; use std::ffi::OsString; use std::fs; use std::os::unix::fs::{PermissionsExt as _, symlink}; use std::process::Command; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; use tempfile::{TempDir, tempdir}; @@ -14,9 +16,92 @@ use crate::git_objects::checked_sha1; use crate::policy::ReleaseTarget; use crate::preflight::audit_environment; use crate::preflight::child::{ChildRequest, child_environment}; +use crate::process::Cancellation; const EPOCH: u64 = 1_700_000_000; +#[test] +fn fresh_source_attempt_controls_reject_cancelled_and_expired_work() { + let fixture = Fixture::new(); + let approved = fixture.authenticate().expect("should authenticate source"); + let cancellation = Cancellation::default(); + cancellation.cancel(); + assert!( + approved + .materialize(Instant::now() + Duration::from_secs(30), &cancellation) + .is_err(), + "should reject cancellation before copying" + ); + assert!( + approved + .materialize(Instant::now(), &Cancellation::default()) + .is_err(), + "should reject expired attempts before copying" + ); + let cancellation = Cancellation::default(); + let fresh = approved + .materialize(Instant::now() + Duration::from_secs(30), &cancellation) + .expect("should copy active source"); + cancellation.cancel(); + assert!( + fresh.verify_unchanged(&[]).is_err(), + "should retain attempt cancellation for handoff checks" + ); +} + +#[test] +fn fresh_source_copy_and_source_scan_stop_at_the_first_cancelled_check() { + let fixture = Fixture::new(); + let approved = fixture.authenticate().expect("should authenticate source"); + let baseline = Cell::new(0); + super::worktree::compare_with_outputs_controlled( + &approved.snapshot.root, + &approved.snapshot.records, + &[], + &|| { + baseline.set(baseline.get() + 1); + Ok(()) + }, + ) + .expect("should measure deterministic initial scan"); + let calls = Cell::new(0); + let stop = baseline.get() + 12; + let result = approved.materialize_with_check( + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + &|| { + calls.set(calls.get() + 1); + if calls.get() >= stop { + return Err(source_error("cancelled during private copy")); + } + Ok(()) + }, + ); + assert!( + result.is_err(), + "should stop copying instead of returning fresh source" + ); + assert_eq!(calls.get(), stop, "should stop exactly at cancellation"); + let scans = Cell::new(0); + let scanned = super::worktree::compare_with_outputs_controlled( + &approved.snapshot.root, + &approved.snapshot.records, + &[], + &|| { + scans.set(scans.get() + 1); + if scans.get() >= 3 { + return Err(source_error("cancelled during directory scan")); + } + Ok(()) + }, + ); + assert!( + scanned.is_err(), + "should abort source enumeration when cancelled" + ); + assert_eq!(scans.get(), 3, "should not enumerate after cancellation"); +} + struct Fixture { parent: TempDir, root: PathBuf, @@ -102,6 +187,18 @@ impl Fixture { "scripts/release/bootstrap.sh", b"#!/bin/sh\nexit 1\n", ); + for script in [ + "build-js.sh", + "build.sh", + "collect-js.mjs", + "observe-npm.mjs", + ] { + write( + &root, + &format!("scripts/release/{script}"), + b"// fictional driver\n", + ); + } fs::set_permissions( root.join("scripts/release/bootstrap.sh"), fs::Permissions::from_mode(0o755), @@ -192,3 +289,108 @@ mod layout; mod objects; mod process; mod worktree; + +#[test] +fn fresh_source_copies_share_no_files_and_reject_changed_inputs() { + let fixture = Fixture::new(); + let approved = fixture.authenticate().expect("should authenticate source"); + let first = approved + .materialize( + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should materialize first source"); + let second = approved + .materialize( + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should materialize second source"); + assert_ne!( + first.root(), + second.root(), + "should own distinct source roots" + ); + assert!( + !first.root().join(".git").exists(), + "should not inherit repository configuration" + ); + first + .verify_unchanged(&[]) + .expect("should verify pristine copy"); + fs::write(first.root().join("AGENTS.md"), b"changed").expect("should modify private copy"); + assert!( + first.verify_unchanged(&[]).is_err(), + "should reject changed copied source" + ); + second + .verify_unchanged(&[]) + .expect("should preserve independent copy"); + approved + .snapshot() + .verify_unchanged() + .expect("should preserve original source"); + fs::write(fixture.root.join("AGENTS.md"), b"changed").expect("should change original"); + assert!( + approved + .materialize( + Instant::now() + Duration::from_secs(30), + &Cancellation::default() + ) + .is_err(), + "should reject changed original before copying" + ); +} + +#[test] +fn fresh_source_output_exclusions_cannot_hide_approved_files() { + let fixture = Fixture::new(); + let approved = fixture.authenticate().expect("should authenticate source"); + let fresh = approved + .materialize( + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should copy source"); + fs::create_dir(fresh.root().join("generated")).expect("should create generated root"); + fs::write(fresh.root().join("generated/output"), b"output").expect("should write output"); + assert!( + fresh.verify_unchanged(&[]).is_err(), + "should reject unapproved output root" + ); + fresh + .verify_unchanged(&["generated"]) + .expect("should allow disjoint generated root"); + assert!( + fresh.verify_unchanged(&["crates"]).is_err(), + "should reject exclusion overlapping tracked source" + ); + assert!( + fresh.verify_unchanged(&["../source"]).is_err(), + "should reject nonnormal exclusions" + ); + fs::write(fresh.root().join("AGENTS.md"), b"changed").expect("should change tracked file"); + assert!( + fresh.verify_unchanged(&["generated"]).is_err(), + "should still reject source changes outside generated root" + ); +} + +#[test] +fn fresh_source_recheck_rejects_replaced_root_alias() { + let fixture = Fixture::new(); + let approved = fixture.authenticate().expect("should authenticate source"); + let fresh = approved + .materialize( + Instant::now() + Duration::from_secs(30), + &Cancellation::default(), + ) + .expect("should create source copy"); + let saved = fixture.parent.path().join("saved-copy"); + fs::rename(fresh.root(), &saved).expect("should move owned source"); + symlink(&saved, fresh.root()).expect("should alias identical source"); + assert!( + fresh.verify_unchanged(&[]).is_err(), + "should reject source root substitution" + ); +} diff --git a/crates/trusted-server-release/src/source/tests/closure.rs b/crates/trusted-server-release/src/source/tests/closure.rs index 3fc297f28f..4d86b5d6b5 100644 --- a/crates/trusted-server-release/src/source/tests/closure.rs +++ b/crates/trusted-server-release/src/source/tests/closure.rs @@ -259,3 +259,34 @@ fn child_environment_takes_commit_and_epoch_from_approved_source() { "should inject the approved committer epoch" ); } + +#[test] +fn helper_closure_includes_the_actual_npm_observer() { + let mut fixture = Fixture::new(); + write( + &fixture.root, + "scripts/release/observe-npm.mjs", + b"// first observer\n", + ); + fixture.commit_all(); + let first = fixture + .authenticate() + .expect("should authenticate observer source") + .closure_digest() + .to_owned(); + write( + &fixture.root, + "scripts/release/observe-npm.mjs", + b"// changed observer\n", + ); + fixture.commit_all(); + let second = fixture + .authenticate() + .expect("should authenticate changed observer source") + .closure_digest() + .to_owned(); + assert_ne!( + first, second, + "should invalidate helper selection when observer code changes" + ); +} diff --git a/crates/trusted-server-release/src/source/tests/objects.rs b/crates/trusted-server-release/src/source/tests/objects.rs index fc52f71682..b59d2e0aa2 100644 --- a/crates/trusted-server-release/src/source/tests/objects.rs +++ b/crates/trusted-server-release/src/source/tests/objects.rs @@ -34,6 +34,10 @@ fn authenticates_clean_fixture_with_commit_epoch_and_closure() { "crates/trusted-server-release/src/main.rs", "rust-toolchain.toml", "scripts/release/bootstrap.sh", + "scripts/release/build-js.sh", + "scripts/release/build.sh", + "scripts/release/collect-js.mjs", + "scripts/release/observe-npm.mjs", ], "should record the complete helper-source closure" ); diff --git a/crates/trusted-server-release/src/source/worktree.rs b/crates/trusted-server-release/src/source/worktree.rs index 3abe749bdb..c734d71ad0 100644 --- a/crates/trusted-server-release/src/source/worktree.rs +++ b/crates/trusted-server-release/src/source/worktree.rs @@ -3,16 +3,15 @@ use std::collections::BTreeSet; use std::ffi::OsString; use std::fs::{self, Metadata}; -use std::io; use std::os::unix::ffi::OsStrExt as _; -use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::os::unix::fs::MetadataExt as _; use std::path::Path; use error_stack::Report; use sha2::{Digest as _, Sha256}; use super::{Mode, Records, source_error}; -use crate::documents::{Role, read_bounded}; +use crate::documents::hash_bounded_with_check; use crate::error::ReleaseError; const PROBLEMS_SHOWN: usize = 20; @@ -25,6 +24,45 @@ pub(super) fn compare_worktree( root: &Path, expected: &Records, ) -> Result<(), Report> { + compare_with_outputs(root, expected, &[]) +} + +/// Checks source while allowing only explicitly disjoint generated roots. +pub(super) fn compare_with_outputs( + root: &Path, + expected: &Records, + outputs: &[&str], +) -> Result<(), Report> { + compare_with_outputs_controlled(root, expected, outputs, &|| Ok(())) +} + +/// Checks the owning attempt between directory entries and held-file chunks. +/// +/// # Errors +/// +/// Returns an error for changed source, unsafe output exclusions, exceeded +/// entry limits or failed attempt checks. +pub(super) fn compare_with_outputs_controlled( + root: &Path, + expected: &Records, + outputs: &[&str], + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(), Report> { + check()?; + for output in outputs { + if output.is_empty() + || output + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || expected + .keys() + .any(|path| path == output || path.starts_with(&format!("{output}/"))) + { + return Err(source_error( + "generated root overlaps approved source or is not normal", + )); + } + } let mut scan = Scan { root, expected, @@ -32,9 +70,12 @@ pub(super) fn compare_worktree( directories: vec![String::new()], problems: Vec::new(), seen: BTreeSet::new(), + outputs, + check, }; while let Some(directory) = scan.directories.pop() { - for name in list_directory(root, &directory)? { + for name in list_directory(root, &directory, check)? { + check()?; scan.visit(&directory, &name); } } @@ -44,6 +85,7 @@ pub(super) fn compare_worktree( .map(|path| format!("tracked path {path} is missing")) .collect(); scan.problems.extend(missing); + check()?; report_problems(scan.problems) } @@ -60,14 +102,26 @@ fn ancestor_directories(expected: &Records) -> BTreeSet<&str> { ancestors } -fn list_directory(root: &Path, directory: &str) -> Result, Report> { - let mut names = fs::read_dir(root.join(directory)) - .and_then(|entries| { - entries - .map(|entry| entry.map(|entry| entry.file_name())) - .collect::>>() - }) +fn list_directory( + root: &Path, + directory: &str, + check: &dyn Fn() -> Result<(), Report>, +) -> Result, Report> { + check()?; + let entries = fs::read_dir(root.join(directory)) .map_err(|error| source_error(format!("cannot list {directory:?}: {error}")))?; + let mut names = Vec::new(); + for entry in entries { + check()?; + if names.len() >= 1_000_000 { + return Err(source_error("source directory exceeds tracked entry limit")); + } + names.push( + entry + .map_err(|error| source_error(error.to_string()))? + .file_name(), + ); + } names.sort(); Ok(names) } @@ -91,6 +145,8 @@ struct Scan<'a> { directories: Vec, problems: Vec, seen: BTreeSet, + outputs: &'a [&'a str], + check: &'a dyn Fn() -> Result<(), Report>, } impl Scan<'_> { @@ -110,6 +166,9 @@ impl Scan<'_> { if relative == ".git" { return; } + if self.outputs.contains(&relative.as_str()) { + return; + } self.observe(&relative); self.seen.insert(relative); } @@ -134,7 +193,7 @@ impl Scan<'_> { } return; }; - match observe_record(&path, &metadata) { + match observe_record(&path, &metadata, self.check) { Ok(observed) if observed == *record => {} Ok(_) => self.problems.push(format!( "{relative} differs from the approved commit in mode, type or content" @@ -145,7 +204,11 @@ impl Scan<'_> { } /// Observes the mode and content digest of one tracked worktree path. -fn observe_record(path: &Path, metadata: &Metadata) -> Result<(Mode, String), String> { +fn observe_record( + path: &Path, + metadata: &Metadata, + check: &dyn Fn() -> Result<(), Report>, +) -> Result<(Mode, String), String> { if metadata.file_type().is_symlink() { let target = fs::read_link(path).map_err(|error| error.to_string())?; return Ok(( @@ -159,12 +222,14 @@ fn observe_record(path: &Path, metadata: &Metadata) -> Result<(Mode, String), St if metadata.nlink() != 1 { return Err(format!("has {} hard links", metadata.nlink())); } - let mode = if metadata.permissions().mode() & 0o100 == 0 { - Mode::Regular - } else { - Mode::Executable - }; - read_bounded(path, Role::Executable) - .map(|raw| (mode, raw.sha256)) + hash_bounded_with_check(path, 64 * 1024 * 1024, check) + .map(|raw| { + let mode = if raw.mode & 0o100 == 0 { + Mode::Regular + } else { + Mode::Executable + }; + (mode, raw.sha256) + }) .map_err(|report| format!("{report:?}")) } diff --git a/crates/trusted-server-release/tests/commands.rs b/crates/trusted-server-release/tests/commands.rs index c8af879ad3..7f2edce1eb 100644 --- a/crates/trusted-server-release/tests/commands.rs +++ b/crates/trusted-server-release/tests/commands.rs @@ -76,6 +76,18 @@ impl SourceFixture { write(&root, ".cargo/config.toml", b"[alias]\n"); write(&root, ".tool-versions", b"rust 1.95.0\n"); write(&root, BOOTSTRAP, b"#!/bin/sh\nexit 1\n"); + for script in [ + "build-js.sh", + "build.sh", + "collect-js.mjs", + "observe-npm.mjs", + ] { + write( + &root, + &format!("scripts/release/{script}"), + b"// fictional driver\n", + ); + } write( &root, "crates/trusted-server-openrtb/Cargo.toml", diff --git a/scripts/release/collect-js.mjs b/scripts/release/collect-js.mjs index 3baffe8bef..5a1ac57694 100644 --- a/scripts/release/collect-js.mjs +++ b/scripts/release/collect-js.mjs @@ -27,9 +27,36 @@ const defaultRoot = path.resolve(here, '../..') const MAX_BUNDLE = 32 * 1024 * 1024 /** Discover modules from the selected source identity. */ -export function expectedModuleIds(repoRoot, sourceCommit) { +export function expectedModuleIds(repoRoot, sourceCommit, sourceContextPath) { if (!/^[a-f0-9]{40}$/.test(sourceCommit ?? '')) throw new Error('Expected full source commit') + if (sourceContextPath) { + const file = fs.realpathSync(sourceContextPath) + if (file === repoRoot || file.startsWith(`${repoRoot}${path.sep}`)) + throw new Error('Source context must be outside the writable checkout') + const context = strictJson(readBounded(file)) + if ( + !context || + typeof context !== 'object' || + Array.isArray(context) || + Object.keys(context).sort().join(',') !== + 'module_ids,schema_version,source_commit' || + context.schema_version !== 1 || + context.source_commit !== sourceCommit || + !Array.isArray(context.module_ids) || + !context.module_ids.includes('core') || + context.module_ids.some( + (id) => typeof id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(id) + ) || + context.module_ids.some( + (id, index) => index > 0 && context.module_ids[index - 1] >= id + ) + ) + throw new Error('Invalid parent-derived source context') + // The parent derives this list from authenticated objects and mounts it + // read-only. Parsing it alone does not establish source/build completion. + return context.module_ids + } const gitEnvironment = { PATH: process.env.PATH, TMPDIR: os.tmpdir(), @@ -79,6 +106,7 @@ export async function collectJs({ outDir, inventoryPath, preparationPath, + sourceContextPath, }) { if (!preparationPath) throw new Error('Checked preparation records are required') @@ -128,7 +156,7 @@ export async function collectJs({ inventoryPath.startsWith(`${outDir}${path.sep}`) ) throw new Error('Inventory must be outside the bundle directory') - const expected = expectedModuleIds(repoRoot, sourceCommit) + const expected = expectedModuleIds(repoRoot, sourceCommit, sourceContextPath) const records = await buildAll({ outDir, sourceDir: path.join(libDir, 'src'), @@ -167,6 +195,7 @@ export async function collectJs({ 'tsconfig.json', ].map((file) => `crates/trusted-server-js/lib/${file}`) sources.push('scripts/release/collect-js.mjs') + sources.push('scripts/release/observe-npm.mjs') sources.push( 'scripts/release/build-js.sh', 'scripts/release/bootstrap.sh', @@ -205,17 +234,19 @@ if ( ) { const args = process.argv.slice(2) if ( - args.length !== 6 || + ![6, 8].includes(args.length) || args[0] !== '--out-dir' || args[2] !== '--inventory' || - args[4] !== '--preparation' + args[4] !== '--preparation' || + (args.length === 8 && args[6] !== '--source-context') ) throw new Error( - 'Usage: collect-js.mjs --out-dir --inventory --preparation ' + 'Usage: collect-js.mjs --out-dir --inventory --preparation [--source-context ]' ) await collectJs({ outDir: args[1], inventoryPath: args[3], preparationPath: args[5], + sourceContextPath: args[7], }) } diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index 4f49e1766d..343dfeb5fb 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -102,21 +102,26 @@ fresh installation, network confinement, A/B comparison, signing or publication. ## Independent Rust preparation recipe -Introduced Python release orchestration is removed; replacement Rust execution remains pending. +The private Rust preparation and comparison recipe uses the retained Node collector. `build-js.sh` remains a thin POSIX adapter to the independently approved-source OS-native `trusted-server-release build-js` command. Until its authenticated Rust -bootstrap, source-owned selections and real recipe are implemented, the wrapper +bootstrap and qualified source-owned selections are implemented, the wrapper refuses before creating work directories. No public fixture bypass, unchecked interpreter or colocated self-authored SHA file establishes bootstrap authority. The host-only tool has no core/CLI/JS crate dependency; it retains this Node collector and composition pipeline. See the [migration addendum](../../docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md). -Port the actual recipe into private `materials`, `process`, `boundary`, -`preparation` and `javascript` modules. Each installation owns a fresh tracked -source/object snapshot, sanitized private Git store, cache, node_modules, HOME, -TMP, lifecycle observations and bundles. Original sources/object stores and shared -authenticated archives/tools are read-only; sibling data/metadata, shared writes, -network sockets and arbitrary Mach delegation are denied. Current installed-tool +The recipe lives in private `materials`, `process`, `boundary`, +`preparation` and `javascript` modules. Each installation owns a fresh copy of +approved tracked source bytes, cache, node_modules, HOME, TMP, lifecycle observations +and bundles. The parent authenticates the approved Git objects and derives the +complete module set; it supplies that set and the source commit through a protected +read-only source-context document. JS working copies contain no inherited `.git`, +configuration or history. Git material snapshots for Cargo remain separate. +Original sources/object stores and shared authenticated archives/tools are read-only; +sibling data/metadata, shared writes and network sockets are denied. The shared JS +execution path selects Linux containers with owned descendant cleanup; macOS native +recipes require their own lifecycle containment and qualification. Current installed-tool rehashing is separate from independently reviewed all-cohort hosted evidence. Local macOS 26.6.2 diagnostics cannot qualify macOS 15 ARM/Intel or GNU. @@ -177,6 +182,38 @@ results describe declarations only. They grant no execution authority and supply no starts, completions, failures or changed-file observations. Full installation tree mutation checks and actual lifecycle observations remain separate gates. +`observe-npm.mjs` adapts the selected npm 11.6.2 lifecycle entry before npm loads +its dependency graph. It records real starts, fulfilled/rejected execution, +exit status and changed owned-file facts in memory. This includes hooks with no +output and optional hook failures that npm would otherwise swallow. Root calls +for absent scripts remain no-ops; root commands and unreviewed dependency hooks +fail. Selected Node replaces the reviewed command's bare `node` and leads the +child PATH after npm prepends package bins. Lifecycle children receive no parent +stdin or additional inherited descriptors. + +The Linux supervisor loads the authenticated native non-dumpable-process guard +before giving the observer its private request key. The guard and the process +memory syscall denials prevent lifecycle children from reading or modifying the +trusted npm process. Observations and the checked preparation document are sealed +together with HMAC-SHA-256 over their exact original JSON bytes. The supervisor +joins the nonce, source, workspace, cache, guard and native esbuild expectation, +requires successful npm exit and complete successful events, and independently +checks the retained outputs. A child's printed prefix or writable JSON file does +not establish an observation. This adapter provides no macOS completion path and +does not itself authenticate Node/npm distributions or qualify a hosted runner. + +The reviewed esbuild 0.27.3 postinstall can hard-link its selected native package +binary to `node_modules/esbuild/bin/esbuild`. To retain the boundary's global +single-link policy, the observer detaches only this exact pair after execution. +Both paths must identify the same regular inode with exactly two links and match +the parent-derived authenticated native archive's digest, size and expected mode. +The observer copies the owned esbuild path into a fresh file and atomically +replaces it, then verifies both files have one link and unchanged executable +bytes. It records `esbuild-hardlink-copy-v1` separately in the sealed observations; +the changed owned executable remains a lifecycle output. Other hard links, +additional aliases, mismatched bytes/modes or unsafe paths fail. This +normalization changes inode ownership of the bytes, not executable content. + Run collector and unconditional full Vitest/type checks under that same native boundary, checking tracked source and unexpected files before/after each stage. Preserve release Vitest's literal host and narrowly scoped native `node:` import diff --git a/scripts/release/observe-npm.mjs b/scripts/release/observe-npm.mjs new file mode 100644 index 0000000000..b766049814 --- /dev/null +++ b/scripts/release/observe-npm.mjs @@ -0,0 +1,690 @@ +/** Observe pinned npm execution; the Rust supervisor authenticates the sealed result. */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { + actualToolFiles, + expectedLifecycleEvents, + fileDigest, + installedMaterials, + installedOrigins, + readBounded, + sha256, + strictJson, + validatePreparation, +} from '../../crates/trusted-server-js/lib/release-composition.mjs' + +const require = createRequire(import.meta.url) +const MAX_JSON = 1024 * 1024 +const MAX_FILE = 32 * 1024 * 1024 +const MAX_TREE = 2 * 1024 * 1024 * 1024 +const HOOKS = new Map([ + [ + 'core-js@3.48.0:postinstall', + 'node -e "try{require(\'./postinstall\')}catch(e){}"', + ], + ['esbuild@0.27.3:postinstall', 'node install.js'], +]) +const ROOT_HOOKS = [ + 'preinstall', + 'install', + 'postinstall', + 'prepublish', + 'preprepare', + 'prepare', + 'postprepare', + 'predependencies', + 'dependencies', + 'postdependencies', +] +const inside = (root, file) => + file === root || file.startsWith(`${root}${path.sep}`) +const ordered = (left, right) => (left < right ? -1 : left > right ? 1 : 0) +const fail = () => { + throw new Error('Release npm observation rejected') +} + +/** Parse a bounded parent request without accepting commands or hook-policy overrides. */ +export function parseRequest(bytes) { + const request = strictJson(bytes) + const keys = [ + 'schema_version', + 'key', + 'nonce', + 'source_commit', + 'repo_root', + 'cache', + 'guard_path', + 'native_esbuild', + ] + if ( + !request || + typeof request !== 'object' || + Array.isArray(request) || + Object.keys(request).sort().join(',') !== keys.sort().join(',') || + request.schema_version !== 1 || + !/^[a-f0-9]{64}$/.test(request.key ?? '') || + !/^[a-f0-9]{64}$/.test(request.nonce ?? '') || + !/^[a-f0-9]{40}$/.test(request.source_commit ?? '') + ) + fail() + validateNativeExpectation(request.native_esbuild) + for (const key of ['repo_root', 'cache', 'guard_path']) { + const value = request[key] + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + /[\\\x00-\x1f\x7f]/.test(value) || + value.split('/').some((part) => part === '.' || part === '..') || + fs.realpathSync(value) !== value + ) + fail() + } + if ( + inside(request.repo_root, request.cache) || + inside(request.cache, request.repo_root) || + inside(request.repo_root, request.guard_path) || + inside(request.cache, request.guard_path) || + !fs.lstatSync(request.repo_root).isDirectory() || + !fs.lstatSync(request.cache).isDirectory() || + !fs.lstatSync(request.guard_path).isFile() || + fs.lstatSync(request.guard_path).nlink !== 1 + ) + fail() + return request +} + +function validateNativeExpectation(native) { + if ( + !native || + typeof native !== 'object' || + Array.isArray(native) || + Object.keys(native).sort().join(',') !== 'lock_path,mode,sha256,size' || + !/^node_modules\/@esbuild\/linux-(?:x64|arm64)$/.test( + native.lock_path ?? '' + ) || + !/^[a-f0-9]{64}$/.test(native.sha256 ?? '') || + !Number.isSafeInteger(native.size) || + native.size < 1 || + native.size > MAX_FILE || + !Number.isSafeInteger(native.mode) || + native.mode < 0 || + native.mode > 0o777 || + !(native.mode & 0o111) + ) + fail() +} + +/** Detach only esbuild's reviewed native hard-link optimization without changing executable bytes. */ +export function normalizeEsbuildHardlink({ installRoot, native }) { + validateNativeExpectation(native) + const nativePath = `${native.lock_path}/bin/esbuild` + const ownedPath = 'node_modules/esbuild/bin/esbuild' + const original = path.join(installRoot, nativePath) + const owned = path.join(installRoot, ownedPath) + if ( + fs.realpathSync(original) !== original || + fs.realpathSync(owned) !== owned + ) + fail() + const first = fs.lstatSync(original) + const second = fs.lstatSync(owned) + if ( + !first.isFile() || + !second.isFile() || + first.size !== native.size || + (first.mode & 0o777) !== native.mode || + fileDigest(original) !== native.sha256 + ) + fail() + if (first.nlink === 1 && second.nlink === 1) return null + if ( + first.nlink !== 2 || + second.nlink !== 2 || + first.dev !== second.dev || + first.ino !== second.ino || + second.size !== native.size || + (second.mode & 0o777) !== native.mode || + fileDigest(owned) !== native.sha256 + ) + fail() + const temporary = path.join( + path.dirname(owned), + `.release-esbuild-${crypto.randomBytes(16).toString('hex')}` + ) + let descriptor + try { + descriptor = fs.openSync( + temporary, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW, + 0o600 + ) + const bytes = readBounded(original, MAX_FILE) + const held = fs.lstatSync(original) + if ( + bytes.length !== native.size || + sha256(bytes) !== native.sha256 || + held.dev !== first.dev || + held.ino !== first.ino || + held.nlink !== 2 || + held.size !== first.size || + held.mode !== first.mode || + held.mtimeMs !== first.mtimeMs || + held.ctimeMs !== first.ctimeMs + ) + fail() + let offset = 0 + while (offset < bytes.length) { + const written = fs.writeSync( + descriptor, + bytes, + offset, + bytes.length - offset + ) + if (!written) fail() + offset += written + } + fs.fchmodSync(descriptor, native.mode) + fs.closeSync(descriptor) + descriptor = undefined + if ( + fs.lstatSync(owned).ino !== second.ino || + fs.lstatSync(owned).dev !== second.dev + ) + fail() + fs.renameSync(temporary, owned) + const afterOriginal = fs.lstatSync(original) + const afterOwned = fs.lstatSync(owned) + if ( + afterOriginal.nlink !== 1 || + afterOwned.nlink !== 1 || + afterOwned.ino === afterOriginal.ino || + afterOwned.size !== native.size || + (afterOwned.mode & 0o777) !== native.mode || + fileDigest(original) !== native.sha256 || + fileDigest(owned) !== native.sha256 + ) + fail() + return { + kind: 'esbuild-hardlink-copy-v1', + path: ownedPath, + native_path: nativePath, + sha256: native.sha256, + size: native.size, + mode: native.mode, + } + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor) + fs.rmSync(temporary, { force: true }) + } +} + +/** Measure bounded file facts, rejecting special files, hard links and concurrent mutation. */ +export function snapshotTree(root) { + const facts = new Map() + let total = 0 + function visit(directory, depth) { + if (depth > 64) fail() + for (const name of fs.readdirSync(directory).sort(ordered)) { + if (!name || /[\\\x00-\x1f\x7f]/.test(name)) fail() + const file = path.join(directory, name) + const relative = path.relative(root, file).split(path.sep).join('/') + const stat = fs.lstatSync(file) + if (facts.size >= 100000) fail() + if (stat.isDirectory()) { + facts.set(relative, { kind: 'directory' }) + visit(file, depth + 1) + } else if (stat.isFile()) { + if ( + stat.nlink !== 1 || + stat.size > MAX_FILE || + (total += stat.size) > MAX_TREE + ) + fail() + const digest = fileDigest(file) + const after = fs.lstatSync(file) + if ( + after.dev !== stat.dev || + after.ino !== stat.ino || + after.nlink !== 1 || + after.size !== stat.size || + after.mtimeMs !== stat.mtimeMs || + after.ctimeMs !== stat.ctimeMs || + after.mode !== stat.mode + ) + fail() + facts.set(relative, { + kind: 'file', + size: stat.size, + executable: Boolean(stat.mode & 0o111), + sha256: digest, + }) + } else if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(file) + if ( + path.isAbsolute(target) || + /[\\\x00-\x1f\x7f]/.test(target) || + !inside(root, fs.realpathSync(file)) + ) + fail() + facts.set(relative, { kind: 'symlink', target }) + } else fail() + } + } + visit(root, 0) + return facts +} + +function changesAfter(before, after, owner) { + const paths = [...new Set([...before.keys(), ...after.keys()])].sort(ordered) + const changes = [] + for (const filename of paths) { + const first = before.get(filename) ?? null + const last = after.get(filename) ?? null + if (JSON.stringify(first) === JSON.stringify(last)) continue + if (!filename.startsWith(`${owner}/`)) fail() + changes.push({ path: filename, before: first, after: last }) + } + return changes +} + +/** Adapt exact pinned npm lifecycle calls before npm loads its dependency graph. */ +export function installLifecycleObserver({ + npmRoot, + installRoot, + nodePath, + nativeEsbuild, +}) { + if ( + strictJson(readBounded(path.join(npmRoot, 'package.json'))).version !== + '11.6.2' || + process.versions.node !== '24.12.0' || + fs.realpathSync(nodePath) !== fs.realpathSync(process.execPath) + ) + fail() + const lock = strictJson( + readBounded(path.join(installRoot, 'package-lock.json')) + ) + const manifest = strictJson( + readBounded(path.join(installRoot, 'package.json')) + ) + if ( + lock.lockfileVersion !== 3 || + !lock.packages || + ROOT_HOOKS.some((event) => Object.hasOwn(manifest.scripts ?? {}, event)) || + fs.existsSync(path.join(installRoot, 'binding.gyp')) + ) + fail() + const root = path.join(npmRoot, 'node_modules/@npmcli/run-script') + const entry = path.join(root, 'lib/run-script.js') + const spawnEntry = path.join(root, 'lib/make-spawn-args.js') + // Already captured imports would retain an unobserved lifecycle function. + if ( + require.cache[entry] || + require.cache[path.join(root, 'lib/run-script-pkg.js')] + ) + fail() + const makeSpawnArgs = require(spawnEntry) + const events = [] + const normalizations = [] + let active = null + let rejected = false + require.cache[spawnEntry].exports = (options) => { + if ( + !active || + options.event !== active.event || + options.path !== active.path || + options.cmd !== active.command || + options.args?.length + ) { + rejected = true + fail() + } + const result = makeSpawnArgs(options) + const spawn = result[2] + // npm prepends package bins; bind node after that transformation too. + for (const key of Object.keys(spawn.env)) { + if (/^path$/i.test(key)) + spawn.env[key] = + `${path.dirname(nodePath)}${path.delimiter}${spawn.env[key]}` + if ( + /^(?:NODE_OPTIONS|NODE_PATH|TS_RELEASE_OBSERVER|TS_RELEASE_KEY)/.test( + key + ) + ) + delete spawn.env[key] + } + // Both reviewed commands start with bare node; resolve that word independently of PATH. + result[0] = `'${nodePath.replaceAll("'", "'\\''")}'${active.command.slice(4)}` + spawn.stdio = ['ignore', 'inherit', 'inherit'] + active.spawned = true + return result + } + const original = require(entry) + const observed = async (options) => { + let row + let before + try { + if (active || !options || typeof options.path !== 'string') fail() + const owner = fs.realpathSync(options.path) + // npm invokes absent root hooks too. Preserve those real no-op calls, never a root command. + if (owner === installRoot) { + const current = strictJson( + readBounded(path.join(installRoot, 'package.json')) + ) + if ( + !ROOT_HOOKS.includes(options.event) || + options.cmd !== undefined || + options.args?.length || + ROOT_HOOKS.some((event) => + Object.hasOwn(current.scripts ?? {}, event) + ) || + fs.existsSync(path.join(installRoot, 'binding.gyp')) + ) + fail() + return await original(options) + } + const location = path + .relative(installRoot, owner) + .split(path.sep) + .join('/') + const locked = lock.packages[location] + const pkg = strictJson(readBounded(path.join(owner, 'package.json'))) + const command = pkg.scripts?.[options.event] + const expected = HOOKS.get(`${pkg.name}@${pkg.version}:${options.event}`) + const lockName = location + .split('/node_modules/') + .at(-1) + .replace(/^node_modules\//, '') + if ( + !inside(installRoot, owner) || + owner === installRoot || + !locked || + pkg.name !== lockName || + pkg.version !== locked.version || + !expected || + command !== expected || + options.cmd !== undefined || + options.args?.length || + options.pkg?.name !== pkg.name || + options.pkg?.version !== pkg.version || + options.pkg?.scripts?.[options.event] !== command || + events.some( + (event) => + event.stage === 'start' && + event.lock_path === location && + event.event === options.event + ) + ) + fail() + before = snapshotTree(installRoot) + if (nativeEsbuild && location === 'node_modules/esbuild') { + validateNativeExpectation(nativeEsbuild) + const native = before.get(`${nativeEsbuild.lock_path}/bin/esbuild`) + if ( + native?.kind !== 'file' || + native.sha256 !== nativeEsbuild.sha256 || + native.size !== nativeEsbuild.size || + native.executable !== true || + lock.packages[nativeEsbuild.lock_path]?.version !== '0.27.3' + ) + fail() + } + row = { + lock_path: location, + event: options.event, + script_sha256: sha256(command), + } + events.push({ ...row, stage: 'start', changes: [] }) + active = { ...row, path: options.path, command, spawned: false } + const result = await original(options) + if (!active.spawned || result.code !== 0 || result.signal) fail() + if (nativeEsbuild && location === 'node_modules/esbuild') { + const normalization = normalizeEsbuildHardlink({ + installRoot, + native: nativeEsbuild, + }) + if (normalization) normalizations.push(normalization) + } + const changes = changesAfter(before, snapshotTree(installRoot), location) + events.push({ + ...row, + stage: 'success', + exit_code: result.code, + signal: null, + changes, + }) + return result + } catch (error) { + rejected = true + if (row) { + let changes = [] + try { + changes = changesAfter( + before, + snapshotTree(installRoot), + row.lock_path + ) + } catch { + /* Failed observations authorize nothing. */ + } + events.push({ + ...row, + stage: 'failure', + exit_code: Number.isInteger(error?.code) ? error.code : null, + signal: typeof error?.signal === 'string' ? error.signal : null, + changes, + }) + } + throw error + } finally { + active = null + } + } + Object.assign(observed, original) + require.cache[entry].exports = observed + return { events, normalizations, failed: () => rejected || active !== null } +} + +/** Measure preparation using existing collector semantics after actual successful hooks. */ +export function buildPreparation({ repoRoot, observation }) { + if (observation.failed()) fail() + const installRoot = path.join(repoRoot, 'crates/trusted-server-js/lib') + const owners = installedOrigins({ + repoRoot, + lockFile: path.join(installRoot, 'package-lock.json'), + }) + const packages = installedMaterials(owners) + const lifecycle = observation.events.filter( + (event) => event.stage === 'success' + ) + const expected = expectedLifecycleEvents({ installRoot, packages }) + if (lifecycle.length !== expected.length) fail() + const lifecycle_events = expected.map((event) => { + if ( + !lifecycle.some( + (row) => + `npm:${row.lock_path}` === event.package_material_id && + row.event === event.event && + row.script_sha256 === event.script_sha256 + ) + ) + fail() + return event + }) + const lifecycle_outputs = lifecycle.flatMap((event) => + event.changes + .filter((change) => change.after?.kind === 'file') + .map((change) => ({ + kind: 'lifecycle-output', + id: `lifecycle-output:${event.lock_path}:${event.event}:${change.path}`, + package_material_id: `npm:${event.lock_path}`, + event: event.event, + script_sha256: event.script_sha256, + path: change.path, + size: change.after.size, + sha256: change.after.sha256, + })) + ) + const measured = actualToolFiles({ installRoot }) + const distributions = measured.map((tool) => ({ + material: { + kind: 'tool-distribution', + id: `tool:${tool.name}`, + name: tool.name, + version: tool.version, + sha256: tool.sha256, + }, + path: tool.path, + })) + const tools = measured.map((tool) => ({ + name: tool.name, + version: tool.version, + role: tool.role, + material_id: `tool:${tool.name}`, + })) + const preparation = { + schema_version: 1, + packages, + distributions, + tools, + lifecycle_events, + lifecycle_outputs, + } + validatePreparation(preparation, { installRoot, packages }) + return preparation +} + +/** Seal exact JSON bytes; child output cannot forge a valid result without the parent key. */ +export function sealObservation(value, key) { + const bytes = Buffer.from(JSON.stringify(value)) + if (bytes.length > MAX_JSON || !/^[a-f0-9]{64}$/.test(key)) fail() + const signature = crypto + .createHmac('sha256', Buffer.from(key, 'hex')) + .update(bytes) + .digest('hex') + return `TS_RELEASE_NPM_OBSERVATION_V1 ${bytes.toString('base64')} ${signature}\n` +} + +function readRequest() { + const bytes = Buffer.alloc(MAX_JSON + 1) + let count = 0 + while (count < bytes.length) { + const read = fs.readSync(0, bytes, count, bytes.length - count, null) + if (!read) break + count += read + } + if (count > MAX_JSON) fail() + return parseRequest(bytes.subarray(0, count)) +} + +function main() { + // Protect the trusted npm process before reading the parent's private key. + // The Rust supervisor authenticates and mounts this selected native guard read-only. + if ( + process.platform !== 'linux' || + process.argv.length !== 4 || + process.argv[2] !== '--guard' + ) + fail() + const guard = process.argv[3] + if ( + !path.isAbsolute(guard) || + !guard.endsWith('.node') || + fs.realpathSync(guard) !== guard || + /[\\\x00-\x1f\x7f]/.test(guard) || + guard.split('/').some((part) => part === '.' || part === '..') || + require(guard).protected !== true + ) + fail() + const request = readRequest() + const key = request.key + delete request.key + if ( + process.platform !== 'linux' || + request.guard_path !== guard || + process.cwd() !== + path.join(request.repo_root, 'crates/trusted-server-js/lib') || + process.env.NODE_OPTIONS !== undefined || + process.env.NODE_PATH !== undefined || + Object.keys(process.env).some((name) => /^npm_config_/i.test(name)) || + fs.existsSync(path.join(process.cwd(), '.npmrc')) || + fs.existsSync(path.join(request.cache, 'release-disabled-global.npmrc')) || + process.env.TRUSTED_SERVER__RELEASE_BUILD !== '1' || + process.env.TRUSTED_SERVER__GIT_VERSION !== request.source_commit + ) + fail() + const npmRoot = path.resolve( + path.dirname(process.execPath), + '../lib/node_modules/npm' + ) + const observation = installLifecycleObserver({ + npmRoot, + installRoot: process.cwd(), + nodePath: process.execPath, + nativeEsbuild: request.native_esbuild, + }) + process.once('exit', (code) => { + let preparation = null + let success = code === 0 && !observation.failed() + if (success) { + try { + preparation = buildPreparation({ + repoRoot: request.repo_root, + observation, + }) + } catch { + success = false + } + } + const summary = { + ...request, + success, + npm_exit_code: code, + events: observation.events, + normalizations: observation.normalizations, + preparation, + } + try { + fs.writeSync(1, `\n${sealObservation(summary, key)}`) + } catch { + success = false + } + if (!success) process.exitCode = 1 + }) + process.argv = [ + process.execPath, + path.join(npmRoot, 'bin/npm-cli.js'), + 'ci', + '--offline', + '--include=dev', + '--no-audit', + '--no-fund', + '--foreground-scripts', + '--ignore-scripts=false', + '--userconfig=/dev/null', + `--globalconfig=${path.join(request.cache, 'release-disabled-global.npmrc')}`, + '--script-shell=/bin/sh', + '--progress=false', + '--cache', + request.cache, + ] + require(path.join(npmRoot, 'bin/npm-cli.js')) +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + main() + } catch { + process.stderr.write('Release npm observation rejected\n') + process.exitCode = 1 + } +} diff --git a/scripts/release/tests/js-fixture.Dockerfile b/scripts/release/tests/js-fixture.Dockerfile new file mode 100644 index 0000000000..b3cb3b71e2 --- /dev/null +++ b/scripts/release/tests/js-fixture.Dockerfile @@ -0,0 +1,5 @@ +# Ordinary PR fixture only. This image does not qualify any release recipe. +FROM ubuntu:24.04 +RUN apt-get update \ + && apt-get install -y --no-install-recommends git libstdc++6 libgcc-s1 ca-certificates \ + && rm -rf /var/lib/apt/lists/* diff --git a/scripts/release/tests/prepare-js-fixture.mjs b/scripts/release/tests/prepare-js-fixture.mjs new file mode 100644 index 0000000000..c043448237 --- /dev/null +++ b/scripts/release/tests/prepare-js-fixture.mjs @@ -0,0 +1,224 @@ +/** Acquire ordinary CI fixture inputs; this does not authenticate release tools. */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..' +) +const [destinationArgument, producerArgument] = process.argv.slice(2) +if (process.platform !== 'linux' || process.version !== 'v24.12.0') + throw new Error('Full JS fixture requires Linux and Node 24.12.0') +if (!destinationArgument || !producerArgument || process.argv.length !== 4) + throw new Error( + 'Expected fresh fixture destination and source-built producer' + ) +const destination = path.resolve(destinationArgument) +const producer = fs.realpathSync(producerArgument) +if (fs.existsSync(destination)) + throw new Error('Fixture destination must be fresh') +fs.mkdirSync(destination, { recursive: true, mode: 0o700 }) +const source = path.join(destination, 'source') +const tools = path.join(destination, 'tools') +const originals = path.join(destination, 'originals') +const environment = { + PATH: process.env.PATH, + HOME: os.homedir(), + RUSTUP_TOOLCHAIN: '1.95.0', + ...(process.env.RUSTUP_HOME ? { RUSTUP_HOME: process.env.RUSTUP_HOME } : {}), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', +} +const execute = (program, args, cwd = root) => + execFileSync(program, args, { + cwd, + env: environment, + timeout: 180_000, + maxBuffer: 1024 * 1024, + encoding: 'utf8', + }).trim() +execute('git', ['clone', '--no-hardlinks', '--no-checkout', '--', root, source]) +execute('git', ['checkout', '--detach', '--force', 'HEAD'], source) +fs.writeFileSync( + path.join(source, '.git/config'), + '[core]\nrepositoryformatversion = 0\nbare = false\nfilemode = true\n' +) +const commit = execute('git', ['rev-parse', 'HEAD'], source) +const nodeRoot = path.resolve( + path.dirname(fs.realpathSync(process.execPath)), + '..' +) +const npm = JSON.parse( + fs.readFileSync(path.join(nodeRoot, 'lib/node_modules/npm/package.json')) +) +if (npm.version !== '11.6.2') throw new Error('Fixture requires npm 11.6.2') +fs.cpSync(nodeRoot, tools, { recursive: true, dereference: false }) +fs.copyFileSync(producer, path.join(tools, 'probe'), fs.constants.COPYFILE_EXCL) +fs.chmodSync(path.join(tools, 'probe'), 0o700) +if (!execute('rustc', ['--version']).startsWith('rustc 1.95.0 ')) + throw new Error('Fixture requires selected Rust 1.95.0') +execute('rustc', [ + '--crate-type', + 'cdylib', + '--edition', + '2024', + path.join(source, 'crates/trusted-server-release/src/lifecycle_guard.rs'), + '-o', + path.join(tools, 'guard.node'), +]) +execute(path.join(tools, 'bin/node'), [ + '-e', + ` + const assert = require('node:assert/strict'); + const { spawnSync } = require('node:child_process'); + assert.equal(require(process.argv[1]).protected, true); + const child = spawnSync(process.execPath, ['-e', String.raw\` + const fs = require('node:fs'); + for (const member of ['mem', 'environ', 'fd/0']) { + try { + const descriptor = fs.openSync('/proc/' + process.argv[1] + '/' + member, 'r'); + fs.closeSync(descriptor); + throw new Error('Observer process input or memory is accessible'); + } catch (error) { + if (!['EACCES', 'EPERM'].includes(error.code)) throw error; + } + } + process.stdout.write('guarded\\n'); + \`, String(process.pid)], { env: {}, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, 'guarded\\n'); +`, + path.join(tools, 'guard.node'), +]) +fs.mkdirSync(originals, { mode: 0o700 }) +const lock = JSON.parse( + fs.readFileSync( + path.join(source, 'crates/trusted-server-js/lib/package-lock.json') + ) +) +if (lock.lockfileVersion !== 3) + throw new Error('Expected locked npm v3 fixture') +const requests = new Map() +for (const [location, record] of Object.entries(lock.packages)) { + if (location === '') continue + if (!/^sha512-[A-Za-z0-9+/]{86}==$/.test(record.integrity ?? '')) + throw new Error('Fixture requires exact locked original SHA-512') + const digest = Buffer.from(record.integrity.slice(7), 'base64').toString( + 'hex' + ) + const url = new URL(record.resolved) + if ( + url.protocol !== 'https:' || + url.hostname !== 'registry.npmjs.org' || + url.port || + url.username || + url.password || + url.search || + url.hash || + url.href !== record.resolved || + !url.pathname.endsWith('.tgz') + ) + throw new Error('Fixture rejects noncanonical registry URL') + requests.set(digest, url.href) +} +let total = 0 +const maximum = 64 * 1024 * 1024 +const deadline = Date.now() + 10 * 60_000 +const queue = [...requests] +async function acquire([digest, url]) { + const cached = path.join( + os.homedir(), + '.npm/_cacache/content-v2/sha512', + digest.slice(0, 2), + digest.slice(2, 4), + digest.slice(4) + ) + let bytes + try { + const metadata = fs.lstatSync(cached) + if (metadata.isFile() && metadata.size <= maximum) { + const candidate = fs.readFileSync(cached) + if ( + crypto.createHash('sha512').update(candidate).digest('hex') === digest + ) + bytes = candidate + } + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + if (!bytes) { + const response = await fetch(url, { + redirect: 'error', + signal: AbortSignal.timeout(60_000), + }) + if ( + !response.ok || + Number(response.headers.get('content-length') ?? 0) > maximum + ) + throw new Error('Fixture original download failed or exceeds bound') + const pieces = [] + let size = 0 + for await (const piece of response.body) { + size += piece.length + if (size > maximum || Date.now() > deadline) + throw new Error('Fixture download limit exceeded') + pieces.push(piece) + } + bytes = Buffer.concat(pieces) + } + if (crypto.createHash('sha512').update(bytes).digest('hex') !== digest) + throw new Error('Fixture original differs from approved lock integrity') + total += bytes.length + if (total > 2 * 1024 ** 3 || Date.now() > deadline) + throw new Error('Fixture acquisition budget exceeded') + fs.writeFileSync(path.join(originals, `${digest}.tgz`), bytes, { + flag: 'wx', + mode: 0o600, + }) +} +await Promise.all( + Array.from({ length: 4 }, async () => { + for (;;) { + const next = queue.pop() + if (!next) return + await acquire(next) + } + }) +) +const dockerConfig = path.join(destination, 'docker-config') +fs.mkdirSync(dockerConfig, { mode: 0o700 }) +const imageFile = path.join(destination, 'image-id') +execute('/usr/bin/docker', [ + '--config', + dockerConfig, + '--host', + 'unix:///var/run/docker.sock', + 'build', + '--pull', + '--iidfile', + imageFile, + '--file', + path.join(source, 'scripts/release/tests/js-fixture.Dockerfile'), + path.join(source, 'scripts/release/tests'), +]) +const image = fs.readFileSync(imageFile, 'utf8').trim() +if (!/^sha256:[a-f0-9]{64}$/.test(image)) + throw new Error('Expected actual local fixture image identity') +fs.writeFileSync( + path.join(destination, 'fixture.json'), + JSON.stringify({ + schema_version: 1, + source_commit: commit, + original_count: requests.size, + image_id: image, + }) + '\n', + { flag: 'wx', mode: 0o600 } +) +process.stdout.write( + `Prepared ${requests.size} original-only archives for the full offline JS fixture\n` +) From e2260d457b29b1202a39f8732e78d5d4bd9c323b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 18:47:56 +0530 Subject: [PATCH 060/153] Support reviewed layouts in locked npm originals --- .github/workflows/test.yml | 1 + .../trusted-server-release/src/materials.rs | 17 +- .../src/materials/extract.rs | 71 ++++- .../src/materials/extract/compatibility.rs | 153 +++++++++ .../src/materials/extract/tests.rs | 292 ++++++++++++++++-- .../src/materials/manifest.rs | 35 ++- .../src/materials/npm_bundle.rs | 75 ++++- .../src/materials/tests.rs | 88 ++++++ .../src/preparation/tests/full.rs | 18 ++ scripts/release/js-composition.md | 33 +- scripts/release/tests/prepare-js-fixture.mjs | 6 +- 11 files changed, 740 insertions(+), 49 deletions(-) create mode 100644 crates/trusted-server-release/src/materials/extract/compatibility.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5a817047f..61f4c89ca9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -372,6 +372,7 @@ jobs: cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST" awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one full JS execution fixture"; exit 1 } }' "$TEST_LIST" cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture + cat "$TS_RELEASE_JS_FIXTURE_ROOT/js-execution-measurement.json" - name: Guard and run native JS prebuilt tests shell: bash diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs index 40cc103b56..7cf5bbd2ff 100644 --- a/crates/trusted-server-release/src/materials.rs +++ b/crates/trusted-server-release/src/materials.rs @@ -123,11 +123,24 @@ impl LockedMaterials { | ("http-proxy-agent", "7.0.2") | ("https-proxy-agent", "7.0.6") ); + let (root, node_tar_timestamps) = match (locked.name.as_str(), locked.version.as_str()) { + ("@types/chai", "5.2.3") => ("chai", true), + ("@types/deep-eql", "4.0.2") => ("deep-eql", true), + ("@types/estree", "1.0.8") => ("estree", true), + ("@types/jsdom", "27.0.0") => ("jsdom", true), + ("@types/json-schema", "7.0.15") => ("json-schema", true), + ("@types/json5", "0.0.29") => ("json5", false), + ("@types/node", "24.10.13") => ("node v24.10", true), + ("@types/tough-cookie", "4.0.5") => ("tough-cookie", true), + _ => ("package", false), + }; extract::extract( original, extract::Selection { - root: "package", + root, index_alias, + node_tar_timestamps, + local_pax: locked.name == "gulp-wrap" && locked.version == "0.15.0", }, ) } @@ -156,6 +169,8 @@ impl LockedMaterials { extract::Selection { root: &format!("{name}-{version}"), index_alias: false, + node_tar_timestamps: false, + local_pax: false, }, ) } diff --git a/crates/trusted-server-release/src/materials/extract.rs b/crates/trusted-server-release/src/materials/extract.rs index 7c15ba0b5c..a5101b67c3 100644 --- a/crates/trusted-server-release/src/materials/extract.rs +++ b/crates/trusted-server-release/src/materials/extract.rs @@ -19,10 +19,16 @@ use crate::error::ReleaseError; pub(super) struct Selection<'a> { pub(super) root: &'a str, pub(super) index_alias: bool, + pub(super) node_tar_timestamps: bool, + pub(super) local_pax: bool, } +mod compatibility; + const DECODED_LIMIT: u64 = 512 * 1024 * 1024; -const MEMBER_LIMIT: u64 = 64 * 1024 * 1024; +// The approved npm lock includes a 110,275,248-byte optional native member. +// Keep the compressed-original and total decoded limits independent. +const MEMBER_LIMIT: u64 = 128 * 1024 * 1024; const ENTRY_LIMIT: usize = 100_000; const DEPTH_LIMIT: usize = 64; const PATH_LIMIT: usize = 4096; @@ -242,11 +248,19 @@ fn field(bytes: &[u8]) -> Result<&str, Report> { } /// Uses raw fields because [`tar::Entry::path_bytes`] normalizes backslashes. -fn raw_name(header: &tar::Header) -> Result> { +fn raw_name( + header: &tar::Header, + selection: &Selection<'_>, +) -> Result> { let name = field(&header.as_old().name)?; match header.as_ustar() { Some(ustar) => { - let prefix = field(&ustar.prefix)?; + let prefix = + if selection.node_tar_timestamps && ustar.prefix.iter().any(|byte| *byte != 0) { + compatibility::timestamp_prefix(header)? + } else { + field(&ustar.prefix)? + }; Ok(if prefix.is_empty() { name.to_owned() } else { @@ -333,7 +347,7 @@ impl State { "TAR member is not a regular file or directory", )); } - let raw = raw_name(header)?; + let raw = raw_name(header, selection)?; let name = member_name(&raw, directory, selection)?; if !self.spellings.insert(raw.clone()) { return Err(material_error("TAR repeats a member spelling")); @@ -341,10 +355,15 @@ impl State { self.portable_name(&name)?; let mode = header.mode().map_err(material_error)?; let size = entry.size(); - if mode & !0o777 != 0 || size > MEMBER_LIMIT || (directory && size != 0) { - return Err(material_error( - "TAR member has privileged mode or invalid size", - )); + if mode & !0o777 != 0 || (directory && size != 0) { + return Err(material_error(format!( + "TAR member `{name}` has privileged mode {mode:o} or invalid size {size}", + ))); + } + if size > MEMBER_LIMIT { + return Err(material_error(format!( + "TAR member `{name}` size {size} exceeds member byte limit {MEMBER_LIMIT}", + ))); } let path = self.directory.path().join(&name); if directory { @@ -458,6 +477,7 @@ fn extract_bounded( first_spelling: BTreeMap::new(), directories: BTreeSet::new(), }; + let mut pending_pax: Option = None; for (index, entry) in archive .entries() .map_err(material_error)? @@ -467,7 +487,40 @@ fn extract_bounded( if index >= entry_limit { return Err(material_error("TAR exceeds entry count limit")); } - state.entry(entry.map_err(material_error)?, &selection)?; + let mut entry = entry.map_err(material_error)?; + if entry.header().entry_type().as_byte() == b'x' { + if !selection.local_pax || pending_pax.is_some() || entry.size() > 64 * 1024 { + return Err(material_error( + "unreviewed, consecutive or oversized local PAX header", + )); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(material_error)?; + let pax = compatibility::parse_pax(&bytes)?; + if raw_name(entry.header(), &selection)? != pax.header_name() + || entry.header().mode().map_err(material_error)? & !0o777 != 0 + || !field(&entry.header().as_old().linkname)?.is_empty() + { + return Err(material_error("unsafe reviewed local PAX header")); + } + pending_pax = Some(pax); + continue; + } + if let Some(pax) = pending_pax.take() { + if !entry.header().entry_type().is_file() { + return Err(material_error( + "local PAX must immediately precede a regular file", + )); + } + pax.join( + &raw_name(entry.header(), &selection)?, + entry.header().size().map_err(material_error)?, + )?; + } + state.entry(entry, &selection)?; + } + if pending_pax.is_some() { + return Err(material_error("dangling local PAX header")); } if state.spellings.contains("package/./dist/index.js") && !state.spellings.contains("package/dist/index.js") diff --git a/crates/trusted-server-release/src/materials/extract/compatibility.rs b/crates/trusted-server-release/src/materials/extract/compatibility.rs new file mode 100644 index 0000000000..42a1a1ec4c --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/compatibility.rs @@ -0,0 +1,153 @@ +//! Closed compatibility for authenticated, exact-version npm originals. +use super::{field, material_error}; +use crate::error::ReleaseError; +use error_stack::Report; +use std::collections::BTreeMap; + +pub(super) fn timestamp_prefix(header: &tar::Header) -> Result<&str, Report> { + let bytes = header.as_bytes(); + if &bytes[257..265] != b"ustar\x0000" + || bytes[345..476].iter().any(|byte| *byte != 0) + || bytes[500..].iter().any(|byte| *byte != 0) + || [476, 488].iter().any(|start| { + bytes[*start..*start + 11] + .iter() + .any(|byte| !matches!(byte, b'0'..=b'7')) + || bytes[*start + 11] != 0 + }) + { + return Err(material_error("invalid reviewed node-tar timestamp layout")); + } + field(&bytes[345..475]) +} + +pub(super) struct LocalPax { + path: String, + size: u64, +} +impl LocalPax { + pub(super) fn header_name(&self) -> String { + format!("PaxHeader/{}", self.path) + } + pub(super) fn join(&self, path: &str, size: u64) -> Result<(), Report> { + if self.path != path || self.size != size { + return Err(material_error( + "local PAX path or size would override raw member", + )); + } + Ok(()) + } +} +const KEYS: &[&str] = &[ + "NODETAR.blksize", + "NODETAR.blocks", + "NODETAR.depth", + "NODETAR.follow", + "NODETAR.ignoreFiles.0", + "NODETAR.ignoreFiles.1", + "NODETAR.ignoreFiles.2", + "NODETAR.package.author.email", + "NODETAR.package.author.name", + "NODETAR.package.author.url", + "NODETAR.package.contributors.0.email", + "NODETAR.package.contributors.0.name", + "NODETAR.package.contributors.0.url", + "NODETAR.package.dependencies.consolidate", + "NODETAR.package.dependencies.es6-promise", + "NODETAR.package.dependencies.fs-readfile-promise", + "NODETAR.package.dependencies.js-yaml", + "NODETAR.package.dependencies.lodash", + "NODETAR.package.dependencies.node.extend", + "NODETAR.package.dependencies.plugin-error", + "NODETAR.package.dependencies.through2", + "NODETAR.package.dependencies.tryit", + "NODETAR.package.dependencies.vinyl-bufferstream", + "NODETAR.package.description", + "NODETAR.package.devDependencies.eslint", + "NODETAR.package.devDependencies.expect.js", + "NODETAR.package.devDependencies.istanbul", + "NODETAR.package.devDependencies.istanbul-coveralls", + "NODETAR.package.devDependencies.jscs", + "NODETAR.package.devDependencies.mocha", + "NODETAR.package.devDependencies.simple-bufferstream", + "NODETAR.package.devDependencies.vinyl", + "NODETAR.package.engines.node", + "NODETAR.package.engines.npm", + "NODETAR.package.files.0", + "NODETAR.package.jscsConfig.maximumLineLength", + "NODETAR.package.jscsConfig.preset", + "NODETAR.package.jscsConfig.requireBlocksOnNewline", + "NODETAR.package.jscsConfig.validateLineBreaks", + "NODETAR.package.keywords.0", + "NODETAR.package.keywords.1", + "NODETAR.package.keywords.2", + "NODETAR.package.keywords.3", + "NODETAR.package.keywords.4", + "NODETAR.package.keywords.5", + "NODETAR.package.licenses.0.type", + "NODETAR.package.licenses.0.url", + "NODETAR.package.name", + "NODETAR.package.repository", + "NODETAR.package.scripts.coverage", + "NODETAR.package.scripts.coveralls", + "NODETAR.package.scripts.pretest", + "NODETAR.package.scripts.test", + "NODETAR.package.version", + "NODETAR.type", + "SCHILY.dev", + "SCHILY.ino", + "SCHILY.nlink", + "gid", + "path", + "size", + "uid", +]; +pub(super) fn parse_pax(mut bytes: &[u8]) -> Result> { + let mut values = BTreeMap::new(); + while !bytes.is_empty() { + let space = bytes + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| material_error("invalid PAX length framing"))?; + let digits = std::str::from_utf8(&bytes[..space]).map_err(material_error)?; + if digits.is_empty() + || digits.starts_with('0') + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(material_error("noncanonical PAX length")); + } + let length = digits.parse::().map_err(material_error)?; + if length <= space + 2 || length > bytes.len() || bytes[length - 1] != b'\n' { + return Err(material_error("invalid PAX record length")); + } + let record = std::str::from_utf8(&bytes[space + 1..length - 1]).map_err(material_error)?; + let (key, value) = record + .split_once('=') + .ok_or_else(|| material_error("invalid PAX assignment"))?; + if !KEYS.contains(&key) + || value.contains(['\0', '\n', '\r']) + || values.insert(key, value).is_some() + { + return Err(material_error("unknown or repeated PAX metadata field")); + } + bytes = &bytes[length..]; + } + let path = values + .get("path") + .ok_or_else(|| material_error("PAX lacks path"))? + .to_string(); + let size = values + .get("size") + .ok_or_else(|| material_error("PAX lacks size"))?; + if size.is_empty() + || (size.len() > 1 && size.starts_with('0')) + || !size.bytes().all(|byte| byte.is_ascii_digit()) + || values.get("NODETAR.type") != Some(&"File") + { + return Err(material_error("invalid PAX size or member type")); + } + Ok(LocalPax { + path, + size: size.parse().map_err(material_error)?, + }) +} diff --git a/crates/trusted-server-release/src/materials/extract/tests.rs b/crates/trusted-server-release/src/materials/extract/tests.rs index 3afd8abef1..f7d259c78a 100644 --- a/crates/trusted-server-release/src/materials/extract/tests.rs +++ b/crates/trusted-server-release/src/materials/extract/tests.rs @@ -45,6 +45,10 @@ fn tar_bytes(entries: &[(&str, u8, u32, &[u8])]) -> Vec { } fn original(bytes: &[u8]) -> VerifiedOriginal { + original_with_timeout(bytes, Duration::from_secs(10)) +} + +fn original_with_timeout(bytes: &[u8], timeout: Duration) -> VerifiedOriginal { let directory = tempfile::tempdir().expect("should create archive directory"); let path = directory.path().join("original.tgz"); fs::write(&path, bytes).expect("should write archive"); @@ -53,7 +57,7 @@ fn original(bytes: &[u8]) -> VerifiedOriginal { Expected::Sha256(&hex::encode(Sha256::digest(bytes))), &"a".repeat(40), "npm:node_modules/fictional-package", - Instant::now() + Duration::from_secs(10), + Instant::now() + timeout, &Cancellation::default(), ) .expect("should verify original bytes") @@ -72,6 +76,8 @@ fn extracts_verified_regular_files_and_preserves_original_bytes() { Selection { root: "package", index_alias: false, + node_tar_timestamps: false, + local_pax: false, }, ) .expect("should extract verified regular file"); @@ -88,6 +94,45 @@ fn extracts_verified_regular_files_and_preserves_original_bytes() { assert_eq!(retained, bytes, "should preserve exact compressed bytes"); } +#[test] +fn accepts_bounded_native_members_above_64_mib() { + let size = 64 * 1024 * 1024 + 1; + let encoder = GzEncoder::new(Vec::new(), Compression::fast()); + let mut builder = tar::Builder::new(encoder); + let mut header = tar::Header::new_ustar(); + header.set_mode(0o644); + header.set_size(size); + header.set_cksum(); + builder + .append_data(&mut header, "package/native.node", io::repeat(0).take(size)) + .expect("should stream a bounded large native member"); + let bytes = builder + .into_inner() + .expect("should finish native fixture TAR") + .finish() + .expect("should finish native fixture gzip"); + let extracted = extract( + original_with_timeout(&bytes, Duration::from_secs(60)), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + }, + ) + .expect("should support authenticated native members larger than 64 MiB"); + assert_eq!( + fs::metadata(extracted.root().join("native.node")) + .expect("should inspect native member") + .len(), + size, + "should retain every original native byte" + ); + extracted + .verify_unchanged() + .expect("should stream recheck the entire large native member"); +} + #[test] fn rejects_unsafe_paths_types_modes_duplicates_and_collisions() { for entries in [ @@ -124,7 +169,9 @@ fn rejects_unsafe_paths_types_modes_duplicates_and_collisions() { original(&compressed(&tar_bytes(&entries))), Selection { root: "package", - index_alias: false + index_alias: false, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -142,7 +189,9 @@ fn rejects_excessive_depth_member_size_and_cancelled_extraction() { original(&bytes), Selection { root: "package", - index_alias: false + index_alias: false, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -154,16 +203,23 @@ fn rejects_excessive_depth_member_size_and_cancelled_extraction() { header.set_size(MEMBER_LIMIT + 1); header.set_cksum(); tar[..512].copy_from_slice(header.as_bytes()); + let rejected = extract( + original(&compressed(&tar)), + Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + }, + ) + .err() + .expect("should reject oversized member before reading body"); + let diagnostic = format!("{rejected:?}"); assert!( - extract( - original(&compressed(&tar)), - Selection { - root: "package", - index_alias: false - } - ) - .is_err(), - "should reject oversized member before reading body" + diagnostic.contains("package/file") + && diagnostic.contains("134217729") + && diagnostic.contains("134217728"), + "should identify the rejected member, actual size and enforced bound" ); let bytes = compressed(&tar_bytes(&[("package/file", b'0', 0o644, b"x")])); let cancellation = Cancellation::default(); @@ -186,7 +242,9 @@ fn rejects_excessive_depth_member_size_and_cancelled_extraction() { original, Selection { root: "package", - index_alias: false + index_alias: false, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -206,7 +264,9 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { original(&compressed(&tar_bytes(&pair[..1]))), Selection { root: "package", - index_alias: true + index_alias: true, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -217,7 +277,9 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { original(&bytes), Selection { root: "package", - index_alias: true + index_alias: true, + node_tar_timestamps: false, + local_pax: false, } ) .is_ok(), @@ -228,7 +290,9 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { original(&bytes), Selection { root: "package", - index_alias: false + index_alias: false, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -241,7 +305,9 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { original(&compressed(&tar_bytes(&changed))), Selection { root: "package", - index_alias: true + index_alias: true, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -254,7 +320,9 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { original(&compressed(&tar_bytes(&third))), Selection { root: "package", - index_alias: true + index_alias: true, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -269,7 +337,9 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { original(&compressed(&tar_bytes(&directory_alias))), Selection { root: "package", - index_alias: true + index_alias: true, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -303,7 +373,9 @@ fn rejects_hidden_data_bad_trailers_concatenation_and_truncation() { original(&bad), Selection { root: "package", - index_alias: false + index_alias: false, + node_tar_timestamps: false, + local_pax: false, } ) .is_err(), @@ -439,7 +511,9 @@ fn enforces_decoded_byte_and_entry_budgets() { original(&bytes), Selection { root: "package", - index_alias: false + index_alias: false, + node_tar_timestamps: false, + local_pax: false, }, 1 ) @@ -471,6 +545,8 @@ fn extracted_tree_review_rejects_changed_or_added_members() { Selection { root: "package", index_alias: false, + node_tar_timestamps: false, + local_pax: false, }, ) .expect("should extract original"); @@ -548,6 +624,8 @@ fn extraction_recheck_rejects_root_alias_and_missing_empty_directory() { Selection { root: "package", index_alias: false, + node_tar_timestamps: false, + local_pax: false, }, ) .expect("should extract original"); @@ -569,3 +647,175 @@ fn extraction_recheck_rejects_root_alias_and_missing_empty_directory() { "should reject replaced extraction root even with identical members" ); } + +#[test] +fn accepts_only_reviewed_node_tar_timestamp_layout() { + let mut header = tar::Header::new_ustar(); + header.as_mut_bytes()[476..488].copy_from_slice(b"15075543223\0"); + header.as_mut_bytes()[488..500].copy_from_slice(b"15075543223\0"); + header.as_mut_bytes()[..12].copy_from_slice(b"chai/file.js"); + let selection = Selection { + root: "chai", + index_alias: false, + node_tar_timestamps: true, + local_pax: false, + }; + assert_eq!( + raw_name(&header, &selection).expect("should parse reviewed layout"), + "chai/file.js" + ); + assert!( + raw_name( + &header, + &Selection { + node_tar_timestamps: false, + ..selection + } + ) + .is_err(), + "should reject layout outside approved tuple" + ); + header.as_mut_bytes()[475] = b'x'; + assert!( + raw_name(&header, &selection).is_err(), + "should reject hidden prefix bytes" + ); + header.as_mut_bytes()[475] = 0; + header.as_mut_bytes()[476] = b'9'; + assert!( + raw_name(&header, &selection).is_err(), + "should reject nonoctal timestamps" + ); +} + +fn pax_record(key: &str, value: &str) -> Vec { + let content = format!(" {key}={value}\n"); + let mut length = content.len() + 1; + loop { + let next = content.len() + length.to_string().len(); + if next == length { + return format!("{length}{content}").into_bytes(); + } + length = next; + } +} +#[test] +fn reviewed_local_pax_never_overrides_member_selection() { + let mut body = pax_record("path", "package/file.js"); + body.extend(pax_record("size", "3")); + body.extend(pax_record("NODETAR.type", "File")); + let parsed = compatibility::parse_pax(&body).expect("should parse reviewed metadata"); + assert!( + parsed.join("package/file.js", 3).is_ok(), + "should accept redundant selectors" + ); + assert!( + parsed.join("package/other.js", 3).is_err(), + "should reject path override" + ); + assert!( + parsed.join("package/file.js", 4).is_err(), + "should reject size override" + ); + for (key, value) in [ + ("linkpath", "package/other"), + ("GNU.sparse.size", "3"), + ("size", "03"), + ] { + let mut invalid = body.clone(); + invalid.extend(pax_record(key, value)); + assert!( + compatibility::parse_pax(&invalid).is_err(), + "should reject semantic or duplicate fields" + ); + } + assert!( + compatibility::parse_pax(b"99 path=package/file.js\n").is_err(), + "should reject malformed framing" + ); +} + +#[test] +fn reviewed_pax_is_local_bounded_and_never_redirects_extraction() { + let metadata = |path: &str, size: &str| { + let mut bytes = pax_record("path", path); + bytes.extend(pax_record("size", size)); + bytes.extend(pax_record("NODETAR.type", "File")); + bytes + }; + let valid = metadata("package/file.js", "3"); + let selection = Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: true, + }; + let archive = compressed(&tar_bytes(&[ + ("PaxHeader/package/file.js", b'x', 0o644, &valid), + ("package/file.js", b'0', 0o644, b"abc"), + ])); + let extracted = + extract(original(&archive), selection).expect("should extract redundant local metadata"); + extracted + .verify_unchanged() + .expect("should preserve regular member facts"); + let bad_path = metadata("package/other.js", "3"); + let bad_size = metadata("package/file.js", "4"); + let cases = [ + vec![("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice())], + vec![ + ("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice()), + ("PaxHeader/other.js", b'x', 0o644, valid.as_slice()), + ], + vec![ + ("PaxHeader/package/file.js", b'g', 0o644, valid.as_slice()), + ("package/file.js", b'0', 0o644, b"abc"), + ], + vec![ + ( + "PaxHeader/package/file.js", + b'x', + 0o644, + bad_path.as_slice(), + ), + ("package/file.js", b'0', 0o644, b"abc"), + ], + vec![ + ( + "PaxHeader/package/file.js", + b'x', + 0o644, + bad_size.as_slice(), + ), + ("package/file.js", b'0', 0o644, b"abc"), + ], + ]; + for entries in cases { + let bytes = compressed(&tar_bytes(&entries)); + assert!( + extract(original(&bytes), selection).is_err(), + "should reject nonlocal or overriding metadata" + ); + } + for (name, mode) in [ + ("PaxHeader/../file.js", 0o644), + ("PaxHeader/package/file.js", 0o4644), + ] { + let bytes = compressed(&tar_bytes(&[ + (name, b'x', mode, &valid), + ("package/file.js", b'0', 0o644, b"abc"), + ])); + assert!( + extract(original(&bytes), selection).is_err(), + "should reject unsafe PAX header itself" + ); + } + let unreviewed = Selection { + local_pax: false, + ..selection + }; + assert!( + extract(original(&archive), unreviewed).is_err(), + "should reject PAX outside exact approved tuple" + ); +} diff --git a/crates/trusted-server-release/src/materials/manifest.rs b/crates/trusted-server-release/src/materials/manifest.rs index eaacb0c538..37bd717325 100644 --- a/crates/trusted-server-release/src/materials/manifest.rs +++ b/crates/trusted-server-release/src/materials/manifest.rs @@ -159,15 +159,9 @@ fn scripts(document: &Value) -> Result>, Report Ok(None), - Some(Value::Object(scripts)) - if scripts - .values() - .all(|value| value.as_str().is_some_and(|value| !value.is_empty())) => - { - Ok(Some(scripts)) - } + Some(Value::Object(scripts)) if scripts.values().all(Value::is_string) => Ok(Some(scripts)), Some(_) => Err(material_error( - "manifest scripts must contain nonempty command strings", + "manifest scripts must contain command strings", )), } } @@ -236,6 +230,31 @@ mod tests { use super::*; + #[test] + fn empty_development_script_does_not_authorize_a_lifecycle_hook() { + let mut manifest = + json!({"name":"fictional-package","version":"1.0.0","scripts":{"test":""}}); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false) + .expect("should accept an unused empty development script") + .is_empty(), + "should declare no install execution from a development script" + ); + assert!( + review_root_document(&manifest, false).is_ok(), + "should allow a root development script without lifecycle declarations" + ); + manifest["scripts"]["postinstall"] = json!(""); + assert!( + declared_hooks(&manifest, "fictional-package", "1.0.0", false).is_err(), + "should still reject an unreviewed empty lifecycle declaration" + ); + assert!( + review_root_document(&manifest, false).is_err(), + "should still reject root lifecycle declarations" + ); + } + #[test] fn root_review_allows_build_scripts_but_rejects_all_ci_hooks() { let mut manifest = json!({"name":"fictional-project", "scripts":{"build":"node build.mjs", "test":"vitest run"}}); diff --git a/crates/trusted-server-release/src/materials/npm_bundle.rs b/crates/trusted-server-release/src/materials/npm_bundle.rs index a7f38826ff..a6c8716bc5 100644 --- a/crates/trusted-server-release/src/materials/npm_bundle.rs +++ b/crates/trusted-server-release/src/materials/npm_bundle.rs @@ -129,11 +129,24 @@ impl LockedMaterials { for (location, locked) in &self.npm { check_attempt(deadline, cancellation)?; let name = format!("{}.tgz", hex::encode(locked.sha512)); - let verified = - self.verify_npm(location, &inputs.join(&name), deadline, cancellation)?; + let verified = self + .verify_npm(location, &inputs.join(&name), deadline, cancellation) + .map_err(|report| { + report.attach(format!( + "authenticating locked npm package `{location}` original `{name}`" + )) + })?; let expected_sha256 = verified.sha256().to_owned(); - let extracted = self.extract_npm(location, verified)?; - let mut reviewed = self.review_npm(location, extracted)?; + let extracted = self.extract_npm(location, verified).map_err(|report| { + report.attach(format!( + "extracting authenticated locked npm package `{location}`" + )) + })?; + let mut reviewed = self.review_npm(location, extracted).map_err(|report| { + report.attach(format!( + "reviewing authenticated locked npm package `{location}`" + )) + })?; reviewed.verify_unchanged()?; if let Entry::Vacant(entry) = originals.entry(name) { let name = entry.key(); @@ -243,4 +256,58 @@ mod tests { "should reject oversized retained archives before allocation" ); } + + #[test] + #[ignore = "requires explicitly supplied current-lock original archives; no acquisition qualification"] + fn actual_current_lock_npm_originals() { + let inputs = std::env::var_os("TS_RELEASE_NPM_ORIGINALS") + .map(PathBuf::from) + .expect("should explicitly supply current locked compressed originals"); + let bytes = include_bytes!("../../../trusted-server-js/lib/package-lock.json"); + let npm = crate::materials::locked::parse_npm(bytes) + .expect("should parse every actual current npm lock identity"); + let count = npm.len(); + let archives: std::collections::BTreeSet<_> = + npm.values().map(|entry| entry.sha512).collect(); + assert!(count > 0, "should select nonzero current locked packages"); + let locked = LockedMaterials { + // This original-format diagnostic supplies no official source approval. + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + npm_lock_sha256: hex::encode(Sha256::digest(bytes)), + cargo: crate::materials::locked::CargoMaterials { + registry: BTreeMap::new(), + git: std::collections::BTreeSet::new(), + }, + npm, + }; + let prepared = locked + .prepare_npm_originals( + &inputs, + Instant::now() + Duration::from_secs(600), + &Cancellation::default(), + ) + .expect("should authenticate, extract and review every actual current npm original"); + assert_eq!( + prepared.reviews.len(), + count, + "should review every locked package including optional platform entries" + ); + assert_eq!( + prepared.archive_paths().len(), + archives.len(), + "should retain every distinct original compressed distribution" + ); + assert_eq!( + prepared.source_binding(), + ( + "a".repeat(40).as_str(), + hex::encode(Sha256::digest(bytes)).as_str() + ), + "should retain the diagnostic source and exact current raw lock binding" + ); + prepared + .verify_unchanged() + .expect("should rejoin every retained original and complete reviewed extraction"); + } } diff --git a/crates/trusted-server-release/src/materials/tests.rs b/crates/trusted-server-release/src/materials/tests.rs index efcb61cd98..f529d6d692 100644 --- a/crates/trusted-server-release/src/materials/tests.rs +++ b/crates/trusted-server-release/src/materials/tests.rs @@ -1,6 +1,7 @@ use crate::process::Cancellation; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; +use flate2::{Compression, write::GzEncoder}; use serde_json::json; use sha2::{Digest as _, Sha512}; use std::ffi::CString; @@ -10,6 +11,93 @@ use std::time::{Duration, Instant}; use super::*; +fn compatibility_original(root: &str, local_pax: bool) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::fast()); + let mut builder = tar::Builder::new(encoder); + if local_pax { + let mut body = String::new(); + for assignment in ["path=package/file", "size=1", "NODETAR.type=File"] { + let mut length = assignment.len() + 3; + loop { + let record = format!("{length} {assignment}\n"); + if record.len() == length { + body.push_str(&record); + break; + } + length = record.len(); + } + } + let mut header = tar::Header::new_ustar(); + header.set_mode(0o644); + header.set_entry_type(tar::EntryType::XHeader); + header.set_size(body.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "PaxHeader/package/file", body.as_bytes()) + .expect("should append fictional local metadata"); + } + let mut header = tar::Header::new_ustar(); + header.set_mode(0o644); + header.set_size(1); + header.set_cksum(); + builder + .append_data(&mut header, format!("{root}/file"), b"x".as_slice()) + .expect("should append fictional regular member"); + builder + .into_inner() + .expect("should finish fictional TAR") + .finish() + .expect("should finish fictional gzip") +} + +#[test] +fn legacy_root_and_pax_compatibility_require_exact_locked_versions() { + for (name, accepted, rejected, root, pax) in [ + ("@types/chai", "5.2.3", "5.2.4", "chai", false), + ("@types/node", "24.10.13", "24.10.14", "node v24.10", false), + ("gulp-wrap", "0.15.0", "0.15.1", "package", true), + ] { + let bytes = compatibility_original(root, pax); + let directory = tempfile::tempdir().expect("should own fictional distribution"); + let path = directory.path().join("original.tgz"); + std::fs::write(&path, &bytes).expect("should retain fictional original"); + for (version, allowed) in [(accepted, true), (rejected, false)] { + let location = format!("node_modules/{name}"); + let locked = LockedMaterials { + source_commit: "a".repeat(40), + cargo_lock_sha256: "b".repeat(64), + npm_lock_sha256: "c".repeat(64), + cargo: CargoMaterials { + registry: BTreeMap::new(), + git: Default::default(), + }, + npm: BTreeMap::from([( + location.clone(), + NpmMaterial { + name: name.to_owned(), + version: version.to_owned(), + resolved: "https://example.com/fixture.tgz".to_owned(), + sha512: Sha512::digest(&bytes).into(), + }, + )]), + }; + let original = locked + .verify_npm( + &location, + &path, + Instant::now() + Duration::from_secs(10), + &Cancellation::default(), + ) + .expect("should authenticate exact fictional bytes before selection"); + assert_eq!( + locked.extract_npm(&location, original).is_ok(), + allowed, + "should restrict legacy extraction to reviewed package/version: {name}@{version}" + ); + } + } +} + fn npm_lock(integrity: &str) -> Vec { serde_json::to_vec(&json!({ "lockfileVersion": 3, diff --git a/crates/trusted-server-release/src/preparation/tests/full.rs b/crates/trusted-server-release/src/preparation/tests/full.rs index a89e40b430..eff769bad2 100644 --- a/crates/trusted-server-release/src/preparation/tests/full.rs +++ b/crates/trusted-server-release/src/preparation/tests/full.rs @@ -113,6 +113,7 @@ fn real_two_fresh_js_builds() { ] }) .collect(); + let module_count = prepared.module_ids.len(); let completed=prepared.run([&first,&second],[&first_proof,&second_proof]).expect("should complete both actual fresh offline installations, lifecycle observations, negative tool controls, full suites and final-byte equality"); assert!( owned_roots.iter().all(|root| !root.exists()), @@ -145,4 +146,21 @@ fn real_two_fresh_js_builds() { completed .rehash_for_handoff() .expect("should rejoin retained A bytes before downstream handoff"); + let measurement = json!({ + "schema_version": 1, + "kind": "ordinary-ci-two-fresh-js-builds", + "source_commit": completed.source_commit(), + "test_counts": completed.test_counts(), + "module_count": module_count, + "tools_sha256": completed.tools_sha256(), + "settings_sha256": completed.settings_sha256(), + "inventory_sha256": completed.inventory_sha256(), + "private_roots_removed": owned_roots.len(), + "release_qualification": false, + }); + fs::write( + fixture.join("js-execution-measurement.json"), + serde_json::to_vec_pretty(&measurement).expect("should encode actual CI measurement"), + ) + .expect("should retain actual CI measurement after successful handoff"); } diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index 343dfeb5fb..f0124460a9 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -132,7 +132,27 @@ exception for three exact locked npm archives containing identical http-proxy-agent 7.0.2 and https-proxy-agent 7.0.6. Resolve their original locked SHA-512 identities and require matching regular type, size, mode and bytes; materialize the canonical file once. Other duplicates, dot aliases, traversal, -links, devices and privileged modes fail. Registry archive SHA-256/exact-root and +links, devices and privileged modes fail. Eight exact locked type-package +versions use reviewed legacy roots: `@types/chai` 5.2.3 (`chai`), +`@types/deep-eql` 4.0.2 (`deep-eql`), `@types/estree` 1.0.8 (`estree`), +`@types/jsdom` 27.0.0 (`jsdom`), `@types/json-schema` 7.0.15 (`json-schema`), +`@types/json5` 0.0.29 (`json5`), `@types/node` 24.10.13 (`node v24.10`), +and `@types/tough-cookie` 4.0.5 (`tough-cookie`). Seven of these originals +(all except json5) may use the observed node-tar timestamp layout: empty +130-byte prefix, a zero separator, two eleven-digit octal timestamps with +NUL terminators, and zero remaining reserved bytes. No timestamp propagates +to extracted files. Other hidden name/prefix bytes fail. + +Only locked `gulp-wrap` 0.15.0 may carry local PAX metadata. Each bounded +record uses the reviewed metadata-key allowlist; duplicate, unknown and +malformed fields fail. Its path and size must exactly match the immediately +following raw regular-file header, and its own name must be +`PaxHeader/`. Global, consecutive and dangling records and any +path/size override fail. These exceptions apply only after original-byte +authentication and exact name/version selection; they are not general +extension support. + +Registry archive SHA-256/exact-root and Git revision/whole-tree validation remain available to Cargo preparation. Query selected Git object identity/type/size before body acquisition: reject objects over 32 MiB or cumulative bodies over 512 MiB, then rejoin exact body headers. @@ -140,13 +160,16 @@ This reader assumes the sanitized trusted Git implementation and an immutable object store, rather than claiming adversarial race-proof streaming. The Rust dependency archive inspector limits each authenticated compressed -original to 64 MiB, decoded TAR bytes to 512 MiB, each member to 64 MiB, +original to 64 MiB, decoded TAR bytes to 512 MiB, each member to 128 MiB, entries to 100,000, path depth to 64 and path text to 4,096 bytes. It accepts ASCII regular-file and directory names under the exact locked root, rejects -case aliases and unsupported extension records (including PAX, GNU long-name -and sparse records), and requires a single complete gzip member with complete +case aliases and unsupported extension records (including unreviewed PAX, +GNU long-name and sparse records), and requires a single complete gzip member with complete TAR zero terminators and no hidden trailing data. These are dependency archive -limits, separate from toolchain distributions and Git object limits. Extraction +limits, separate from toolchain distributions and Git object limits. The +128 MiB member limit accommodates the current locked optional OpenBSD Rollup +binary expands to 110,275,248 bytes. Hashing and extraction remain streamed; +this does not increase the compressed-original or total decoded-byte limits. Extraction owns a fresh private directory and preserves original compressed bytes. File permissions become owner read/write plus the archive's executable bits; archive ownership, timestamps and other permission bits do not propagate. This stage diff --git a/scripts/release/tests/prepare-js-fixture.mjs b/scripts/release/tests/prepare-js-fixture.mjs index c043448237..354c7fc669 100644 --- a/scripts/release/tests/prepare-js-fixture.mjs +++ b/scripts/release/tests/prepare-js-fixture.mjs @@ -57,7 +57,11 @@ const npm = JSON.parse( fs.readFileSync(path.join(nodeRoot, 'lib/node_modules/npm/package.json')) ) if (npm.version !== '11.6.2') throw new Error('Fixture requires npm 11.6.2') -fs.cpSync(nodeRoot, tools, { recursive: true, dereference: false }) +fs.cpSync(nodeRoot, tools, { + recursive: true, + dereference: false, + verbatimSymlinks: true, +}) fs.copyFileSync(producer, path.join(tools, 'probe'), fs.constants.COPYFILE_EXCL) fs.chmodSync(path.join(tools, 'probe'), 0o700) if (!execute('rustc', ['--version']).startsWith('rustc 1.95.0 ')) From cbd96047400099271534f110b5decdcf73b1a5c8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 19:22:11 +0530 Subject: [PATCH 061/153] Optimize full JavaScript acceptance checks with test safety retained --- .github/workflows/test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61f4c89ca9..d1d58ac83f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -364,6 +364,11 @@ jobs: - name: Prove two fresh offline JS builds (Linux) if: runner.os == 'Linux' shell: bash + env: + # Optimize repeated whole-input hashes with test safety checks retained. + CARGO_PROFILE_TEST_OPT_LEVEL: '2' + CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true' + CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true' run: | set -euo pipefail TEST_NAME=preparation::tests::full::real_two_fresh_js_builds From 1a2c46075e18791b623f26ea73af8f833dc5f70b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 19:31:47 +0530 Subject: [PATCH 062/153] Preserve approved source context in collector rejection fixtures --- .../lib/test/release-composition.test.mjs | 4 + .../trusted-server-release/src/javascript.rs | 94 ++++++++++++++++++- .../src/javascript/tests.rs | 49 ++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/test/release-composition.test.mjs b/crates/trusted-server-js/lib/test/release-composition.test.mjs index 545847d645..49e6e0ebd5 100644 --- a/crates/trusted-server-js/lib/test/release-composition.test.mjs +++ b/crates/trusted-server-js/lib/test/release-composition.test.mjs @@ -712,6 +712,7 @@ describe('complete inventory producer', () => { outDir: alias, inventoryPath: path.join(outDir, 'inside.json'), preparationPath, + sourceContextPath, }) ).rejects.toThrow(/outside.*bundle/i); await expect( @@ -720,6 +721,7 @@ describe('complete inventory producer', () => { outDir, inventoryPath: path.join(root, 'wrong-checkout.json'), preparationPath, + sourceContextPath, }) ).rejects.toThrow(/collector.*checkout/i); const originalPreparation = fs.readFileSync(preparationPath); @@ -734,6 +736,7 @@ describe('complete inventory producer', () => { outDir, inventoryPath: path.join(root, 'collision.json'), preparationPath, + sourceContextPath, }) ).rejects.toThrow(/duplicate.*material|material.*identity/i); expect(fs.existsSync(path.join(root, 'collision.json'))).toBe(false); @@ -753,6 +756,7 @@ describe('complete inventory producer', () => { outDir, inventoryPath: path.join(root, 'bad.json'), preparationPath, + sourceContextPath, }) ).rejects.toThrow(/actual.*tool.*identity/i); } finally { diff --git a/crates/trusted-server-release/src/javascript.rs b/crates/trusted-server-release/src/javascript.rs index ad3330b749..6c2691b3e5 100644 --- a/crates/trusted-server-release/src/javascript.rs +++ b/crates/trusted-server-release/src/javascript.rs @@ -342,6 +342,67 @@ fn test_report( Ok(total) } +// Failure diagnostics deliberately exclude assertions, stacks and child output. +fn vitest_failure_diagnostic( + bytes: &[u8], + expected: &BTreeSet, + execution_library: &Path, +) -> Result> { + let report = parse_json(bytes, Role::Companion)?; + let results = report["testResults"] + .as_array() + .ok_or_else(|| preparation_error("Vitest report lacks suite results"))?; + let sanitize = |name: &str| -> String { + name.chars() + .take(256) + .map(|character| { + if character.is_ascii() && !character.is_ascii_control() { + character + } else { + '?' + } + }) + .collect() + }; + let mut names = Vec::new(); + for result in results { + if names.len() >= 16 { + break; + } + let Some(relative) = result["name"].as_str().and_then(|name| { + Path::new(name) + .strip_prefix(execution_library) + .ok()? + .to_str() + }) else { + continue; + }; + if !expected.contains(relative) { + continue; + } + if result["status"] == "failed" { + names.push(format!("suite failed: {}", sanitize(relative))); + } + if let Some(assertions) = result["assertionResults"].as_array() { + for assertion in assertions { + if names.len() >= 16 { + break; + } + if assertion["status"] == "failed" + && let Some(name) = assertion["fullName"].as_str() + { + names.push(format!("test failed: {}", sanitize(name))); + } + } + } + } + Ok(if names.is_empty() { + "Vitest failed without registered failed names".to_owned() + } else { + format!("Vitest failed names (at most 16): {}", names.join("; ")) + }) +} + struct MissingFiles { paths: Vec<(PathBuf, PathBuf)>, _directory: tempfile::TempDir, @@ -567,7 +628,38 @@ impl PreparedJsBuild<'_> { .execution_path(&side.output().join("vitest.json"))? .into_os_string(), ]; - self.run_command(index, boundaries[index], proofs[index], arguments)?; + if let Err(mut error) = + self.run_command(index, boundaries[index], proofs[index], arguments) + { + let diagnostic = read_bounded_with_check( + &side.output().join("vitest.json"), + Role::Companion, + &|| self.attempt.check(), + ) + .and_then(|report| { + vitest_failure_diagnostic( + &report.bytes, + &side.expected_tests, + &boundaries[index].execution_path(&side.library())?, + ) + }); + error = error.attach( + diagnostic + .unwrap_or_else(|_| "Bounded Vitest failure names unavailable".to_owned()), + ); + if let Err(changed) = + verify_captured_output(&side.output(), &captured, &|| self.attempt.check()) + { + error = error.attach(format!( + "post-failure collector recheck failed: {changed:?}" + )); + } + if let Err(changed) = side.verify_source() { + error = + error.attach(format!("post-failure source recheck failed: {changed:?}")); + } + return Err(error); + } verify_captured_output(&side.output(), &captured, &|| self.attempt.check())?; let report = read_bounded_with_check( &side.output().join("vitest.json"), diff --git a/crates/trusted-server-release/src/javascript/tests.rs b/crates/trusted-server-release/src/javascript/tests.rs index 42f3382e1c..fad5db530f 100644 --- a/crates/trusted-server-release/src/javascript/tests.rs +++ b/crates/trusted-server-release/src/javascript/tests.rs @@ -207,3 +207,52 @@ fn failed_cleanup_cannot_return_or_retain_a_completed_build() { "should retain both failure contexts" ); } + +#[test] +fn vitest_failure_diagnostics_include_only_bounded_registered_names() { + let report = json!({"testResults":[ + {"name":"/work/lib/test/fictional.test.ts","status":"failed","message":"PRIVATE ASSERTION","assertionResults":[ + {"fullName":"fictional suite should reject invalid input","status":"failed","failureMessages":["PRIVATE STACK"]}, + {"fullName":"successful case","status":"passed"} + ]}, + {"name":"/outside/private-file","status":"failed","assertionResults":[]} + ]}); + let expected = BTreeSet::from(["test/fictional.test.ts".to_owned()]); + let bytes = serde_json::to_vec(&report).expect("should encode fictional report"); + let diagnostic = vitest_failure_diagnostic(&bytes, &expected, Path::new("/work/lib")) + .expect("should parse report"); + assert!( + diagnostic.contains("test/fictional.test.ts") + && diagnostic.contains("fictional suite should reject invalid input"), + "should preserve failed suite and test identities" + ); + for omitted in [ + "PRIVATE ASSERTION", + "PRIVATE STACK", + "successful case", + "private-file", + ] { + assert!( + !diagnostic.contains(omitted), + "should omit arbitrary failure context and unregistered paths" + ); + } + assert!( + vitest_failure_diagnostic(b"{", &expected, Path::new("/work/lib")).is_err(), + "should reject malformed reports" + ); + let tests: Vec<_> = (0..100) + .map(|_| json!({"fullName":format!("{}\n\u{1b}","x".repeat(1000)),"status":"failed"})) + .collect(); + let large = json!({"testResults":[{"name":"/work/lib/test/fictional.test.ts","status":"failed","assertionResults":tests}]}); + let diagnostic = vitest_failure_diagnostic( + &serde_json::to_vec(&large).expect("should encode bounded fixture"), + &expected, + Path::new("/work/lib"), + ) + .expect("should summarize names"); + assert!( + diagnostic.len() < 5000 && !diagnostic.contains(['\n', '\u{1b}']), + "should bound count and length and remove controls" + ); +} From a4399290295dcb58bd309a3732e31ac5f7d090ce Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 20:37:39 +0530 Subject: [PATCH 063/153] Record two fresh JS build acceptance evidence The full Linux A/B execution test passed in ordinary PR CI (run 38747798078): 1,275/1,275 tests per side, 13 byte-identical modules and six private roots removed, with release_qualification false. Record that evidence, mark M3 complete and update Task 7 status. Task 7 stays open for macOS lifecycle containment carried by M2 and Tasks 8/9. Add a unit test rejecting failed, pending and unregistered Vitest report results, and assert in the real Linux fixture that each side exposes only its own private roots and is bound to its own boundary. --- .../src/javascript/tests.rs | 29 +++ .../src/preparation/tests/full.rs | 52 ++++-- ...6-10-13-attested-release-implementation.md | 43 +++-- ...26-10-14-release-tooling-rust-migration.md | 2 +- scripts/release/js-composition.md | 2 +- .../qualification/2026-10-15-js-two-builds.md | 171 ++++++++++++++++++ 6 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 scripts/release/qualification/2026-10-15-js-two-builds.md diff --git a/crates/trusted-server-release/src/javascript/tests.rs b/crates/trusted-server-release/src/javascript/tests.rs index fad5db530f..3efd8bb260 100644 --- a/crates/trusted-server-release/src/javascript/tests.rs +++ b/crates/trusted-server-release/src/javascript/tests.rs @@ -54,6 +54,35 @@ fn full_suite_report_rejects_zero_selection_or_missing_typecheck_file() { ); } #[test] +fn full_suite_report_rejects_failed_pending_or_unregistered_results() { + let expected = BTreeSet::from(["test/example.test.ts".to_owned()]); + let passing = json!({"success":true,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"testResults":[{"name":"/example/lib/test/example.test.ts","status":"passed"}]}); + let encode = |value: &Value| serde_json::to_vec(value).expect("should encode test report"); + test_report(&encode(&passing), &expected, Path::new("/example/lib")) + .expect("should accept the passing control report"); + let changes: [(&str, Value); 6] = [ + ("/success", json!(false)), + ("/numFailedTests", json!(1)), + ("/numPassedTests", json!(0)), + ("/numPendingTests", json!(1)), + ("/testResults/0/status", json!("failed")), + ( + "/testResults/0/name", + json!("/example/lib/test/other.test.ts"), + ), + ]; + for (pointer, value) in changes { + let mut report = passing.clone(); + *report + .pointer_mut(pointer) + .expect("should select an existing report field") = value; + assert!( + test_report(&encode(&report), &expected, Path::new("/example/lib")).is_err(), + "should reject a report with changed {pointer}" + ); + } +} +#[test] fn parsed_inventory_cannot_choose_an_untracked_module_or_filename() { let mut inventory = json!({"modules":[{"id":"core","filename":"tsjs-core.js","size":1,"sha256":"a".repeat(64)}]}); let expected = vec!["core".to_owned()]; diff --git a/crates/trusted-server-release/src/preparation/tests/full.rs b/crates/trusted-server-release/src/preparation/tests/full.rs index eff769bad2..8705487649 100644 --- a/crates/trusted-server-release/src/preparation/tests/full.rs +++ b/crates/trusted-server-release/src/preparation/tests/full.rs @@ -71,24 +71,52 @@ fn real_two_fresh_js_builds() { stop_timeout_seconds: 5, host_limits: host_limits(), }; + let first_roots = prepared + .boundary_roots(0) + .expect("should select only A roots"); + let second_roots = prepared + .boundary_roots(1) + .expect("should select only B roots"); let first = Boundary::linux_container( - prepared - .boundary_roots(0) - .expect("should select only A roots"), + first_roots.clone(), settings.clone(), image.clone(), host.path(), ) .expect("should configure A owned PID/network boundary"); - let second = Boundary::linux_container( - prepared - .boundary_roots(1) - .expect("should select only B roots"), - settings, - image, - host.path(), - ) - .expect("should configure B disjoint boundary"); + let second = Boundary::linux_container(second_roots.clone(), settings, image, host.path()) + .expect("should configure B disjoint boundary"); + for (own, other) in [(&first_roots, &second_roots), (&second_roots, &first_roots)] { + let mut shared = 0; + for (_, _, root) in other.entries() { + if own.entries().any(|(_, _, own_root)| own_root == root) { + shared += 1; + assert!( + !own.writable().iter().any(|path| path == root), + "should share only read-only roots between A and B" + ); + } else { + assert!( + own.entries() + .all(|(_, _, own_root)| !own_root.starts_with(root) + && !root.starts_with(own_root)), + "should never expose the other side's private source, installation or metadata root" + ); + } + } + assert_eq!( + shared, 2, + "should share exactly the read-only originals and selected tool roots" + ); + } + assert!( + first.matches_roots(&first_roots) && second.matches_roots(&second_roots), + "should bind each boundary to exactly its own selected roots" + ); + assert!( + !first.matches_roots(&second_roots) && !second.matches_roots(&first_roots), + "should refuse either side's commands inside the other side's boundary" + ); let probe = ProbeProgram::from_staged_release_binary(&fixture.join("tools/probe")) .expect("should select staged native producer probe"); let limits = ProcessLimits { diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 25bd19158e..861ef646a2 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -41,13 +41,13 @@ Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrapp - [x] **M0: Remove superseded tooling.** Introduced Python release code and validator requirements are removed. Deleted prototype behavior is not implemented or fixed by removal; pending Rust regressions below remain mandatory. - [ ] **M1: Port primitives and preflight (Tasks 2–3).** Strict document/schema utilities, fixed policy/environment checks, local Git object/worktree authentication and the helper-closure fingerprint are implemented. Git commands use bounded process supervision and a shared command deadline. Independently authenticated bootstrap/tool acquisition and qualification remain incomplete; the closure fingerprint supplies no acquisition authority. Register each private test module before applying the nonzero-selection guard. - [ ] **M2: Port material/process/OS boundaries (Tasks 7–9).** Establish original archive/Git identities, bounded child execution, kill/reap/cleanup, private staging, source/object read-only protections and real native denial controls. Preserve independent all-cohort remote receipts separately from rehashing tools actually installed in the current execution environment. -- [ ] **M3: Restore real JS orchestration (Task 7).** Perform two fresh offline installations, actual lifecycle observations and denied missing-tool probes, all tracked bundle comparisons and both full registered JS/type-check suites. Only actual successful execution constructs a private typed completion bound to source, tools, settings and retained A bytes; parsed JSON, paths or booleans cannot construct it. +- [x] **M3: Restore real JS orchestration (Task 7).** Perform two fresh offline installations, actual lifecycle observations and denied missing-tool probes, all tracked bundle comparisons and both full registered JS/type-check suites. Only actual successful execution constructs a private typed completion bound to source, tools, settings and retained A bytes; parsed JSON, paths or booleans cannot construct it. The full Linux A/B execution test passed in ordinary PR CI on 2026-10-15 (1,275/1,275 tests per side, 13 byte-identical modules, six private roots removed); see the [two-build evidence report](../../../scripts/release/qualification/2026-10-15-js-two-builds.md). This is candidate CI execution, not hosted release qualification. - [ ] **M4: Restore canonical recipes and inspection (Tasks 8–9).** Compose actual Cargo preparation, compile, final acceptance and copying; collect Task 10 metadata/tree/native evidence through that same boundary before hosted qualification. Local tiny artifacts cannot qualify the full product or actual macOS 15/GNU cohort. - [ ] **Then Tasks 10–22.** Implement the remaining Rust producer modules, workflow contracts, consumer verification and manual route in dependency order. Complete every selected qualification and full CI gate before release enablement/handoff. The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. -Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Original archive authentication, bounded private extraction and lifecycle declaration review are implemented. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The macOS profile still permits a scrubbed process-group escape and is not a qualified lifecycle-containment backend. Native boundary controls pass ordinary Ubuntu 24.04 and macOS 15 CI; they do not qualify complete release recipes or authenticated tool selections. Git material preparation, two-installation JS orchestration, canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. +Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Original archive authentication, bounded private extraction and lifecycle declaration review are implemented. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The macOS profile still permits a scrubbed process-group escape and is not a qualified lifecycle-containment backend. Native boundary controls pass ordinary Ubuntu 24.04 and macOS 15 CI; they do not qualify complete release recipes or authenticated tool selections. Original npm and Cargo Git material verification and two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI. Canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. ## Current task status — 2026-10-15 @@ -56,15 +56,20 @@ qualification. There are **7 complete tasks and 15 open tasks**. One open task has its implementation finished; eight tasks therefore have completed code. Task counts do not measure remaining effort. -| Status | Count | Tasks | Remaining exit work | -| ---------------------------------------------- | ----- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Complete | 7 | 2, 4, 5, 6, 17, 18, 20 | Implementation, local verification, real pinned-verifier public-fixture validation where required, and independent review completed | -| Implementation complete; qualification pending | 1 | 19 | Official Trusted Server signed-release first-use verification; upstream provenance/SPDX format compatibility has been measured | -| Partial or prerequisite approval pending | 5 | 1, 3, 7, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; Git materials, actual JS lifecycle and A/B runs; platform qualification; full Rust recipes/runtime acceptance | -| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | - -Task 7's archive and declaration checks are prerequisites. They do not construct -`JsBuildCompleted`, prove hook execution or complete that task. Task 9's +| Status | Count | Tasks | Remaining exit work | +| ---------------------------------------------- | ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Complete | 7 | 2, 4, 5, 6, 17, 18, 20 | Implementation, local verification, real pinned-verifier public-fixture validation where required, and independent review completed | +| Implementation complete; qualification pending | 1 | 19 | Official Trusted Server signed-release first-use verification; upstream provenance/SPDX format compatibility has been measured | +| Partial or prerequisite approval pending | 5 | 1, 3, 7, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; macOS lifecycle-containment regressions; platform qualification; full Rust recipes/runtime acceptance | +| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | + +Task 7's Linux JS exit (M3) passed: an actual ordinary-CI run completed both +fresh offline installations, lifecycle observations, missing-tool and download +controls, both full suites and final-byte equality before constructing +`JsBuildCompleted`. Task 7 stays open only for its macOS lifecycle-containment +regression bullet (the scrubbed process-group escape and macOS descendant +inheritance), which M2 and Tasks 8/9 carry. That candidate run is not hosted +qualification and opens no production selector. Task 9's structural binary inspection does not prove a successful canonical build or Fastly publisher request. Ordinary CI passes do not change the pending qualification pins or authorize release publication. @@ -277,15 +282,15 @@ The implemented utility chooses the mandatory role byte limit before reading/all **Retain/modify:** `scripts/release/build-js.sh`, `bootstrap.sh`, `collect-js.mjs`. **Create:** release tool `src/materials.rs`, `process.rs`, `boundary.rs`, `preparation.rs`, `javascript.rs` and private registered tests; add public process fixtures to `tests/commands.rs`. -- [ ] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. -- [ ] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" materials::tests`, `process::tests`, `boundary::tests`, `preparation::tests` and `javascript::tests` separately with the nonzero-selection guard; expect missing Rust orchestration. -- [ ] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Rust migration MUST reprove this with actual full local execution and fresh fixtures; Task 8 MUST qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Read-only original sources/object stores and authenticated archive/tool inputs remain outside private installation outputs. Timeouts/cancellation/pipe limits kill and reap descendant process groups; bounded Docker stop/remove and all temporary-profile/resource cleanup run on success and failure. Bundle under qualified network denial; compilation-time downloads fail. -- [ ] Compare final core and all tracked integration files byte-for-byte. Retain only A plus its original inventory after equality. Construct a private typed `JsBuildCompleted` only from the actual successful two-install/test/build/control/comparison path, binding authenticated source/tools/settings, inventory raw digest and retained module bytes. Rehash at handoff; a checked inventory or JSON completion assertion cannot substitute. Unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` into BOTH actual A/B Vitest processes, independent of inherited developer flags; run every registered test/type check and reject a zero selected suite; verify source-only trees remained intact. -- [ ] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Require both actual fresh full registered JS/type-check suites and all tracked final bundles before `Build and compare shared Rust release JS inputs`; fixture-only counts remain separate. +- [x] Write tests for archive/revision mismatch, stale extracted tree, prohibited install download, two bundles differing by one byte, test failure and changed source. Assert no generated output exchange between A/B. Prohibited download and missing-tool denial are actual runtime controls exercised in the full Linux fixture; that fixture also asserts each side exposes only its own private roots and shares only read-only originals and tools. +- [x] Run `cargo test -p trusted-server-release --target "$HOST_TARGET" materials::tests`, `process::tests`, `boundary::tests`, `preparation::tests` and `javascript::tests` separately with the nonzero-selection guard; expect missing Rust orchestration. +- [x] Preparation verifies registry/git/npm materials and required native esbuild package archives, then freshly materializes each installation. Run pinned `npm ci --offline --include=dev --no-audit --no-fund` with verified freshly prepared caches in two independent workspaces under the selected denial boundary; review/record lifecycle scripts and fail any lifecycle tool fetch. Rust migration MUST reprove this with actual full local execution and fresh fixtures; Task 8 MUST qualify the actual hosted JS boundary before Task 15 may use it officially. No restored node_modules/generated JS. Read-only original sources/object stores and authenticated archive/tool inputs remain outside private installation outputs. Timeouts/cancellation/pipe limits kill and reap descendant process groups; bounded Docker stop/remove and all temporary-profile/resource cleanup run on success and failure. Bundle under qualified network denial; compilation-time downloads fail. The passing run used an ordinary-CI Docker denial boundary; qualifying that boundary remains Task 8. +- [x] Compare final core and all tracked integration files byte-for-byte. Retain only A plus its original inventory after equality. Construct a private typed `JsBuildCompleted` only from the actual successful two-install/test/build/control/comparison path, binding authenticated source/tools/settings, inventory raw digest and retained module bytes. Rehash at handoff; a checked inventory or JSON completion assertion cannot substitute. Unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` into BOTH actual A/B Vitest processes, independent of inherited developer flags; run every registered test/type check and reject a zero selected suite; verify source-only trees remained intact. +- [x] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Require both actual fresh full registered JS/type-check suites and all tracked final bundles before `Build and compare shared Rust release JS inputs`; fixture-only counts remain separate. Landed as `Build and compare two fresh offline JavaScript installations` plus follow-up fixes; Linux CI evidence is in the [two-build evidence report](../../../scripts/release/qualification/2026-10-15-js-two-builds.md). -- [ ] Port actual regressions for immutable macOS source/object regular files with `nlink == 1`; reject pre-existing hard links and prove write-through/restoration cannot bypass the boundary. Restrict signals to `same-sandbox`: own-child positive, outside same-user sibling and orchestrator denial negatives. Parent/child/grandchild install lifecycle, Cargo/compiler/proc-macro/native children MUST inherit the same OS-denied controls. -- [ ] Port Docker boundary regressions: use Docker's default private PID namespace (no invalid `--pid=private`), and actually execute positive/denial/recipe controls instead of testing argv alone. Keep BOTH host seccomp profiles and an empty Docker config disjoint from every container-writable root; scan before creating them and rehash BOTH before EVERY host command. Select fixed authenticated Docker client/config/Unix daemon, no TTY/plugins/context or environment overrides, and read-only anchors. Reject escaping and dangling aliases in the CONTAINER namespace. -- [ ] Bound child pipes/deadlines; timeout/cancellation/group kill MUST reap descendants and clear token/provider/injection environment. Record real errno without inventing `ETIMEDOUT`. Use host-independent tool/path/environment fixtures; no ambient shim or interpreter supplies trust. +- [ ] Port actual regressions for immutable macOS source/object regular files with `nlink == 1`; reject pre-existing hard links and prove write-through/restoration cannot bypass the boundary. Restrict signals to `same-sandbox`: own-child positive, outside same-user sibling and orchestrator denial negatives. Parent/child/grandchild install lifecycle, Cargo/compiler/proc-macro/native children MUST inherit the same OS-denied controls. Partial: `nlink == 1` scans, hard-link rejection, run-time mutation detection, `same-sandbox` signal positives/negatives and grandchild socket denial pass on macOS 15 CI. Open: a descendant that leaves its process group with a scrubbed environment is not contained, and no macOS install-lifecycle path exists (R-JS uses the single shared Linux JS job). This bullet stays open under M2 and Tasks 8/9. +- [x] Port Docker boundary regressions: use Docker's default private PID namespace (no invalid `--pid=private`), and actually execute positive/denial/recipe controls instead of testing argv alone. Keep BOTH host seccomp profiles and an empty Docker config disjoint from every container-writable root; scan before creating them and rehash BOTH before EVERY host command. Select fixed authenticated Docker client/config/Unix daemon, no TTY/plugins/context or environment overrides, and read-only anchors. Reject escaping and dangling aliases in the CONTAINER namespace. Linux container tests execute actual positive, denial and confined commands in ordinary CI. Cleanup `stop`/`rm` still rehashes every input first: a changed client or config blocks the command, while a changed seccomp profile (which those commands do not read) is recorded as a failure and cleanup proceeds. +- [x] Bound child pipes/deadlines; timeout/cancellation/group kill MUST reap descendants and clear token/provider/injection environment. Record real errno without inventing `ETIMEDOUT`. Use host-independent tool/path/environment fixtures; no ambient shim or interpreter supplies trust. ### Task 8: Qualify real platform environments before fixing recipes diff --git a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md index 754be9059a..e47fe197e4 100644 --- a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md +++ b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md @@ -38,7 +38,7 @@ One `release.yml` owns publication and same-source `release-build.yml` owns sign The active plan keeps Task IDs 1–22 and prepends migration checkpoints: M0 verify removal/fail-closed wrappers; M1 Rust documents/preflight/source; M2 original materials/process/OS boundaries; M3 real two-installation JS completion; M4 actual canonical recipes/inspection/composition; then finish Tasks 10–22. Full AGENTS CI and independent review remain required before handoff. No resource creation/dispatch, signing, publication or provider operation follows from this language correction. -Introduced Python producer tooling and requirements are removed. The host-only crate implements strict documents/policy, local source/preflight checks, a helper-closure fingerprint, bounded process supervision and final-artifact inspection. Rust build-fact and checked-inventory guards and Node composition are implemented. The experimental macOS profile does not contain descendants that escape their process group and scrub the tracking environment; it cannot qualify M2 lifecycle containment. Original-material preparation, complete M2–M4 execution, independently approved acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. +Introduced Python producer tooling and requirements are removed. The host-only crate implements strict documents/policy, local source/preflight checks, a helper-closure fingerprint, bounded process supervision and final-artifact inspection. Rust build-fact and checked-inventory guards and Node composition are implemented. The experimental macOS profile does not contain descendants that escape their process group and scrub the tracking environment; it cannot qualify M2 lifecycle containment. Original npm/Cargo Git material verification and M3 real two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI ([evidence](../../../scripts/release/qualification/2026-10-15-js-two-builds.md)). That CI candidate is not release qualification. Complete M2 and M4 execution, macOS lifecycle containment, independently approved acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. ## Mandatory replacement regressions diff --git a/scripts/release/js-composition.md b/scripts/release/js-composition.md index f0124460a9..0d282108a1 100644 --- a/scripts/release/js-composition.md +++ b/scripts/release/js-composition.md @@ -266,4 +266,4 @@ Private fixtures belong alongside their registered modules; public process tests use `tests/commands.rs`. Full local two-installation/native-probe execution MUST be selected explicitly through reviewed test paths and cannot waive hosted gates. -The committed Node collector/composition fixtures are current local interface checks. Task 7's real two-installation orchestration and Task 8's selected native qualification remain pending; utility fixtures do not construct producer completion. +The committed Node collector/composition fixtures are current local interface checks; utility fixtures do not construct producer completion. Task 7's real two-installation orchestration passed its full Linux execution test in ordinary PR CI; see the [two-build evidence report](qualification/2026-10-15-js-two-builds.md). That candidate run is not release qualification: Task 8's selected hosted/native qualification, authenticated bootstrap and tool pins remain pending. diff --git a/scripts/release/qualification/2026-10-15-js-two-builds.md b/scripts/release/qualification/2026-10-15-js-two-builds.md new file mode 100644 index 0000000000..79fcfc645a --- /dev/null +++ b/scripts/release/qualification/2026-10-15-js-two-builds.md @@ -0,0 +1,171 @@ +# Task 7: real original materials and two fresh JS builds + +Status: the explicit original-material and Cargo Git tests passed locally on +native macOS, and the full Linux A/B execution test passed in +[ordinary PR CI](https://github.com/IABTechLab/trusted-server/actions/runs/38747798078) +on 2026-10-15. This is candidate execution evidence from an ordinary CI +runner. It does not qualify release tools, an OCI image, bootstrap, hosted +release environments or independently approved release source. Production +selectors remain closed. + +## Original-material validation + +The explicit native test +`materials::npm_bundle::tests::actual_current_lock_npm_originals` passed +locally on native macOS on 2026-10-15 in 283.74 seconds. It is ignored in +ordinary CI; there, original authentication runs only inside the A/B fixture's +npm original preparation. It authenticated all 651 locked installation +locations and 626 distinct compressed originals against the current npm +lock's SHA-512 identities, freshly extracted every original, reviewed every +manifest, retained the compressed originals, and rechecked all retained +archives and extracted trees. Its fictional source binding is a diagnostic +test input, not independent source approval or acquisition qualification. + +The exact npm lock SHA-256 is +`d5a1b74310b0455531c3133cacf86fa528682cbed91c40412fb3791ccd48c527`. +An independent local raw-header diagnostic, not retained in the repository, +inspected 30,723 entries. The largest +member is the locked optional OpenBSD Rollup binary, 110,275,248 bytes. The +reviewed member limit is 128 MiB; the compressed-original limit remains +64 MiB and total decoded limit remains 512 MiB per archive. Extraction and +rehashing stream bytes and honor cancellation/deadlines. + +Actual originals exposed the eight exact-version legacy type-package roots, +seven node-tar timestamp layouts, and local PAX metadata in `gulp-wrap` +0.15.0 documented in [JS composition](../js-composition.md). Only redundant +PAX path/size values matching the next raw regular member are accepted. +Unknown fields, duplicate keys, malformed framing, overrides, unsupported +record sequences, links, privileged modes, and hidden name bytes fail. +The only empty script entries were non-install separator labels in `ignore`; +empty lifecycle declarations remain rejected. The only actual dependency +install hooks were the reviewed `core-js` and `esbuild` tuples. + +The separate actual-current-Cargo-Git fixture also passed locally; it is +ignored in ordinary CI. The selected +closure contained 557 objects and 6,334,558 decoded object-content bytes, +excluding canonical object headers, with root tree +`411e16f40511f3f4867349d09784ec68beac022d`. This checks actual Git material +format and closure validation; it does not qualify Git acquisition, a native +toolchain, or a Cargo recipe. + +## Linux execution acceptance + +Orchestration commit: `2edd46ce6` +(`Build and compare two fresh offline JavaScript installations`); reviewed +archive layouts: `e2260d457b29b1202a39f8732e78d5d4bd9c323b`; CI runner configuration: +`cbd96047400099271534f110b5decdcf73b1a5c8`; collector fixture context and +failure diagnostics: `1a2c46075e18791b623f26ea73af8f833dc5f70b`. +The [Linux producer job](https://github.com/IABTechLab/trusted-server/actions/runs/38747798078/job/116465922121) +(`cargo test (release tooling and JS prebuilt, native) (ubuntu-24.04)`) +selects exactly one ignored full-execution test: +`preparation::tests::full::real_two_fresh_js_builds`. +It passed in 687.37 seconds (14:09:47Z to 14:21:15Z) after a two-minute +optimized test build: `1 passed; 0 failed; 0 ignored; 354 filtered out`. +Every job in that Run Tests workflow passed, as did the Run Format, CodeQL +and Integration Tests workflows for the same head. + +CI checked out the pull request merge commit +`15fcd34c64b0f726f42f4f4da5de5af7e3577aa2`, whose parents are `main` at +`a0b00c72705baf6b0f63c206d7721b68ee03e995` and branch head +`1a2c46075e18791b623f26ea73af8f833dc5f70b`. The fixture cloned that exact +checkout. The commit is a CI candidate, not independently approved release +source. The printed measurement was: + +```json +{ + "inventory_sha256": "2cdcb1da9c6318ce812cf7fd0b9cc0b1bdc9465c93d68953054db3e361b45042", + "kind": "ordinary-ci-two-fresh-js-builds", + "module_count": 13, + "private_roots_removed": 6, + "release_qualification": false, + "schema_version": 1, + "settings_sha256": "6bce0b31534321c4e36209caf09cad19a0ede1dbe7f6e39254b3f68657a8ea27", + "source_commit": "15fcd34c64b0f726f42f4f4da5de5af7e3577aa2", + "test_counts": [1275, 1275], + "tools_sha256": "4a461321d25eec779752960570ba574bae397102cb3b652b9b70cc24bb6431c6" +} +``` + +Both confined full suites passed all 1,275 registered tests including type +checks. All 13 final core/integration modules were byte-identical, six private +roots were removed and retained A bytes were rehashed at handoff. The test +writes this file only after every stage succeeds, so its presence records the +actual completed path; the file itself remains a diagnostic printout. + +After this run, the fixture gained explicit assertions that each side exposes +only its own private source, installation and metadata roots, shares only the +read-only originals and tool roots, and cannot reuse the other side's boundary. +Isolation was already enforced: exact `matches_roots` checks run before +execution and each installation, and every command path is translated only +through its own boundary; the next CI run on +this branch exercises the new assertions. A unit test also now rejects failed, +pending and unregistered Vitest report results. + +The fixture uses Node 24.12.0, npm 11.6.2, a source-built producer and native +observer guard, original compressed archives, and an ordinary local Linux +container image. It creates two independent tracked-source copies with no +`.git`, installed dependencies, generated bundles or shared writable cache. +Each side establishes an actual socket-denial proof in its own network/PID +boundary, seeds its private cache from authenticated originals, performs a +fresh offline installation with lifecycle observation, captures the original +collector inventory, exercises missing native tools and the actual esbuild +download fallback, and runs the full registered suite with release guards +and type checks. It compares every final core/integration bundle byte for +byte, closes all six private source/state/metadata roots, then rehashes +retained A bytes at handoff. No serialized measurement can construct private +completion authority. + +After success the test writes `js-execution-measurement.json` into its +external fixture root and CI prints it. This records actual source identity, +A/B test counts, module count, selected tool/settings hashes, original A +inventory hash and removed-root count. Its `release_qualification` field is +explicitly false; the file is diagnostic evidence, not an authorization +input or a canonical release companion. + +The full fixture uses test-profile optimization level 2 with debug assertions +and overflow checks explicitly enabled. Ordinary native tests retain their +existing debug profile. Cache seeding alone triggers at least 80 complete +original/tree/tool rechecks across A/B; optimization retains every check. +Local all-originals runtime fell from 283.74 seconds to 18.18 seconds after +a 48.44-second optimized compilation. A further run with both safety settings +explicitly enabled reused the same executable and passed in 17.93 seconds. +These are local performance measurements, not hosted execution proof. + +## Supporting checks and scope + +With the new unit test, the local producer suite passed 364 unit tests, four +boundary integration tests and 13 command tests on macOS. Two explicit +actual-material tests are ignored by that ordinary run and are exercised +separately. +All-target/all-feature producer Clippy, documentation, formatting and staged +domain lint passed. Independent spec and quality reviews found no remaining +Task 7 code blocker before the Linux run; that run has since passed. + +A separate local, unretained release-mode Vitest JSON run passed all 1,275 tests. Its +52 report entries cover exactly 51 distinct registered test/type-check files; +the type-check file appears twice. This confirms the Rust report parser's +real format/coverage join; it does not substitute for either confined A/B run. + +Two earlier full Linux attempts failed for concrete, since-fixed causes. The +[first](https://github.com/IABTechLab/trusted-server/actions/runs/38736177179/job/116425928695) +rejected the authentic 110,275,248-byte optional OpenBSD Rollup member under +the former 64 MiB member limit. The +[second](https://github.com/IABTechLab/trusted-server/actions/runs/38741529217/job/116444179601), +unoptimized, reached Vitest and failed one collector negative fixture after +2,027.48 seconds. That fixture passed parent +source context to its main call but omitted it from later rejection cases; +the duplicate-material case consequently failed at Git discovery in a fresh +source copy without `.git`. A separate local full no-`.git` reproduction confirmed +exactly that single failure. Copying only the corrected test file into the +same disposable tree made all 1,275 tests and type checks pass with zero +failed/pending tests. Every negative call now supplies the same source context, +preserving its intended semantic assertion. Failed Vitest runs also retain a +bounded sanitized list of registered failed test names in the process error +before checked cleanup; raw assertion messages and stacks are excluded. + +R-JS allows one shared Linux JS build for later Rust artifact jobs. Task 7 +does not qualify native Rust recipes or macOS lifecycle containment. The +macOS hardlink and same-sandbox signal regression ports pass, but the known +scrubbed process-group escape remains open for Tasks 8/9 and M2. Authenticated +bootstrap, selected hosted environments, actual Cargo recipes and official +release publication remain separate incomplete gates. From 60fafa7175f26685edd88c8336e958e8e709b000 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 21:34:39 +0530 Subject: [PATCH 064/153] Reap escaped macOS sandbox descendants by sandbox instance A descendant that left the process group with posix_spawn and scrubbed its environment escaped the environment-nonce survivor check. Each run now binds two fresh marker files outside every root; the profile allows reading one and denies the other, and sandbox_check identifies processes of exactly that instance. Only processes started after the run began are eligible, and each match is re-identified immediately before SIGKILL, so already-running applications and reused pids are never signalled. Add tests for the scrubbed escape, escaped-descendant network denial, runtime hard-link denial, concurrent instance isolation, and collect-only checks that a fresh signature matches no existing process and that processes started before a run are never eligible. --- crates/trusted-server-release/src/boundary.rs | 15 +- .../src/boundary/macos.rs | 119 +++++++-- .../src/boundary/survivors.rs | 238 +++++++++++++++--- .../src/boundary/tests/macos.rs | 181 +++++++++++-- .../src/boundary/tests/modes.rs | 24 ++ .../src/boundary/tests/unit.rs | 7 + 6 files changed, 514 insertions(+), 70 deletions(-) diff --git a/crates/trusted-server-release/src/boundary.rs b/crates/trusted-server-release/src/boundary.rs index ef162dd09f..dbd418e9f6 100644 --- a/crates/trusted-server-release/src/boundary.rs +++ b/crates/trusted-server-release/src/boundary.rs @@ -33,13 +33,14 @@ //! process module's group kill reaps it. //! - `posix_spawn` with `POSIX_SPAWN_SETPGROUP`/`POSIX_SPAWN_SETSID` (Rust's //! `Command::process_group`) bypasses that syscall filter (observed). As a -//! fail-closed backstop, each run carries a unique nonce in its environment -//! and, after the run, every same-user process whose exec-time environment -//! holds it is killed and the run rejected. -//! - Residual: a descendant that escapes the group **and** execs with that -//! variable scrubbed is not found. It stays inside the same sandbox -//! instance (network-denied, writes only in the writable roots), but may -//! outlive the run. +//! fail-closed backstop, after each run every process that started during +//! it and belongs to that run's sandbox instance (identified by per-run +//! marker rules through `sandbox_check`) or carries its environment nonce is +//! killed and the run rejected. Scrubbing the environment does not hide a +//! descendant, and a confined process cannot re-apply a sandbox. +//! - Survivors are found only after the run exits, so the macOS backend +//! still does not own descendant cleanup the way a container does +//! ([`Boundary::has_owned_descendant_cleanup`] stays false). //! //! # What this module does not prove //! diff --git a/crates/trusted-server-release/src/boundary/macos.rs b/crates/trusted-server-release/src/boundary/macos.rs index f48305308f..d5688d58cd 100644 --- a/crates/trusted-server-release/src/boundary/macos.rs +++ b/crates/trusted-server-release/src/boundary/macos.rs @@ -17,9 +17,16 @@ //! `setpgid` system calls are not filtered; `(allow syscall-unix)` followed by //! `(deny syscall-unix (syscall-number …))` makes both fail with `EPERM` in //! the confined process and its descendants. +//! +//! Each run also binds two fresh empty marker files, created outside every +//! root: the profile allows reading one and denies reading the other. That +//! pair of rules identifies processes of exactly this run's sandbox instance +//! to the survivor check, independent of their environment. use std::ffi::OsString; -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use error_stack::Report; use sha2::{Digest as _, Sha256}; @@ -69,6 +76,16 @@ const PROFILE_HEADER: &str = r#"(version 1) "#; /// Network denial; later SBPL rules take precedence over earlier ones. const NETWORK_DENIAL: &str = "(deny network*)\n"; +/// Profile parameter naming the run marker the instance may read. +pub(crate) const RUN_MARK_ALLOW: &str = "RUN_MARK_ALLOW"; +/// Profile parameter naming the run marker the instance must not read. +pub(crate) const RUN_MARK_DENY: &str = "RUN_MARK_DENY"; +/// Instance-identifying marker rules, last so that they take precedence. +const RUN_MARK_RULES: &str = "(allow file-read-data (literal (param \"RUN_MARK_ALLOW\")))\n\ + (deny file-read* (literal (param \"RUN_MARK_DENY\")))\n"; +/// Allowance before a run's recorded start. It only widens eligibility; +/// identity checks still gate every match. +const START_MARGIN: Duration = Duration::from_secs(1); /// Profile identity domain for the configuration fingerprint. const FINGERPRINT_DOMAIN: &[u8] = b"trusted-server-boundary-macos-sbpl-v2\n"; @@ -149,6 +166,7 @@ impl MacProfile { text.push_str(&denials); text.push_str(NETWORK_DENIAL); text.push_str(&sockets); + text.push_str(RUN_MARK_RULES); Ok(Self { text, defines }) } @@ -175,7 +193,8 @@ impl MacProfile { arguments } - /// Returns the profile fingerprint (before the instance nonce). + /// Returns the profile fingerprint (before the instance nonce). The marker + /// rules are covered; per-run marker paths are not. #[must_use] pub(crate) fn fingerprint(&self) -> [u8; 32] { let mut hasher = Sha256::new(); @@ -188,7 +207,8 @@ impl MacProfile { hasher.finalize().into() } - /// Wraps `command` in `sandbox-exec`, adding the run nonce variable. + /// Wraps `command` in `sandbox-exec`, adding the run's marker defines and + /// nonce variable. /// /// # Errors /// @@ -198,9 +218,13 @@ impl MacProfile { pub(crate) fn wrap( &self, command: &ConfinedCommand, - run_nonce: &str, + run: &RunIdentity, ) -> Result> { let mut arguments = self.arguments(); + for (name, value) in &run.defines { + arguments.push(OsString::from("-D")); + arguments.push(OsString::from(format!("{name}={value}"))); + } arguments.push(command.program.clone().into_os_string()); arguments.extend(command.arguments.iter().cloned()); let mut bounded = BoundedCommand::new( @@ -209,27 +233,28 @@ impl MacProfile { command.limits, )? .arguments(arguments) - .environment(RUN_NONCE_VARIABLE, run_nonce)?; + .environment(RUN_NONCE_VARIABLE, &run.nonce)?; for (name, value) in &command.environment { bounded = bounded.environment(name, value)?; } Ok(command.finish(bounded)) } - /// Runs `command` confined, then kills and rejects any descendant that - /// escaped the process group while keeping the run nonce. + /// Runs `command` confined, then kills and rejects any process of this + /// run's sandbox instance, or carrying its nonce, that outlived it. /// /// # Errors /// - /// Returns [`ReleaseError::Boundary`] when an escaped descendant was found - /// (even if the run itself succeeded), or the run's own error. + /// Returns [`ReleaseError::Boundary`] when the run identity cannot be + /// created or an escaped descendant was found (even if the run itself + /// succeeded), or the run's own error. pub(crate) fn run( &self, command: &ConfinedCommand, ) -> Result> { - let nonce = run_nonce()?; - let outcome = self.wrap(command, &nonce).and_then(|bounded| bounded.run()); - let survivors = reap_survivors(&nonce); + let run = RunIdentity::new(self)?; + let outcome = self.wrap(command, &run).and_then(|bounded| bounded.run()); + let survivors = reap_survivors(&run); match (outcome, survivors) { (Ok(result), Ok(())) => Ok(result), (Err(run), Ok(())) => Err(run), @@ -241,12 +266,76 @@ impl MacProfile { } } +/// One run's survivor identity: an environment nonce and two private marker +/// files outside every boundary root. +pub(crate) struct RunIdentity { + nonce: String, + started_after_micros: u64, + defines: [(String, String); 2], + allow: PathBuf, + deny: PathBuf, + _directory: tempfile::TempDir, +} + +impl RunIdentity { + /// Creates fresh markers for one run of `profile`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] when the markers cannot be created, + /// are not safe define text, or overlap a boundary root. + pub(crate) fn new(profile: &MacProfile) -> Result> { + let directory = tempfile::Builder::new() + .prefix("ts-boundary-run-") + .tempdir() + .map_err(super::boundary_error)?; + let base = fs::canonicalize(directory.path()).map_err(super::boundary_error)?; + if profile.defines.iter().any(|(_, root)| { + let root = Path::new(root); + base.starts_with(root) || root.starts_with(&base) + }) { + return Err(super::boundary_error("run markers overlap a boundary root")); + } + let allow = base.join("allow"); + let deny = base.join("deny"); + for marker in [&allow, &deny] { + fs::write(marker, b"").map_err(super::boundary_error)?; + } + Ok(Self { + nonce: run_nonce()?, + started_after_micros: started_after_micros()?, + defines: [ + define(RUN_MARK_ALLOW, &allow)?, + define(RUN_MARK_DENY, &deny)?, + ], + allow, + deny, + _directory: directory, + }) + } +} + +/// Returns the earliest process start this run may own: now, less a +/// [`START_MARGIN`], in microseconds since the epoch. +fn started_after_micros() -> Result> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(super::boundary_error)?; + u64::try_from(now.saturating_sub(START_MARGIN).as_micros()).map_err(super::boundary_error) +} + #[cfg(target_os = "macos")] -fn reap_survivors(nonce: &str) -> Result<(), Report> { - super::survivors::reap(RUN_NONCE_VARIABLE, nonce) +fn reap_survivors(run: &RunIdentity) -> Result<(), Report> { + super::survivors::reap(&super::survivors::RunSignature { + variable: RUN_NONCE_VARIABLE, + nonce: &run.nonce, + allow: &run.allow, + deny: &run.deny, + started_after_micros: run.started_after_micros, + }) } #[cfg(not(target_os = "macos"))] -fn reap_survivors(_nonce: &str) -> Result<(), Report> { +fn reap_survivors(_run: &RunIdentity) -> Result<(), Report> { Err(Report::new(ReleaseError::Boundary).attach("sandbox-exec survivor checks are macOS-only")) } diff --git a/crates/trusted-server-release/src/boundary/survivors.rs b/crates/trusted-server-release/src/boundary/survivors.rs index 27ea37fd1f..c012346cfa 100644 --- a/crates/trusted-server-release/src/boundary/survivors.rs +++ b/crates/trusted-server-release/src/boundary/survivors.rs @@ -2,15 +2,32 @@ //! //! macOS has no subreaper, and `posix_spawn` with `POSIX_SPAWN_SETPGROUP` or //! `POSIX_SPAWN_SETSID` leaves the supervised process group even though the -//! profile denies the `setsid`/`setpgid` system calls. Each confined run -//! therefore carries `NAME=` in its environment. After the run, -//! every process whose exec-time arguments and environment -//! (`KERN_PROCARGS2`, readable only for same-user processes) contain that -//! exact entry is sent `SIGKILL`, and the run is rejected. +//! profile denies the `setsid`/`setpgid` system calls. After every run, each +//! other process is checked two ways, and every match is sent `SIGKILL` and +//! the run rejected: //! -//! Residual: a descendant that execs with the variable removed is not found; -//! it stays in the same sandbox instance and remains network-denied. +//! - Sandbox instance: the process is sandboxed, may read the run's allow +//! marker and may not read its deny marker, according to `sandbox_check`. +//! A confined process cannot leave or re-apply its sandbox (nested +//! `sandbox_apply` fails with `EPERM`), so scrubbing its environment or +//! changing its process group does not hide it. +//! - Environment: its exec-time arguments and environment (`KERN_PROCARGS2`, +//! readable only for same-user processes) hold `NAME=`. macOS +//! hides this area for Apple platform binaries such as `/bin/sleep`, so +//! this signal is secondary to the sandbox-instance check. +//! +//! Only processes that started after the run began, less a one-second clock +//! margin, are eligible, so already-running applications are never +//! signalled even if a check misbehaves. +//! +//! `sandbox_check` is exported by libSystem's `libsystem_sandbox` but is not +//! declared in the public SDK header. A self-check against this unsandboxed +//! orchestrator must give the expected answers before any scan is trusted; +//! the orchestrator must therefore not itself run inside a sandbox. +use std::ffi::{CStr, CString}; +use std::os::unix::ffi::OsStrExt as _; +use std::path::Path; use std::time::{Duration, Instant}; use error_stack::Report; @@ -23,11 +40,131 @@ const REAP_GRACE: Duration = Duration::from_secs(2); const POLL_INTERVAL: Duration = Duration::from_millis(10); /// Extra slots when listing pids, for processes started between calls. const PID_SLACK: usize = 256; +/// `SANDBOX_FILTER_NONE` in the private `sandbox_filter_type` enumeration. +const SANDBOX_FILTER_NONE: libc::c_int = 0; +/// `SANDBOX_FILTER_PATH` in the same enumeration; observed with +/// `sandbox-exec` profiles on macOS 26.6.2. +const SANDBOX_FILTER_PATH: libc::c_int = 1; +/// The checked file operation. +const FILE_READ_DATA: &CStr = c"file-read-data"; + +unsafe extern "C" { + /// Returns 0 when `operation` is allowed for `pid`, 1 when denied and -1 + /// on error; with no operation, 1 means `pid` is sandboxed. + fn sandbox_check( + pid: libc::pid_t, + operation: *const libc::c_char, + filter: libc::c_int, + ... + ) -> libc::c_int; + /// Flag suppressing sandbox violation reports for a check. + static SANDBOX_CHECK_NO_REPORT: libc::c_int; +} + +/// What identifies one run's processes. +pub(crate) struct RunSignature<'a> { + /// Environment variable name carrying the run nonce. + pub(crate) variable: &'a str, + /// The run nonce. + pub(crate) nonce: &'a str, + /// Marker file the run's sandbox instance may read. + pub(crate) allow: &'a Path, + /// Marker file the run's sandbox instance may not read. + pub(crate) deny: &'a Path, + /// Earliest eligible process start, in microseconds since the epoch. + /// Processes that started before the run, such as already-open + /// applications, are never matched or signalled. + pub(crate) started_after_micros: u64, +} + +/// Prepared queries for one signature. +struct Matcher { + started_after_micros: u64, + entry: Vec, + allow: CString, + deny: CString, +} fn survivor_error(message: impl std::fmt::Display) -> Report { Report::new(ReleaseError::Boundary).attach(message.to_string()) } +fn no_report() -> libc::c_int { + // SAFETY: the exported constant is an initialized, immutable `int`. + unsafe { SANDBOX_CHECK_NO_REPORT } +} + +/// Returns whether `pid` is sandboxed (1), not (0), or the check failed (-1). +fn sandboxed(pid: libc::pid_t) -> libc::c_int { + // SAFETY: a null operation with the none filter takes no variadic value. + unsafe { sandbox_check(pid, core::ptr::null(), SANDBOX_FILTER_NONE | no_report()) } +} + +/// Returns 0 when `pid` may read `path`, 1 when denied, -1 on error. +fn read_check(pid: libc::pid_t, path: &CStr) -> libc::c_int { + // SAFETY: the path filter takes exactly one NUL-terminated string, and + // both strings outlive the call. + unsafe { + sandbox_check( + pid, + FILE_READ_DATA.as_ptr(), + SANDBOX_FILTER_PATH | no_report(), + path.as_ptr(), + ) + } +} + +impl Matcher { + fn new(signature: &RunSignature<'_>) -> Result> { + let path = |path: &Path| { + CString::new(path.as_os_str().as_bytes()) + .map_err(|_| survivor_error("run marker path holds a NUL byte")) + }; + Ok(Self { + started_after_micros: signature.started_after_micros, + entry: format!("{}={}", signature.variable, signature.nonce).into_bytes(), + allow: path(signature.allow)?, + deny: path(signature.deny)?, + }) + } + + /// Requires the expected answers for the unsandboxed orchestrator. + fn self_check(&self, own: libc::pid_t) -> Result<(), Report> { + let answers = ( + sandboxed(own), + read_check(own, &self.allow), + read_check(own, &self.deny), + ); + if answers == (0, 0, 0) { + Ok(()) + } else { + Err(survivor_error(format!( + "sandbox state query self-check failed with {answers:?}; no process was signalled and the orchestrator must run unsandboxed" + ))) + } + } + + /// Returns whether `pid` belongs to the run's sandbox instance. + fn in_instance(&self, pid: libc::pid_t) -> bool { + sandboxed(pid) == 1 && read_check(pid, &self.allow) == 0 && read_check(pid, &self.deny) == 1 + } +} + +/// Returns `pid`'s start time in microseconds since the epoch, when readable. +fn start_micros(pid: libc::pid_t) -> Option { + // SAFETY: `proc_bsdinfo` is plain old data; all-zero bytes are valid. + let mut info: libc::proc_bsdinfo = unsafe { core::mem::zeroed() }; + let size = libc::c_int::try_from(size_of::()).ok()?; + // SAFETY: `info` is a writable buffer of exactly `size` bytes. + let filled = + unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast(), size) }; + (filled == size).then(|| { + info.pbi_start_tvsec + .saturating_mul(1_000_000) + .saturating_add(info.pbi_start_tvusec) + }) +} + /// Lists every process ID on the system. fn all_pids() -> Result, Report> { // SAFETY: a null buffer of size zero asks only for the current count. @@ -90,46 +227,87 @@ fn holds_entry(area: &[u8], entry: &[u8]) -> bool { area.split(|byte| *byte == 0).any(|item| item == entry) } -/// Returns every other process carrying `entry`. -fn carriers(entry: &[u8]) -> Result, Report> { +/// Returns `pid`'s start time when it is another eligible process of the +/// run's instance or carries its nonce. +fn identify( + matcher: &Matcher, + pid: libc::pid_t, + own: libc::pid_t, + buffer: &mut [u8], +) -> Option { + if pid <= 1 || pid == own { + return None; + } + let started = start_micros(pid).filter(|started| *started >= matcher.started_after_micros)?; + let carries = matcher.in_instance(pid) + || process_arguments(pid, buffer).is_some_and(|area| holds_entry(area, &matcher.entry)); + carries.then_some(started) +} + +/// Returns every other matching process with its start time. +fn carriers( + matcher: &Matcher, + own: libc::pid_t, + buffer: &mut [u8], +) -> Result, Report> { + matcher.self_check(own)?; + Ok(all_pids()? + .into_iter() + .filter_map(|pid| identify(matcher, pid, own, buffer).map(|started| (pid, started))) + .collect()) +} + +fn own_pid() -> libc::pid_t { // SAFETY: `getpid` has no preconditions. - let own = unsafe { libc::getpid() }; + unsafe { libc::getpid() } +} + +/// Lists, without signalling them, every other process matching `signature`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when the markers are not C strings, +/// the sandbox state query fails its self-check or processes cannot be listed. +#[cfg(test)] +pub(crate) fn matching( + signature: &RunSignature<'_>, +) -> Result, Report> { let mut buffer = vec![0_u8; argument_area_size()?]; - let mut found = Vec::new(); - for pid in all_pids()? { - let carries = pid > 1 - && pid != own - && process_arguments(pid, &mut buffer).is_some_and(|area| holds_entry(area, entry)); - if carries { - found.push(pid); - } - } - Ok(found) + let found = carriers(&Matcher::new(signature)?, own_pid(), &mut buffer)?; + Ok(found.into_iter().map(|(pid, _)| pid).collect()) } -/// Kills every surviving carrier of `name=nonce` and rejects the run if any +/// Kills every surviving process of the run and rejects the run if any /// existed. /// /// # Errors /// /// Returns [`ReleaseError::Boundary`] naming the survivors when any were -/// found (after killing them), or when they did not vanish within the grace. -pub(crate) fn reap(name: &str, nonce: &str) -> Result<(), Report> { - let entry = format!("{name}={nonce}").into_bytes(); +/// found (after killing them), when they did not vanish within the grace, +/// or when the sandbox state query fails its self-check. +pub(crate) fn reap(signature: &RunSignature<'_>) -> Result<(), Report> { + let matcher = Matcher::new(signature)?; + let own = own_pid(); + let mut buffer = vec![0_u8; argument_area_size()?]; let deadline = Instant::now() + REAP_GRACE; let mut killed = Vec::new(); loop { - let found = carriers(&entry)?; + let found = carriers(&matcher, own, &mut buffer)?; if found.is_empty() { break; } - for pid in &found { - // SAFETY: `kill` has no memory-safety preconditions; `pid > 1`. - unsafe { - libc::kill(*pid, libc::SIGKILL); + for (pid, started) in found { + // Re-identify immediately before signalling so that a reused pid + // with a different start time is never killed. + if identify(&matcher, pid, own, &mut buffer) == Some(started) { + // SAFETY: `kill` has no memory-safety preconditions; + // `identify` only returns pids above 1 other than our own. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + killed.push(pid); } } - killed.extend(found); if Instant::now() >= deadline { return Err(survivor_error(format!( "confined descendants {killed:?} still exist after SIGKILL" diff --git a/crates/trusted-server-release/src/boundary/tests/macos.rs b/crates/trusted-server-release/src/boundary/tests/macos.rs index 447456330f..009d7709cc 100644 --- a/crates/trusted-server-release/src/boundary/tests/macos.rs +++ b/crates/trusted-server-release/src/boundary/tests/macos.rs @@ -5,13 +5,14 @@ //! constructed and the tests fail instead of passing vacuously. use std::fs; -use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; use std::time::SystemTime; use tempfile::{TempDir, tempdir}; -use super::super::macos::MacProfile; +use super::super::macos::{MacProfile, RunIdentity}; use super::super::probe::LoopbackListener; +use super::super::survivors::{RunSignature, matching}; use super::*; use crate::process::ProcessOutcome; @@ -264,32 +265,178 @@ fn group_escape_keeping_the_run_nonce_is_killed_and_rejected() { assert!(wait_gone(pid), "should kill the escaped descendant {pid}"); } -/// Documents the residual: an escape that also scrubs its environment is not -/// found. The test kills the survivor itself. #[test] -fn group_escape_with_scrubbed_environment_is_the_documented_residual() { +fn group_escape_with_scrubbed_environment_is_killed_and_rejected() { let fixture = Fixture::new(); let proof = fixture.prove(); - let pid_file = fixture.writable.join("residual.pid"); + let pid_file = fixture.writable.join("scrubbed.pid"); let outcome = fixture.boundary.run_confined( &fixture.command("escape", &[path_text(&pid_file), "scrub"]), &proof, ); let pid = read_pid(&pid_file); let survived = process_exists(pid); - // SAFETY: `pid` is the fixture's own escaped helper (> 1). - unsafe { - libc::kill(pid, libc::SIGKILL); + if survived { + // SAFETY: `pid` is the fixture's own escaped helper (> 1). + unsafe { + libc::kill(pid, libc::SIGKILL); + } } + let error = outcome.expect_err("should reject a run whose scrubbed descendant escaped"); assert!( - outcome.is_ok(), - "should not detect a scrubbed escape: {outcome:?}" + format!("{error:?}").contains("escaped"), + "should name the escape: {error:?}" + ); + assert!( + !survived, + "should kill the scrubbed escaped descendant {pid} before returning" + ); +} + +/// Collect-only: lists matches without signalling anything. +#[test] +fn fresh_run_signature_matches_no_existing_process() { + let directory = tempdir().expect("should create marker directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize markers"); + let allow = base.join("allow"); + let deny = base.join("deny"); + fs::write(&allow, b"").expect("should write allow marker"); + fs::write(&deny, b"").expect("should write deny marker"); + let matches = matching(&RunSignature { + variable: "TS_RELEASE_BOUNDARY_RUN", + nonce: "fictional-unused-nonce", + allow: &allow, + deny: &deny, + started_after_micros: 0, + }) + .expect("should query every process without signalling it"); + assert!( + matches.is_empty(), + "should match no unrelated sandboxed or unsandboxed process: {matches:?}" + ); +} + +/// Collect-only: the single carrier is this test's own child. +#[test] +fn processes_started_before_the_run_are_never_matched() { + let directory = tempdir().expect("should create marker directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize markers"); + let allow = base.join("allow"); + let deny = base.join("deny"); + fs::write(&allow, b"").expect("should write allow marker"); + fs::write(&deny, b"").expect("should write deny marker"); + let nonce = "fictional-start-guard-nonce"; + // Apple platform binaries hide their environment from KERN_PROCARGS2. + let sleeper = base.join("sleep"); + fs::copy("/bin/sleep", &sleeper).expect("should copy a visible sleeper"); + let mut carrier = std::process::Command::new(&sleeper) + .arg("30") + .env("TS_RELEASE_BOUNDARY_RUN", nonce) + .spawn() + .expect("should start an own nonce carrier"); + let carrier_pid = libc::pid_t::try_from(carrier.id()).expect("should fit a pid"); + std::thread::sleep(Duration::from_millis(100)); + let signature = |started_after_micros| RunSignature { + variable: "TS_RELEASE_BOUNDARY_RUN", + nonce, + allow: &allow, + deny: &deny, + started_after_micros, + }; + let future = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|now| now + Duration::from_secs(3600)) + .ok() + .and_then(|future| u64::try_from(future.as_micros()).ok()); + let eligible = matching(&signature(0)); + let later = future.map(|future| matching(&signature(future))); + // Stop the own carrier before any assertion can unwind. + carrier.kill().expect("should stop own carrier"); + carrier.wait().expect("should reap own carrier"); + let eligible = eligible.expect("should list eligible carriers"); + let later = later + .expect("should compute a later run start") + .expect("should list carriers started after a later run"); + assert_eq!( + eligible, + vec![carrier_pid], + "should match exactly the own carrier" ); assert!( - survived, - "should leave the scrubbed escapee alive (documented residual)" + later.is_empty(), + "should never match a process that started before the run: {later:?}" + ); +} + +#[test] +fn concurrent_run_of_another_instance_is_not_reaped() { + let first = Fixture::new(); + let first_proof = first.prove(); + let second = Fixture::new(); + let second_proof = second.prove(); + std::thread::scope(|scope| { + let long = scope.spawn(|| { + second + .boundary + .run_confined(&second.command("sleep", &["1500"]), &second_proof) + }); + std::thread::sleep(Duration::from_millis(300)); + let short = first + .boundary + .run_confined(&first.command("sleep", &["0"]), &first_proof) + .expect("should not count another instance's live run as a survivor"); + assert_eq!( + short.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should complete the short run" + ); + let long = long + .join() + .expect("should join the concurrent run") + .expect("should leave the other instance's run untouched"); + assert_eq!( + long.outcome(), + ProcessOutcome::Exited { code: 0 }, + "should let the other instance finish normally" + ); + }); +} + +#[test] +fn escaped_scrubbed_descendant_inherits_denial() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let listener = LoopbackListener::bind().expect("should bind listener"); + let address = listener.address().to_string(); + assert_eq!( + fixture.run(&proof, "escape-connect", &[&address]), + "denied errno=1", + "should deny a new-group descendant with a scrubbed environment" + ); + assert_eq!( + listener.drain_accepts().expect("should drain"), + 0, + "should see no accept from the escaped descendant" + ); +} + +#[test] +fn runtime_hard_link_from_immutable_root_is_denied() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let source = fixture.immutable.join("src/a.txt"); + let alias = fixture.writable.join("runtime-link"); + assert_eq!( + fixture.run(&proof, "link", &[path_text(&source), path_text(&alias)]), + "denied errno=1", + "should deny creating a write-through alias during the run" + ); + assert!(!alias.exists(), "should leave no alias behind"); + assert_eq!( + fs::metadata(&source).expect("should stat source").nlink(), + 1, + "should keep the immutable file single-linked" ); - assert!(wait_gone(pid), "should be killed by the test"); } #[test] @@ -343,11 +490,9 @@ fn hard_link_write_through_bypasses_path_denial_and_restoration_hides_it() { // Deliberately skip the boundary's nlink scan to show what it prevents. let profile = MacProfile::generate(&fixture.boundary.roots).expect("should generate"); + let run = RunIdentity::new(&profile).expect("should create run markers"); let result = profile - .wrap( - &fixture.command("write", &[path_text(&alias)]), - "fixture-run", - ) + .wrap(&fixture.command("write", &[path_text(&alias)]), &run) .expect("should wrap") .run() .expect("should run unscanned helper"); diff --git a/crates/trusted-server-release/src/boundary/tests/modes.rs b/crates/trusted-server-release/src/boundary/tests/modes.rs index a5036b3b3b..fc3f744ce5 100644 --- a/crates/trusted-server-release/src/boundary/tests/modes.rs +++ b/crates/trusted-server-release/src/boundary/tests/modes.rs @@ -74,6 +74,13 @@ pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { scrub: argument(1) == "scrub", }, ), + "link" => report_errno( + "linked", + std::fs::hard_link(argument(0), argument(1)) + .err() + .map(|error| error.raw_os_error().unwrap_or(-1)), + ), + "escape-connect" => escape_connect(argument(0)), "unix-exchange" => unix_exchange(argument(0)), "socket" => socket(argument(0)), _ => HELPER_SETUP_FAILED, @@ -235,6 +242,23 @@ fn leave_child(pid_file: &str, departure: Departure) -> i32 { report("spawned", 0) } +/// Runs `connect` in a `posix_spawn(POSIX_SPAWN_SETPGROUP)` child whose +/// environment keeps only the helper selector, and waits for its report. +fn escape_connect(address: &str) -> i32 { + let Some(mut command) = self_command("connect", &[address]) else { + return HELPER_SETUP_FAILED; + }; + command.process_group(0).env_clear(); + for (name, value) in inherited_helper_environment("connect") { + command.env(name, value); + } + command + .status() + .ok() + .and_then(|status| status.code()) + .unwrap_or(HELPER_SETUP_FAILED) +} + /// Binds a Unix socket at `path`, connects to it and echoes four bytes. fn unix_exchange(path: &str) -> i32 { let errno = |error: std::io::Error| Some(error.raw_os_error().unwrap_or(-1)); diff --git a/crates/trusted-server-release/src/boundary/tests/unit.rs b/crates/trusted-server-release/src/boundary/tests/unit.rs index e8ba855a88..d04f292198 100644 --- a/crates/trusted-server-release/src/boundary/tests/unit.rs +++ b/crates/trusted-server-release/src/boundary/tests/unit.rs @@ -478,6 +478,13 @@ fn sbpl_profile_denies_group_escape_and_scopes_unix_sockets() { for absent in ["(subpath \"/usr\")", "/usr/local"] { assert!(!text.contains(absent), "should not allow {absent}"); } + assert!( + text.ends_with( + "(allow file-read-data (literal (param \"RUN_MARK_ALLOW\")))\n\ + (deny file-read* (literal (param \"RUN_MARK_DENY\")))\n" + ), + "should end with the run instance marker rules so that they take precedence" + ); } #[test] From 94fabc2052060a14f558bed32e0fde584f704c4f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 21:59:44 +0530 Subject: [PATCH 065/153] Mark Task 7 complete after macOS escape containment passes CI The macOS 15 CI run for the sandbox-instance reaper passed the scrubbed process-group escape, escaped-descendant denial, runtime hard-link denial and concurrent-instance tests. Check the final Task 7 regression bullet and update the status table to eight complete tasks; the boundary remains unqualified and opens no production selector. --- ...6-10-13-attested-release-implementation.md | 28 +++++++++---------- ...26-10-14-release-tooling-rust-migration.md | 2 +- .../qualification/2026-10-15-js-two-builds.md | 3 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 861ef646a2..01e6e1e258 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -47,29 +47,29 @@ Preserve `collect-js.mjs` and the implemented Node composition hook. POSIX wrapp The producer helper MUST itself be built and executable inside the selected GNU builder at glibc <=2.34, using an independently approved image/toolchain/material channel. An ambient Ubuntu 24 build cannot be assumed to load in UBI 9. Bind helper target, loader/runtime, source/dependency closure and actual native executable identity before execution. macOS helpers require the approved native CPU/SDK/floor-compatible chain. Initial shell/hash/Rust/Cargo authority is independent of the tool; record and authenticate rustup only if it is actually used (direct signed-component installation does not require it); the tool cannot authenticate itself. Native Rust helper/staged POSIX drivers implement socket and lifecycle probes, without language substitutions. -Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Original archive authentication, bounded private extraction and lifecycle declaration review are implemented. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The macOS profile still permits a scrubbed process-group escape and is not a qualified lifecycle-containment backend. Native boundary controls pass ordinary Ubuntu 24.04 and macOS 15 CI; they do not qualify complete release recipes or authenticated tool selections. Original npm and Cargo Git material verification and two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI. Canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. +Current implementation includes strict documents/policy, local source/preflight checks, bounded process supervision, owning build facts, checked JS inventory and Node composition. Original archive authentication, bounded private extraction and lifecycle declaration review are implemented. Final-artifact inspection and experimental native boundaries are implemented, with separate consumer evidence verification. The experimental macOS profile now kills and rejects descendants that escape the process group, including with a scrubbed environment, by identifying its sandbox instance after each run; it remains unqualified and does not own descendant cleanup the way a container does. Native boundary controls pass ordinary Ubuntu 24.04 and macOS 15 CI; they do not qualify complete release recipes or authenticated tool selections. Original npm and Cargo Git material verification and two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI. Canonical recipes, authenticated bootstrap/acquisition and hosted qualification remain incomplete. Production wrappers and qualified selection remain closed. ## Current task status — 2026-10-15 Count complete task exits separately from implemented code awaiting external -qualification. There are **7 complete tasks and 15 open tasks**. One open task -has its implementation finished; eight tasks therefore have completed code. +qualification. There are **8 complete tasks and 14 open tasks**. One open task +has its implementation finished; nine tasks therefore have completed code. Task counts do not measure remaining effort. -| Status | Count | Tasks | Remaining exit work | -| ---------------------------------------------- | ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Complete | 7 | 2, 4, 5, 6, 17, 18, 20 | Implementation, local verification, real pinned-verifier public-fixture validation where required, and independent review completed | -| Implementation complete; qualification pending | 1 | 19 | Official Trusted Server signed-release first-use verification; upstream provenance/SPDX format compatibility has been measured | -| Partial or prerequisite approval pending | 5 | 1, 3, 7, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; macOS lifecycle-containment regressions; platform qualification; full Rust recipes/runtime acceptance | -| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | +| Status | Count | Tasks | Remaining exit work | +| ---------------------------------------------- | ----- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Complete | 8 | 2, 4, 5, 6, 7, 17, 18, 20 | Implementation, local verification, real pinned-verifier public-fixture validation where required, and independent review completed | +| Implementation complete; qualification pending | 1 | 19 | Official Trusted Server signed-release first-use verification; upstream provenance/SPDX format compatibility has been measured | +| Partial or prerequisite approval pending | 4 | 1, 3, 8, 9 | Maintainer scope approval; authenticated bootstrap/tool selection; platform qualification; full Rust recipes/runtime acceptance | +| Pending implementation | 9 | 10–16, 21–22 | Cargo/native composition, SPDX, transfers, evidence collector, trusted workflows, immutable publication, manual Fastly route and final qualification | Task 7's Linux JS exit (M3) passed: an actual ordinary-CI run completed both fresh offline installations, lifecycle observations, missing-tool and download controls, both full suites and final-byte equality before constructing -`JsBuildCompleted`. Task 7 stays open only for its macOS lifecycle-containment -regression bullet (the scrubbed process-group escape and macOS descendant -inheritance), which M2 and Tasks 8/9 carry. That candidate run is not hosted -qualification and opens no production selector. Task 9's +`JsBuildCompleted`. Task 7's macOS regression ports then passed on macOS 15 +CI, including the formerly residual scrubbed process-group escape. That +candidate evidence is not hosted qualification and opens no production +selector. Task 9's structural binary inspection does not prove a successful canonical build or Fastly publisher request. Ordinary CI passes do not change the pending qualification pins or authorize release publication. @@ -288,7 +288,7 @@ The implemented utility chooses the mandatory role byte limit before reading/all - [x] Compare final core and all tracked integration files byte-for-byte. Retain only A plus its original inventory after equality. Construct a private typed `JsBuildCompleted` only from the actual successful two-install/test/build/control/comparison path, binding authenticated source/tools/settings, inventory raw digest and retained module bytes. Rehash at handoff; a checked inventory or JSON completion assertion cannot substitute. Unconditionally inject `TRUSTED_SERVER__RELEASE_JS_TESTS=1` into BOTH actual A/B Vitest processes, independent of inherited developer flags; run every registered test/type check and reject a zero selected suite; verify source-only trees remained intact. - [x] Prove a missing prepared tool cannot fetch a substitute; rerun negatives and full JS suite. Require both actual fresh full registered JS/type-check suites and all tracked final bundles before `Build and compare shared Rust release JS inputs`; fixture-only counts remain separate. Landed as `Build and compare two fresh offline JavaScript installations` plus follow-up fixes; Linux CI evidence is in the [two-build evidence report](../../../scripts/release/qualification/2026-10-15-js-two-builds.md). -- [ ] Port actual regressions for immutable macOS source/object regular files with `nlink == 1`; reject pre-existing hard links and prove write-through/restoration cannot bypass the boundary. Restrict signals to `same-sandbox`: own-child positive, outside same-user sibling and orchestrator denial negatives. Parent/child/grandchild install lifecycle, Cargo/compiler/proc-macro/native children MUST inherit the same OS-denied controls. Partial: `nlink == 1` scans, hard-link rejection, run-time mutation detection, `same-sandbox` signal positives/negatives and grandchild socket denial pass on macOS 15 CI. Open: a descendant that leaves its process group with a scrubbed environment is not contained, and no macOS install-lifecycle path exists (R-JS uses the single shared Linux JS job). This bullet stays open under M2 and Tasks 8/9. +- [x] Port actual regressions for immutable macOS source/object regular files with `nlink == 1`; reject pre-existing hard links and prove write-through/restoration cannot bypass the boundary. Restrict signals to `same-sandbox`: own-child positive, outside same-user sibling and orchestrator denial negatives. Parent/child/grandchild install lifecycle, Cargo/compiler/proc-macro/native children MUST inherit the same OS-denied controls. `nlink == 1` scans, pre-existing hard-link rejection, run-time hard-link denial, run-time mutation detection, `same-sandbox` signal positives/negatives, grandchild socket denial and escaped-descendant socket denial pass on macOS 15 CI. A descendant that leaves its process group, even with a scrubbed environment, is identified by its per-run sandbox instance, killed and the run rejected; only processes started during the run are eligible. R-JS uses the single shared Linux JS job, so no macOS install-lifecycle path exists; Task 9 must reprove Cargo/compiler/proc-macro/native child inheritance through its actual recipes. - [x] Port Docker boundary regressions: use Docker's default private PID namespace (no invalid `--pid=private`), and actually execute positive/denial/recipe controls instead of testing argv alone. Keep BOTH host seccomp profiles and an empty Docker config disjoint from every container-writable root; scan before creating them and rehash BOTH before EVERY host command. Select fixed authenticated Docker client/config/Unix daemon, no TTY/plugins/context or environment overrides, and read-only anchors. Reject escaping and dangling aliases in the CONTAINER namespace. Linux container tests execute actual positive, denial and confined commands in ordinary CI. Cleanup `stop`/`rm` still rehashes every input first: a changed client or config blocks the command, while a changed seccomp profile (which those commands do not read) is recorded as a failure and cleanup proceeds. - [x] Bound child pipes/deadlines; timeout/cancellation/group kill MUST reap descendants and clear token/provider/injection environment. Record real errno without inventing `ETIMEDOUT`. Use host-independent tool/path/environment fixtures; no ambient shim or interpreter supplies trust. diff --git a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md index e47fe197e4..180293480b 100644 --- a/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md +++ b/docs/superpowers/specs/2026-10-14-release-tooling-rust-migration.md @@ -38,7 +38,7 @@ One `release.yml` owns publication and same-source `release-build.yml` owns sign The active plan keeps Task IDs 1–22 and prepends migration checkpoints: M0 verify removal/fail-closed wrappers; M1 Rust documents/preflight/source; M2 original materials/process/OS boundaries; M3 real two-installation JS completion; M4 actual canonical recipes/inspection/composition; then finish Tasks 10–22. Full AGENTS CI and independent review remain required before handoff. No resource creation/dispatch, signing, publication or provider operation follows from this language correction. -Introduced Python producer tooling and requirements are removed. The host-only crate implements strict documents/policy, local source/preflight checks, a helper-closure fingerprint, bounded process supervision and final-artifact inspection. Rust build-fact and checked-inventory guards and Node composition are implemented. The experimental macOS profile does not contain descendants that escape their process group and scrub the tracking environment; it cannot qualify M2 lifecycle containment. Original npm/Cargo Git material verification and M3 real two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI ([evidence](../../../scripts/release/qualification/2026-10-15-js-two-builds.md)). That CI candidate is not release qualification. Complete M2 and M4 execution, macOS lifecycle containment, independently approved acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. +Introduced Python producer tooling and requirements are removed. The host-only crate implements strict documents/policy, local source/preflight checks, a helper-closure fingerprint, bounded process supervision and final-artifact inspection. Rust build-fact and checked-inventory guards and Node composition are implemented. The experimental macOS profile kills and rejects descendants that escape their process group, even with a scrubbed environment, by identifying its per-run sandbox instance; it remains unqualified for M2 lifecycle containment. Original npm/Cargo Git material verification and M3 real two-installation JS orchestration are implemented; the full Linux A/B execution test passed in ordinary PR CI ([evidence](../../../scripts/release/qualification/2026-10-15-js-two-builds.md)). That CI candidate is not release qualification. Complete M2 and M4 execution, qualified macOS lifecycle containment, independently approved acquisition/bootstrap roots and every selected hosted qualification remain pending. Production execution remains closed. ## Mandatory replacement regressions diff --git a/scripts/release/qualification/2026-10-15-js-two-builds.md b/scripts/release/qualification/2026-10-15-js-two-builds.md index 79fcfc645a..6ef9467e9e 100644 --- a/scripts/release/qualification/2026-10-15-js-two-builds.md +++ b/scripts/release/qualification/2026-10-15-js-two-builds.md @@ -165,7 +165,6 @@ before checked cleanup; raw assertion messages and stacks are excluded. R-JS allows one shared Linux JS build for later Rust artifact jobs. Task 7 does not qualify native Rust recipes or macOS lifecycle containment. The -macOS hardlink and same-sandbox signal regression ports pass, but the known -scrubbed process-group escape remains open for Tasks 8/9 and M2. Authenticated +macOS regression ports passed afterwards on [macOS 15 CI](https://github.com/IABTechLab/trusted-server/actions/runs/38765937295/job/116529009924), including the formerly residual scrubbed process-group escape, which is now killed and rejected. That boundary remains unqualified. Authenticated bootstrap, selected hosted environments, actual Cargo recipes and official release publication remain separate incomplete gates. From a7ae687547409540ed748fc89024b2c9dd84a5b0 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 22:12:14 +0530 Subject: [PATCH 066/153] Add closed release recipe planning with checked A/B profiles Begin Task 9 with a recipe layer that runs no build. Assets come only from the fixed artifact table and roles are A or B. Request roots must be absolute, normal, unaliased, fresh and pairwise disjoint with the checkout, tools and retained JS root, rechecked immediately before the private target directory is created. Ambient observations are checked against fixed role profiles; locale and timezone are recorded as normalized, umask, jobs, checkout root and start time must vary, and the execution user is unsupported. Role B's production profile and qualified Rust tools remain uninhabited. The Cargo plan is fixed data bound to the authenticated child environment, the in-memory JS completion (rehashed at bind) and the selected tools, and is re-derived before use. The execute gate rejects a boundary with other roots or without owned descendant cleanup and otherwise stays closed. --- .../src/boundary/proof.rs | 9 + crates/trusted-server-release/src/error.rs | 2 + .../trusted-server-release/src/javascript.rs | 41 ++ .../src/javascript/tests.rs | 33 +- crates/trusted-server-release/src/main.rs | 5 + .../src/policy/tools.rs | 22 +- .../src/preflight/child.rs | 18 + crates/trusted-server-release/src/recipe.rs | 554 ++++++++++++++++++ .../src/recipe/ambient.rs | 414 +++++++++++++ .../src/recipe/fresh.rs | 114 ++++ .../src/recipe/tests.rs | 301 ++++++++++ .../src/recipe/tests/ambient.rs | 260 ++++++++ .../src/recipe/tests/bind.rs | 536 +++++++++++++++++ 13 files changed, 2258 insertions(+), 51 deletions(-) create mode 100644 crates/trusted-server-release/src/recipe.rs create mode 100644 crates/trusted-server-release/src/recipe/ambient.rs create mode 100644 crates/trusted-server-release/src/recipe/fresh.rs create mode 100644 crates/trusted-server-release/src/recipe/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/ambient.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/bind.rs diff --git a/crates/trusted-server-release/src/boundary/proof.rs b/crates/trusted-server-release/src/boundary/proof.rs index 7c26c99b48..2fae9b357f 100644 --- a/crates/trusted-server-release/src/boundary/proof.rs +++ b/crates/trusted-server-release/src/boundary/proof.rs @@ -44,6 +44,15 @@ impl DenialProof { &self.fingerprint == fingerprint } + /// Builds a proof for unit tests that never run a confined command. + #[cfg(test)] + pub(crate) fn for_tests() -> Self { + Self { + fingerprint: [0; 32], + denied_errno: 1, + } + } + /// Returns the real errno the confined probe observed. #[must_use] pub(crate) fn denied_errno(&self) -> i32 { diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index 9e5c580571..e785e6ac6e 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -28,6 +28,8 @@ pub(crate) enum ReleaseError { Process, #[display("Release network-denial boundary rejected the execution")] Boundary, + #[display("Release recipe inputs or execution plan are rejected")] + Recipe, } impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/javascript.rs b/crates/trusted-server-release/src/javascript.rs index 6c2691b3e5..83c570448a 100644 --- a/crates/trusted-server-release/src/javascript.rs +++ b/crates/trusted-server-release/src/javascript.rs @@ -181,6 +181,47 @@ impl JsBuildCompleted { } } +#[cfg(test)] +impl JsBuildCompleted { + /// Builds a retained fixture bound to `source_commit` without executing a build. + /// + /// Only tests construct this; production completion stays on the real path. + pub(crate) fn fixture_for_tests(source_commit: &str) -> Self { + let directory = tempfile::tempdir().expect("should create retained fixture"); + fs::create_dir(directory.path().join("bundles")).expect("should create bundle root"); + let inventory = b"fictional original inventory"; + let observation = b"fictional private observation"; + let module = b"fictional module"; + fs::write(directory.path().join("tsjs-inventory.json"), inventory) + .expect("should write exact fixture inventory"); + fs::write(directory.path().join("npm-observation.json"), observation) + .expect("should write exact fixture observation"); + fs::write(directory.path().join("bundles/tsjs-core.js"), module) + .expect("should write fixture module"); + JsBuildCompleted { + directory, + source_commit: source_commit.to_owned(), + tools_sha256: "b".repeat(64), + settings_sha256: "c".repeat(64), + inventory_sha256: hex::encode(Sha256::digest(inventory)), + observation_sha256: hex::encode(Sha256::digest(observation)), + modules: BTreeMap::from([( + "core".to_owned(), + Module { + filename: "tsjs-core.js".to_owned(), + size: module.len() as u64, + sha256: hex::encode(Sha256::digest(module)), + }, + )]), + tests: [1, 1], + attempt: JsAttempt::new( + std::time::Instant::now() + std::time::Duration::from_secs(30), + crate::process::Cancellation::default(), + ), + } + } +} + fn filenames(root: &Path, limit: usize) -> Result, Report> { if !fs::symlink_metadata(root) .map_err(preparation_error)? diff --git a/crates/trusted-server-release/src/javascript/tests.rs b/crates/trusted-server-release/src/javascript/tests.rs index 3efd8bb260..0a93f5eee8 100644 --- a/crates/trusted-server-release/src/javascript/tests.rs +++ b/crates/trusted-server-release/src/javascript/tests.rs @@ -97,38 +97,7 @@ fn parsed_inventory_cannot_choose_an_untracked_module_or_filename() { } fn retained_fixture() -> JsBuildCompleted { - let directory = tempfile::tempdir().expect("should create retained fixture"); - fs::create_dir(directory.path().join("bundles")).expect("should create bundle root"); - let inventory = b"fictional original inventory"; - let observation = b"fictional private observation"; - let module = b"fictional module"; - fs::write(directory.path().join("tsjs-inventory.json"), inventory) - .expect("should write exact fixture inventory"); - fs::write(directory.path().join("npm-observation.json"), observation) - .expect("should write exact fixture observation"); - fs::write(directory.path().join("bundles/tsjs-core.js"), module) - .expect("should write fixture module"); - JsBuildCompleted { - directory, - source_commit: "a".repeat(40), - tools_sha256: "b".repeat(64), - settings_sha256: "c".repeat(64), - inventory_sha256: hex::encode(Sha256::digest(inventory)), - observation_sha256: hex::encode(Sha256::digest(observation)), - modules: BTreeMap::from([( - "core".to_owned(), - Module { - filename: "tsjs-core.js".to_owned(), - size: module.len() as u64, - sha256: hex::encode(Sha256::digest(module)), - }, - )]), - tests: [1, 1], - attempt: JsAttempt::new( - std::time::Instant::now() + std::time::Duration::from_secs(30), - crate::process::Cancellation::default(), - ), - } + JsBuildCompleted::fixture_for_tests(&"a".repeat(40)) } #[test] diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 0e6ba108fd..549b70ab17 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -16,6 +16,11 @@ mod policy; mod preflight; #[allow(dead_code, reason = "production JS tool qualification remains closed")] mod preparation; +#[allow( + dead_code, + reason = "Task 9 slice 1 recipe layer has no production caller yet" +)] +mod recipe; // `expect` would be unfulfilled in the test build, where the tests use it. #[allow(dead_code, reason = "consumed by Task 7 boundary and preparation")] mod process; diff --git a/crates/trusted-server-release/src/policy/tools.rs b/crates/trusted-server-release/src/policy/tools.rs index 8ba6d6eecc..5ff02871c2 100644 --- a/crates/trusted-server-release/src/policy/tools.rs +++ b/crates/trusted-server-release/src/policy/tools.rs @@ -14,6 +14,7 @@ use super::{ }; use crate::documents::lower_hex; use crate::error::ReleaseError; +use crate::recipe::AmbientProfile; const RUST_VERSION: &str = "1.95.0"; const NODE_VERSION: &str = "24.12.0"; @@ -414,27 +415,10 @@ struct FixedRecipe { /// Complete effective settings shared by every executable recipe. fn artifact_settings() -> Value { - let ambient = |checkout, user, jobs, locale, timezone, umask| { - json!({ - "checkout_root": checkout, - "execution_user": user, - "jobs": jobs, - "locale": locale, - "timezone": timezone, - "umask": umask, - }) - }; json!({ "ambient_profiles": { - "A": ambient("external-release-A", "qualification-selected", 4, "C", "UTC", "022"), - "B": ambient( - "external-release-B", - "qualified-alternate-or-unsupported", - 2, - "qualification-selected-alternate", - "qualification-selected-alternate", - "027", - ), + "A": AmbientProfile::A.to_json(), + "B": AmbientProfile::B_PENDING.to_json(), }, "aws_lc_target_selector": true, "aws_lc_use_system": false, diff --git a/crates/trusted-server-release/src/preflight/child.rs b/crates/trusted-server-release/src/preflight/child.rs index c441c01e8d..29a0d77786 100644 --- a/crates/trusted-server-release/src/preflight/child.rs +++ b/crates/trusted-server-release/src/preflight/child.rs @@ -137,3 +137,21 @@ pub(crate) fn child_environment( ) -> Result> { build_child(audited, source.commit(), source.epoch(), request) } + +/// Builds a child environment from explicit facts, for recipe tests only. +/// +/// Production code must go through [`child_environment`], which takes the +/// commit and epoch from an [`ApprovedSource`]. +/// +/// # Errors +/// +/// As [`child_environment`]. +#[cfg(test)] +pub(crate) fn build_child_for_tests( + audited: &AuditedEnvironment, + commit: &CommitId, + epoch: u64, + request: &ChildRequest, +) -> Result> { + build_child(audited, commit, epoch, request) +} diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs new file mode 100644 index 0000000000..affb65bc31 --- /dev/null +++ b/crates/trusted-server-release/src/recipe.rs @@ -0,0 +1,554 @@ +//! Closed recipe layer for one reproducible Cargo build. +//! +//! Slice 1 validates and binds every input a later Cargo execution needs, but +//! never starts a process: the execute gate rejects a mismatched boundary and +//! otherwise reports the closed production selection. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use crate::boundary::{Boundary, BoundaryRoots, DenialProof}; +use crate::error::ReleaseError; +use crate::javascript::JsBuildCompleted; +use crate::policy::{FIXED_ARTIFACTS, FixedArtifact}; +use crate::preflight::AuditedEnvironment; +use crate::preflight::child::{ChildEnvironment, ChildRequest, child_environment}; +use crate::source::{ApprovedSource, CommitId, FreshSource}; +use fresh::{ + absent_in_real_parent, disjoint, empty_directory, normal_absolute, real_directory, real_file, +}; + +mod ambient; +mod fresh; + +pub(crate) use ambient::{AmbientProfile, CheckedAmbient}; + +/// The only released feature variant of every asset. +const DEFAULT_VARIANT: &str = "default"; + +/// Builds a recipe error carrying `message`. +pub(crate) fn recipe_error(message: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Recipe).attach(message.to_string()) +} + +/// One source-owned asset identity, reachable only through the fixed table. +#[derive(Clone, Copy)] +pub(crate) struct AssetId { + artifact: &'static FixedArtifact, +} + +impl std::fmt::Debug for AssetId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_tuple("AssetId") + .field(&self.artifact.name) + .finish() + } +} + +impl PartialEq for AssetId { + fn eq(&self, other: &Self) -> bool { + self.artifact.name == other.artifact.name + } +} + +impl Eq for AssetId {} + +impl AssetId { + /// Resolves an asset name against the compiled fixed table. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any name not in the table. + pub(crate) fn parse(name: &str) -> Result> { + FIXED_ARTIFACTS + .iter() + .find(|artifact| artifact.name == name) + .map(|artifact| Self { artifact }) + .ok_or_else(|| recipe_error(format!("unknown asset id {name:?}"))) + } + + /// Returns the fixed table row; JSON can never supply its fields. + pub(crate) fn artifact(&self) -> &'static FixedArtifact { + self.artifact + } +} + +/// Which independent build of the pair a recipe belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Role { + /// The first, reference build. + A, + /// The second build under varied ambient conditions. + B, +} + +impl Role { + /// Parses exactly `A` or `B`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any other text. + pub(crate) fn parse(text: &str) -> Result> { + match text { + "A" => Ok(Self::A), + "B" => Ok(Self::B), + _ => Err(recipe_error(format!( + "unknown role {text:?}; expected A or B" + ))), + } + } +} + +/// Requires the default feature variant. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for any other variant name. +pub(crate) fn check_variant(variant: &str) -> Result<(), Report> { + if variant != DEFAULT_VARIANT { + return Err(recipe_error(format!( + "variant {variant:?} is rejected; only the default feature set is released" + ))); + } + Ok(()) +} + +/// Unvalidated textual request fields, exactly as an operator supplied them. +pub(crate) struct RecipeInput<'a> { + /// Asset name, resolved only against the compiled fixed table. + pub(crate) asset: &'a str, + /// Feature variant; only `default` is accepted. + pub(crate) variant: &'a str, + /// `A` or `B`. + pub(crate) role: &'a str, + /// Approved 40-digit source commit. + pub(crate) commit: &'a str, + /// Retained directory of the final JS bundles. + pub(crate) js_dir: &'a Path, + /// Retained JS inventory file beside the bundle directory. + pub(crate) js_inventory: &'a Path, + /// Fresh empty directory for prepared Cargo materials. + pub(crate) materials: &'a Path, + /// Fresh empty directory receiving the built artifact. + pub(crate) output: &'a Path, + /// Cargo target directory; must not exist yet. + pub(crate) target_dir: &'a Path, +} + +/// Checks the lexical form, freshness and disjointness of every request path. +fn check_roots(input: &RecipeInput<'_>) -> Result<(), Report> { + let paths = [ + ("JS bundle directory", input.js_dir), + ("JS inventory", input.js_inventory), + ("materials root", input.materials), + ("output root", input.output), + ("target directory", input.target_dir), + ]; + for (label, path) in paths { + normal_absolute(label, path)?; + } + real_directory(paths[0].0, input.js_dir)?; + real_file(paths[1].0, input.js_inventory)?; + let retained = input + .js_dir + .parent() + .ok_or_else(|| recipe_error("JS bundle directory has no retained parent"))?; + if input.js_inventory.parent() != Some(retained) { + return Err(recipe_error( + "JS inventory must sit beside the JS bundle directory in the retained root", + )); + } + empty_directory(paths[2].0, input.materials)?; + empty_directory(paths[3].0, input.output)?; + absent_in_real_parent(paths[4].0, input.target_dir)?; + disjoint(&[("retained JS root", retained), paths[2], paths[3], paths[4]])?; + Ok(()) +} + +/// A validated recipe request with fresh, disjoint roots. +#[derive(Debug)] +pub(crate) struct RecipeRequest { + asset: AssetId, + role: Role, + commit: CommitId, + js_dir: PathBuf, + js_inventory: PathBuf, + materials: PathBuf, + output: PathBuf, + target_dir: PathBuf, +} + +impl RecipeRequest { + /// Parses and validates one request against the compiled cohort and disk. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for an unknown asset or role, a + /// non-default variant, a malformed commit, non-normal or aliased paths, a + /// reused root or target, or overlapping roots. + pub(crate) fn parse(input: &RecipeInput<'_>) -> Result> { + check_variant(input.variant)?; + let asset = AssetId::parse(input.asset)?; + let role = Role::parse(input.role)?; + let commit = CommitId::parse(input.commit)?; + check_roots(input)?; + Ok(Self { + asset, + role, + commit, + js_dir: input.js_dir.to_owned(), + js_inventory: input.js_inventory.to_owned(), + materials: input.materials.to_owned(), + output: input.output.to_owned(), + target_dir: input.target_dir.to_owned(), + }) + } +} + +/// A Rust toolchain that passed Task 8 qualification. +/// +/// None has, so this type is uninhabited and production selection stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedRustTools {} + +/// Fixed `bin/cargo` and `bin/rustc` layout, retained as a measured local candidate. +pub(crate) struct SelectedRustTools { + root: PathBuf, +} + +impl SelectedRustTools { + /// Accepts only a qualified selection; production construction stays closed. + pub(crate) fn qualified(_root: &Path, qualification: QualifiedRustTools) -> Self { + match qualification {} + } + + /// Selects ordinary CI tools without asserting release qualification. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] unless `bin/cargo` and `bin/rustc` + /// are regular files below the canonical `root`. + #[cfg(test)] + pub(crate) fn for_tests(root: &Path) -> Result> { + let root = std::fs::canonicalize(root) + .map_err(|error| recipe_error(format!("cannot resolve Rust tools: {error}")))?; + for relative in ["bin/cargo", "bin/rustc"] { + real_file("Rust tool", &root.join(relative))?; + } + Ok(Self { root }) + } + + fn cargo(&self) -> PathBuf { + self.root.join("bin/cargo") + } +} + +/// Environment variables the recipe adds on top of the allowlisted child environment. +const PREBUILT_DIR_VARIABLE: &str = "TSJS_PREBUILT_DIR"; +const PREBUILT_INVENTORY_VARIABLE: &str = "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY"; +const TARGET_DIR_VARIABLE: &str = "CARGO_TARGET_DIR"; +/// Child variable carrying the authenticated source commit. +const SOURCE_COMMIT_VARIABLE: &str = "TRUSTED_SERVER__GIT_VERSION"; + +/// The exact Cargo invocation of one recipe; it cannot be started in slice 1. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CargoPlan { + asset: AssetId, + program: PathBuf, + arguments: Vec, + environment: BTreeMap, + working_directory: PathBuf, +} + +/// Returns the fixed argument vector of the release Cargo build. +fn cargo_arguments(asset: AssetId, jobs: u32) -> Vec { + let artifact = asset.artifact(); + [ + "build", + "--package", + artifact.package, + "--bin", + artifact.bin, + "--target", + artifact.target.triple(), + "--release", + "--frozen", + "--message-format=json-render-diagnostics", + "--jobs", + ] + .into_iter() + .map(str::to_owned) + .chain([jobs.to_string()]) + .collect() +} + +/// Derives the private locations a child build receives from the request. +fn child_request(request: &RecipeRequest) -> ChildRequest { + ChildRequest { + target: request.asset.artifact().target, + home: request.materials.join("home"), + cargo_home: request.materials.clone(), + tool_directories: Vec::new(), + } +} + +/// Everything [`PreparedRecipe::bind`] cross-checks before a recipe may run. +struct BindInputs<'a> { + commit: &'a CommitId, + child: ChildEnvironment, + completed: &'a JsBuildCompleted, + tools: SelectedRustTools, + ambient: CheckedAmbient, + request: RecipeRequest, +} + +/// A recipe whose source, JS handoff, tools, ambient and roots are all bound. +pub(crate) struct PreparedRecipe { + request: RecipeRequest, + tools: SelectedRustTools, + ambient: CheckedAmbient, + child: ChildEnvironment, + retained_root: PathBuf, + plan: CargoPlan, +} + +impl PreparedRecipe { + /// Binds the locally authenticated source and a fresh private checkout. + /// + /// The commit and committer epoch reach the child environment only + /// through [`child_environment`], which requires the authenticated source. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the fresh checkout is not the + /// approved commit or not the ambient checkout root, or as [`Self::bind`]. + pub(crate) fn new( + source: &ApprovedSource, + fresh: &FreshSource, + audited: &AuditedEnvironment, + completed: &JsBuildCompleted, + tools: SelectedRustTools, + ambient: CheckedAmbient, + request: RecipeRequest, + ) -> Result> { + if fresh.commit() != source.commit().as_str() { + return Err(recipe_error("fresh checkout is not the approved commit")); + } + if fresh.root() != ambient.checkout_root() { + return Err(recipe_error( + "fresh checkout is not the ambient checkout root", + )); + } + let child = child_environment(audited, source, &child_request(&request))?; + Self::bind(BindInputs { + commit: source.commit(), + child, + completed, + tools, + ambient, + request, + }) + } + + /// Cross-checks every input and creates the fresh target directory. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the request, JS handoff or child + /// environment names another commit, the JS handoff no longer rehashes or + /// is not the requested directory, the ambient role differs, a root is no + /// longer fresh, or the checkout, tools, retained JS, materials, output + /// and target roots overlap. + fn bind(inputs: BindInputs<'_>) -> Result> { + let BindInputs { + commit, + child, + completed, + tools, + ambient, + request, + } = inputs; + if request.commit != *commit { + return Err(recipe_error("request names another source commit")); + } + if completed.source_commit() != commit.as_str() { + return Err(recipe_error( + "JS handoff was built from another source commit", + )); + } + if child + .variables() + .get(SOURCE_COMMIT_VARIABLE) + .map(String::as_str) + != Some(commit.as_str()) + { + return Err(recipe_error( + "child environment names another source commit", + )); + } + if ambient.role() != request.role { + return Err(recipe_error("ambient role differs from the request role")); + } + let retained = completed.rehash_for_handoff().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("JS handoff no longer rehashes") + })?; + let retained_root = std::fs::canonicalize(retained) + .map_err(|error| recipe_error(format!("cannot resolve JS handoff: {error}")))?; + if request.js_dir != retained_root.join("bundles") + || request.js_inventory != retained_root.join("tsjs-inventory.json") + { + return Err(recipe_error("request does not name the JS handoff")); + } + check_fresh_and_disjoint(&request, &ambient, &tools, &retained_root)?; + let plan = plan_for(&request, &tools, &ambient, &child)?; + std::fs::create_dir(&request.target_dir) + .map_err(|error| recipe_error(format!("cannot create fresh target: {error}")))?; + Ok(Self { + request, + tools, + ambient, + child, + retained_root, + plan, + }) + } + + /// Returns the exact planned Cargo invocation. + pub(crate) fn plan(&self) -> &CargoPlan { + &self.plan + } + + /// Re-derives the plan from the bound inputs and compares it with the held one. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the held plan's program, + /// arguments, environment or working directory differ from the derivation. + pub(crate) fn check_plan(&self) -> Result<(), Report> { + let expected = plan_for(&self.request, &self.tools, &self.ambient, &self.child)?; + if self.plan != expected { + return Err(recipe_error( + "Cargo plan differs from the plan derived from the bound inputs", + )); + } + Ok(()) + } + + /// Returns the boundary roles this recipe requires. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for missing or overlapping roots. + pub(crate) fn boundary_roots(&self) -> Result> { + BoundaryRoots::new( + &[ + self.ambient.checkout_root().to_owned(), + self.retained_root.clone(), + ], + std::slice::from_ref(&self.tools.root), + &[ + self.request.output.clone(), + self.request.target_dir.clone(), + self.request.materials.clone(), + ], + ) + } + + /// Requires the exact planned roots and owned descendant cleanup. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the plan was tampered with, the + /// boundary roots differ from [`Self::boundary_roots`], or the boundary + /// does not own descendant cleanup. + fn check_boundary(&self, boundary: &Boundary) -> Result<(), Report> { + self.check_plan()?; + if !boundary.matches_roots(&self.boundary_roots()?) { + return Err(recipe_error( + "boundary roots differ from the exact recipe roots", + )); + } + if !boundary.has_owned_descendant_cleanup() { + return Err(recipe_error( + "boundary does not own descendant cleanup for Cargo build scripts", + )); + } + Ok(()) + } + + /// Checks every gate, then reports the closed production selection. + /// + /// No process is started in slice 1, even for a matching boundary. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the plan, roots or descendant + /// cleanup gate fails, then always [`ReleaseError::Unqualified`] because no + /// Rust toolchain or boundary profile is qualified. + pub(crate) fn execute( + self, + boundary: &Boundary, + _proof: &DenialProof, + ) -> Result<(), Report> { + self.check_boundary(boundary)?; + Err(Report::new(ReleaseError::Unqualified) + .attach("Cargo execution is closed until Rust tools and boundary are qualified")) + } +} + +/// Re-checks root freshness and disjointness immediately before the target is created. +fn check_fresh_and_disjoint( + request: &RecipeRequest, + ambient: &CheckedAmbient, + tools: &SelectedRustTools, + retained_root: &Path, +) -> Result<(), Report> { + empty_directory("materials root", &request.materials)?; + empty_directory("output root", &request.output)?; + absent_in_real_parent("target directory", &request.target_dir)?; + let checkout = std::fs::canonicalize(ambient.checkout_root()) + .map_err(|error| recipe_error(format!("cannot resolve checkout root: {error}")))?; + disjoint(&[ + ("checkout root", checkout.as_path()), + ("Rust tools root", tools.root.as_path()), + ("retained JS root", retained_root), + ("materials root", request.materials.as_path()), + ("output root", request.output.as_path()), + ("target directory", request.target_dir.as_path()), + ]) +} + +/// Derives the exact Cargo invocation from the allowlisted child environment. +fn plan_for( + request: &RecipeRequest, + tools: &SelectedRustTools, + ambient: &CheckedAmbient, + child: &ChildEnvironment, +) -> Result> { + let mut environment = child.variables().clone(); + for (name, path) in [ + (TARGET_DIR_VARIABLE, &request.target_dir), + (PREBUILT_DIR_VARIABLE, &request.js_dir), + (PREBUILT_INVENTORY_VARIABLE, &request.js_inventory), + ] { + let text = path + .to_str() + .ok_or_else(|| recipe_error(format!("{name} is not UTF-8")))?; + environment.insert(name.to_owned(), text.to_owned()); + } + Ok(CargoPlan { + asset: request.asset, + program: tools.cargo(), + arguments: cargo_arguments(request.asset, ambient.jobs()), + environment, + working_directory: ambient.checkout_root().to_owned(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/ambient.rs b/crates/trusted-server-release/src/recipe/ambient.rs new file mode 100644 index 0000000000..b004fdbc94 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/ambient.rs @@ -0,0 +1,414 @@ +//! Ambient build conditions: compiled profiles, observations and A/B pairing. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde_json::{Value, json}; + +use super::fresh::{disjoint, normal_absolute}; +use super::{Role, recipe_error}; +use crate::error::ReleaseError; + +/// Locale every compiler child sees, whatever the host reports. +const NORMALIZED_LOCALE: &str = "C"; +/// Timezone every compiler child sees, whatever the host reports. +const NORMALIZED_TIMEZONE: &str = "UTC"; + +/// A qualified alternate B profile; none exists, so production B stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedAlternate {} + +/// One compiled ambient profile an independent build must run under. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AmbientProfile { + checkout_root: &'static str, + execution_user: &'static str, + jobs: u32, + locale: &'static str, + timezone: &'static str, + umask: u32, +} + +impl AmbientProfile { + /// Profile of the first build; its symbolic user and root are qualification-selected. + pub(crate) const A: Self = Self { + checkout_root: "external-release-A", + execution_user: "qualification-selected", + jobs: 4, + locale: NORMALIZED_LOCALE, + timezone: NORMALIZED_TIMEZONE, + umask: 0o022, + }; + + /// Placeholder of the second build; its alternates await qualification. + pub(crate) const B_PENDING: Self = Self { + checkout_root: "external-release-B", + execution_user: "qualified-alternate-or-unsupported", + jobs: 2, + locale: "qualification-selected-alternate", + timezone: "qualification-selected-alternate", + umask: 0o027, + }; + + /// Selects the production profile of `role`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Unqualified`] for role B: its alternate + /// locale, timezone and user have not been qualified. + pub(crate) fn for_role(role: Role) -> Result> { + match role { + Role::A => Ok(Self::A), + Role::B => Err(Report::new(ReleaseError::Unqualified) + .attach("role B alternate ambient profile is not qualified")), + } + } + + /// Accepts only a qualified alternate; production construction stays closed. + pub(crate) fn alternate(qualification: QualifiedAlternate) -> Self { + match qualification {} + } + + /// Selects a fictional alternate for tests without asserting qualification. + #[cfg(test)] + pub(crate) fn alternate_for_tests() -> Self { + Self::alternate_with_host_for_tests("en_US.UTF-8", "Pacific/Auckland") + } + + /// As [`Self::alternate_for_tests`], with an explicit host locale and timezone. + #[cfg(test)] + pub(crate) fn alternate_with_host_for_tests( + locale: &'static str, + timezone: &'static str, + ) -> Self { + Self { + locale, + timezone, + execution_user: "qualification-selected", + ..Self::B_PENDING + } + } + + /// Renders the profile exactly as `release-tools.json` states it. + pub(crate) fn to_json(&self) -> Value { + json!({ + "checkout_root": self.checkout_root, + "execution_user": self.execution_user, + "jobs": self.jobs, + "locale": self.locale, + "timezone": self.timezone, + "umask": format!("{:03o}", self.umask), + }) + } +} + +/// Fails when tracked `ambient_profiles` settings differ from the compiled profiles. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] naming the profile that drifted. +pub(crate) fn check_profile_drift(settings: &Value) -> Result<(), Report> { + for (name, compiled) in [("A", AmbientProfile::A), ("B", AmbientProfile::B_PENDING)] { + if settings["ambient_profiles"][name] != compiled.to_json() { + return Err(recipe_error(format!( + "tracked ambient profile {name} differs from the compiled profile" + ))); + } + } + Ok(()) +} + +/// One independently varied or normalized build condition. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, derive_more::Display)] +pub(crate) enum Dimension { + /// Process umask. + Umask, + /// Cargo job count. + Jobs, + /// Checkout root path. + CheckoutRoot, + /// Build start time. + StartTime, + /// Host and compiler locale. + Locale, + /// Host and compiler timezone. + Timezone, + /// Executing user. + ExecutionUser, +} + +impl Dimension { + /// Every dimension a pair must account for. + pub(crate) const ALL: [Self; 7] = [ + Self::Umask, + Self::Jobs, + Self::CheckoutRoot, + Self::StartTime, + Self::Locale, + Self::Timezone, + Self::ExecutionUser, + ]; +} + +/// How one dimension is reported in the A/B pair. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Treatment { + Varied, + Normalized, + Unsupported, +} + +/// Conditions an observer reported for one build; carries no authority itself. +#[derive(Clone, Debug)] +pub(crate) struct ObservedAmbient { + /// Process umask of the build, as a numeric mode. + pub(crate) umask: u32, + /// Cargo job count of the build. + pub(crate) jobs: u32, + /// Locale of the host session. + pub(crate) host_locale: String, + /// Timezone of the host session. + pub(crate) host_timezone: String, + /// Locale the compiler child saw. + pub(crate) child_locale: String, + /// Timezone the compiler child saw. + pub(crate) child_timezone: String, + /// Numeric id of the executing user. + pub(crate) execution_uid: u32, + /// Absolute checkout root of the build. + pub(crate) checkout_root: PathBuf, + /// Unix start time of the build in seconds. + pub(crate) started_at_seconds: u64, +} + +/// Observed conditions that match the compiled profile of one role. +#[derive(Clone, Debug)] +pub(crate) struct CheckedAmbient { + role: Role, + profile: AmbientProfile, + observed: ObservedAmbient, +} + +impl CheckedAmbient { + /// Checks `observed` against the profile compiled for `role`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the profile does not belong to + /// the role, or the observed umask, jobs, host locale or timezone differ + /// from it, the compiler child is not normalized, or the checkout root is + /// not an absolute normal path. + pub(crate) fn check( + role: Role, + profile: &AmbientProfile, + observed: ObservedAmbient, + ) -> Result> { + check_profile_role(role, profile)?; + same("umask", &observed.umask, &profile.umask)?; + same("jobs", &observed.jobs, &profile.jobs)?; + same( + "host locale", + &observed.host_locale.as_str(), + &profile.locale, + )?; + same( + "host timezone", + &observed.host_timezone.as_str(), + &profile.timezone, + )?; + same( + "child locale", + &observed.child_locale.as_str(), + &NORMALIZED_LOCALE, + )?; + same( + "child timezone", + &observed.child_timezone.as_str(), + &NORMALIZED_TIMEZONE, + )?; + normal_absolute("checkout root", &observed.checkout_root)?; + Ok(Self { + role, + profile: profile.clone(), + observed, + }) + } + + /// Returns the checked role. + pub(crate) fn role(&self) -> Role { + self.role + } + + /// Returns the Cargo job count the profile fixes. + pub(crate) fn jobs(&self) -> u32 { + self.profile.jobs + } + + /// Returns the checkout root this build runs in. + pub(crate) fn checkout_root(&self) -> &Path { + &self.observed.checkout_root + } +} + +/// Requires an observed value to equal the profile's value. +fn same( + name: &str, + observed: &T, + expected: &T, +) -> Result<(), Report> { + if observed == expected { + return Ok(()); + } + Err(recipe_error(format!( + "observed {name} {observed:?} differs from profile {expected:?}" + ))) +} + +/// Requires the profile to be the runnable one compiled for `role`. +fn check_profile_role(role: Role, profile: &AmbientProfile) -> Result<(), Report> { + let belongs = match role { + Role::A => *profile == AmbientProfile::A, + Role::B => { + *profile != AmbientProfile::B_PENDING + && profile.umask != AmbientProfile::A.umask + && profile.jobs != AmbientProfile::A.jobs + && profile.locale != AmbientProfile::A.locale + && profile.timezone != AmbientProfile::A.timezone + } + }; + if belongs { + return Ok(()); + } + Err(recipe_error(format!( + "ambient profile is not a runnable profile for role {role:?}" + ))) +} + +/// A claimed placement of every dimension, as an attestation would state it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AmbientClaim { + pub(crate) varied: BTreeSet, + pub(crate) normalized: BTreeSet, + pub(crate) unsupported: BTreeSet, +} + +impl AmbientClaim { + /// Returns the only honest placement of the seven dimensions. + pub(crate) fn standard() -> Self { + let place = |treatment| { + Dimension::ALL + .into_iter() + .filter(|dimension| expected_treatment(*dimension) == treatment) + .collect() + }; + Self { + varied: place(Treatment::Varied), + normalized: place(Treatment::Normalized), + unsupported: place(Treatment::Unsupported), + } + } + + fn placements(&self, dimension: Dimension) -> Vec { + [ + (&self.varied, Treatment::Varied), + (&self.normalized, Treatment::Normalized), + (&self.unsupported, Treatment::Unsupported), + ] + .into_iter() + .filter(|(set, _)| set.contains(&dimension)) + .map(|(_, treatment)| treatment) + .collect() + } +} + +fn expected_treatment(dimension: Dimension) -> Treatment { + match dimension { + Dimension::Umask | Dimension::Jobs | Dimension::CheckoutRoot | Dimension::StartTime => { + Treatment::Varied + } + Dimension::Locale | Dimension::Timezone => Treatment::Normalized, + Dimension::ExecutionUser => Treatment::Unsupported, + } +} + +/// Requires every dimension that must vary to vary, and the user to match. +fn check_variation( + first: &ObservedAmbient, + second: &ObservedAmbient, +) -> Result<(), Report> { + for (dimension, differs) in [ + (Dimension::Umask, first.umask != second.umask), + (Dimension::Jobs, first.jobs != second.jobs), + ( + Dimension::StartTime, + first.started_at_seconds != second.started_at_seconds, + ), + ] { + if !differs { + return Err(recipe_error(format!( + "{dimension} is identical across the A and B builds" + ))); + } + } + disjoint(&[ + ("checkout root A", first.checkout_root.as_path()), + ("checkout root B", second.checkout_root.as_path()), + ])?; + if first.execution_uid != second.execution_uid { + return Err(recipe_error( + "execution user differs but no alternate user is qualified", + )); + } + Ok(()) +} + +/// Requires the claim to place every dimension exactly once, as expected. +fn check_claim(claim: &AmbientClaim) -> Result<(), Report> { + for dimension in Dimension::ALL { + let placements = claim.placements(dimension); + if placements.is_empty() { + return Err(recipe_error(format!("claim omits {dimension}"))); + } + if placements != [expected_treatment(dimension)] { + return Err(recipe_error(format!("claim misreports {dimension}"))); + } + } + Ok(()) +} + +/// A verified A/B ambient pairing. +#[derive(Clone, Debug)] +pub(crate) struct AmbientRecord { + claim: AmbientClaim, +} + +impl AmbientRecord { + /// Verifies that the A and B builds varied exactly what the claim states. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the roles are not A then B, a + /// varied dimension did not vary, checkout roots overlap, the execution + /// user differs without qualification, or the claim omits or misplaces a + /// dimension. + pub(crate) fn pair( + first: &CheckedAmbient, + second: &CheckedAmbient, + claim: &AmbientClaim, + ) -> Result> { + if first.role != Role::A || second.role != Role::B { + return Err(recipe_error("ambient pair must be role A then role B")); + } + check_variation(&first.observed, &second.observed)?; + check_claim(claim)?; + Ok(Self { + claim: claim.clone(), + }) + } + + /// Returns the verified placement of every dimension. + pub(crate) fn claim(&self) -> &AmbientClaim { + &self.claim + } +} diff --git a/crates/trusted-server-release/src/recipe/fresh.rs b/crates/trusted-server-release/src/recipe/fresh.rs new file mode 100644 index 0000000000..636698d6fc --- /dev/null +++ b/crates/trusted-server-release/src/recipe/fresh.rs @@ -0,0 +1,114 @@ +//! Lexical and on-disk checks for recipe request roots. + +use std::fs; +use std::io; +use std::path::Path; + +use error_stack::Report; + +use super::recipe_error; +use crate::error::ReleaseError; + +/// Requires an absolute UTF-8 path that is already in normal form. +/// +/// `Path::components` silently drops `.` and doubled or trailing separators, +/// so the text is compared with its rebuilt components instead. +pub(super) fn normal_absolute(label: &str, path: &Path) -> Result<(), Report> { + let normal = path.is_absolute() + && path.to_str().is_some() + && path + .components() + .collect::() + .as_os_str() + == path.as_os_str() + && !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); + if normal { + return Ok(()); + } + Err(recipe_error(format!( + "{label} must be an absolute normal path without dot segments" + ))) +} + +/// Requires `path` to resolve to itself, so no ancestor or leaf is a symlink. +fn unaliased(label: &str, path: &Path) -> Result<(), Report> { + match fs::canonicalize(path) { + Ok(resolved) if resolved == path => Ok(()), + Ok(_) => Err(recipe_error(format!( + "{label} is a symlink or traverses one" + ))), + Err(error) => Err(recipe_error(format!("{label} cannot be resolved: {error}"))), + } +} + +/// Requires an existing real directory that is not an alias. +pub(super) fn real_directory(label: &str, path: &Path) -> Result<(), Report> { + unaliased(label, path)?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| recipe_error(format!("{label} cannot be inspected: {error}")))?; + if !metadata.is_dir() { + return Err(recipe_error(format!("{label} must be a directory"))); + } + Ok(()) +} + +/// Requires an existing real regular file that is not an alias. +pub(super) fn real_file(label: &str, path: &Path) -> Result<(), Report> { + unaliased(label, path)?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| recipe_error(format!("{label} cannot be inspected: {error}")))?; + if !metadata.is_file() { + return Err(recipe_error(format!("{label} must be a regular file"))); + } + Ok(()) +} + +/// Requires a fresh root: a real directory with no entries. +pub(super) fn empty_directory(label: &str, path: &Path) -> Result<(), Report> { + real_directory(label, path)?; + let mut entries = fs::read_dir(path) + .map_err(|error| recipe_error(format!("{label} cannot be listed: {error}")))?; + if entries.next().is_some() { + return Err(recipe_error(format!( + "{label} already has entries and cannot be reused" + ))); + } + Ok(()) +} + +/// Requires a path that does not exist yet inside a real parent directory. +pub(super) fn absent_in_real_parent(label: &str, path: &Path) -> Result<(), Report> { + match fs::symlink_metadata(path) { + Ok(_) => { + return Err(recipe_error(format!( + "{label} already exists and cannot be reused" + ))); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(recipe_error(format!( + "{label} cannot be inspected: {error}" + ))); + } + } + let parent = path + .parent() + .ok_or_else(|| recipe_error(format!("{label} has no parent directory")))?; + real_directory(label, parent) +} + +/// Rejects any two labelled paths where one contains or equals the other. +pub(super) fn disjoint(paths: &[(&str, &Path)]) -> Result<(), Report> { + for (index, (first_label, first)) in paths.iter().enumerate() { + for (second_label, second) in paths.iter().skip(index + 1) { + if first.starts_with(second) || second.starts_with(first) { + return Err(recipe_error(format!( + "{first_label} and {second_label} overlap" + ))); + } + } + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs new file mode 100644 index 0000000000..19fe6db8e8 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -0,0 +1,301 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use super::ambient::ObservedAmbient; +use super::*; +use serde_json::Value; + +const COHORT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/release/release-cohort.json" +)); + +#[test] +fn unknown_asset_id_is_rejected() { + for name in [ + "", + "ts-aarch64-apple-darwin ", + "TS-AARCH64-APPLE-DARWIN", + "../ts-aarch64-apple-darwin", + "ts-riscv64-example-linux", + ] { + assert!( + AssetId::parse(name).is_err_and(|error| format!("{error:?}").contains("unknown asset")), + "should reject unknown asset id {name:?}" + ); + } +} + +#[test] +fn asset_selection_comes_from_fixed_table_not_json() { + let cohort: Value = serde_json::from_str(COHORT).expect("should parse tracked cohort"); + for entry in cohort["artifacts"] + .as_array() + .expect("should list cohort artifacts") + { + let name = entry["name"].as_str().expect("should name artifact"); + let asset = AssetId::parse(name).expect("should accept every fixed asset"); + let fixed = FIXED_ARTIFACTS + .iter() + .find(|fixed| fixed.name == name) + .expect("should find the fixed table row"); + assert_eq!( + asset.artifact().package, + fixed.package, + "should use table package" + ); + assert_eq!(asset.artifact().bin, fixed.bin, "should use table binary"); + assert_eq!( + asset.artifact().target, + fixed.target, + "should use table target" + ); + let mut forged = entry.clone(); + forged["package"] = Value::from("example-forged-package"); + assert_eq!( + AssetId::parse(forged["name"].as_str().expect("should keep forged name")) + .expect("should still resolve") + .artifact() + .package, + fixed.package, + "should ignore JSON-supplied package" + ); + } +} + +#[test] +fn unknown_role_is_rejected() { + assert_eq!(Role::parse("A").expect("should accept A"), Role::A); + assert_eq!(Role::parse("B").expect("should accept B"), Role::B); + for text in ["", "a", "b", "C", "AB", " A"] { + assert!( + Role::parse(text).is_err_and(|error| format!("{error:?}").contains("unknown role")), + "should reject role {text:?}" + ); + } +} + +#[test] +fn non_default_variant_is_rejected() { + check_variant("default").expect("should accept the default variant"); + for variant in ["", "Default", "minimal", "default "] { + assert!( + check_variant(variant).is_err_and(|error| format!("{error:?}").contains("variant")), + "should reject variant {variant:?}" + ); + } +} + +/// A fictional on-disk layout in which every request root is individually valid. +struct Workspace { + directory: tempfile::TempDir, + root: PathBuf, +} + +impl Workspace { + fn new() -> Self { + let directory = tempfile::tempdir().expect("should create workspace"); + let root = fs::canonicalize(directory.path()).expect("should canonicalize workspace"); + fs::create_dir_all(root.join("js/bundles")).expect("should create JS bundle root"); + fs::write(root.join("js/bundles/tsjs-core.js"), b"fictional module") + .expect("should write JS module"); + fs::write(root.join("js/tsjs-inventory.json"), b"fictional inventory") + .expect("should write JS inventory"); + fs::create_dir(root.join("materials")).expect("should create materials root"); + fs::create_dir(root.join("output")).expect("should create output root"); + Self { directory, root } + } + + fn path(&self, relative: &str) -> PathBuf { + self.root.join(relative) + } + + fn input<'a>(&'a self, paths: &'a [PathBuf; 5]) -> RecipeInput<'a> { + RecipeInput { + asset: "ts-aarch64-apple-darwin", + variant: "default", + role: "A", + commit: COMMIT, + js_dir: &paths[0], + js_inventory: &paths[1], + materials: &paths[2], + output: &paths[3], + target_dir: &paths[4], + } + } + + fn defaults(&self) -> [PathBuf; 5] { + [ + self.path("js/bundles"), + self.path("js/tsjs-inventory.json"), + self.path("materials"), + self.path("output"), + self.path("target"), + ] + } +} + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn parse_error(paths: &[PathBuf; 5], workspace: &Workspace) -> String { + let error = match RecipeRequest::parse(&workspace.input(paths)) { + Ok(_) => panic!("should reject the request"), + Err(error) => error, + }; + format!("{error:?}") +} + +#[test] +fn relative_or_dotted_paths_are_rejected() { + let workspace = Workspace::new(); + RecipeRequest::parse(&workspace.input(&workspace.defaults())) + .expect("should accept the baseline layout"); + let bad_values = [ + PathBuf::from("relative/path"), + workspace.path("output/../output"), + workspace.path("./output"), + ]; + for index in 0..5 { + for bad in &bad_values { + let mut paths = workspace.defaults(); + paths[index] = bad.clone(); + assert!( + parse_error(&paths, &workspace).contains("absolute normal path"), + "should reject non-normal path {bad:?} at field {index}" + ); + } + } +} + +#[test] +fn existing_nonempty_output_root_is_rejected() { + let workspace = Workspace::new(); + fs::write(workspace.path("output/leftover"), b"previous attempt") + .expect("should write a leftover entry"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), + "should reject an output root with prior entries" + ); + let mut absent = workspace.defaults(); + absent[3] = workspace.path("missing-output"); + assert!( + parse_error(&absent, &workspace).contains("cannot be resolved"), + "should reject an output root that does not exist" + ); +} + +#[test] +fn symlinked_or_preexisting_materials_root_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("real-materials")).expect("should create alias target"); + symlink( + workspace.path("real-materials"), + workspace.path("linked-materials"), + ) + .expect("should create materials alias"); + let mut linked = workspace.defaults(); + linked[2] = workspace.path("linked-materials"); + assert!( + parse_error(&linked, &workspace).contains("symlink"), + "should reject a symlinked materials root" + ); + fs::write(workspace.path("materials/cache"), b"prepared earlier") + .expect("should populate materials"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), + "should reject a pre-populated materials root" + ); +} + +#[test] +fn reused_target_directory_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("target")).expect("should create a previous target"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), + "should reject a target directory that already exists" + ); +} + +#[test] +fn overlapping_roots_are_rejected() { + let workspace = Workspace::new(); + let cases: [(usize, &str); 4] = [ + (3, "materials"), + (4, "output/target"), + (4, "materials/target"), + (4, "js/target"), + ]; + for (index, relative) in cases { + let mut paths = workspace.defaults(); + paths[index] = workspace.path(relative); + assert!( + parse_error(&paths, &workspace).contains("overlap"), + "should reject {relative} overlapping another root" + ); + } +} + +fn observed_a() -> ObservedAmbient { + ObservedAmbient { + umask: 0o022, + jobs: 4, + host_locale: "C".to_owned(), + host_timezone: "UTC".to_owned(), + child_locale: "C".to_owned(), + child_timezone: "UTC".to_owned(), + execution_uid: 501, + checkout_root: PathBuf::from("/example/release-a"), + started_at_seconds: 1_700_000_000, + } +} + +fn observed_b() -> ObservedAmbient { + ObservedAmbient { + umask: 0o027, + jobs: 2, + host_locale: "en_US.UTF-8".to_owned(), + host_timezone: "Pacific/Auckland".to_owned(), + child_locale: "C".to_owned(), + child_timezone: "UTC".to_owned(), + execution_uid: 501, + checkout_root: PathBuf::from("/example/release-b"), + started_at_seconds: 1_700_000_900, + } +} + +fn checked_a() -> CheckedAmbient { + CheckedAmbient::check(Role::A, &AmbientProfile::A, observed_a()) + .expect("should accept the baseline A ambient") +} + +fn checked_b() -> CheckedAmbient { + CheckedAmbient::check( + Role::B, + &AmbientProfile::alternate_for_tests(), + observed_b(), + ) + .expect("should accept the baseline B ambient") +} + +mod ambient; +mod bind; + +#[test] +fn symlinked_ancestor_of_target_parent_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("real-parent")).expect("should create alias target"); + symlink( + workspace.path("real-parent"), + workspace.path("linked-parent"), + ) + .expect("should create parent alias"); + fs::create_dir(workspace.path("real-parent/inner")).expect("should create inner parent"); + let mut paths = workspace.defaults(); + paths[4] = workspace.path("linked-parent/inner/target"); + assert!( + parse_error(&paths, &workspace).contains("symlink"), + "should reject a target below a symlinked ancestor" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/ambient.rs b/crates/trusted-server-release/src/recipe/tests/ambient.rs new file mode 100644 index 0000000000..f3c5c440b5 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/ambient.rs @@ -0,0 +1,260 @@ +use std::path::PathBuf; + +use serde_json::Value; + +use super::super::ambient::{ + AmbientClaim, AmbientRecord, Dimension, ObservedAmbient, check_profile_drift, +}; +use super::*; + +const TOOLS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/release/release-tools.json" +)); + +fn check_error(role: Role, profile: &AmbientProfile, observed: ObservedAmbient) -> String { + match CheckedAmbient::check(role, profile, observed) { + Ok(_) => panic!("should reject the ambient"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn role_b_with_role_a_umask_is_rejected() { + checked_a(); + checked_b(); + let b_profile = AmbientProfile::alternate_for_tests(); + let wrong_umask = ObservedAmbient { + umask: 0o022, + ..observed_b() + }; + assert!( + check_error(Role::B, &b_profile, wrong_umask).contains("umask"), + "should reject role B observed under the role A umask" + ); + let a_with_b_umask = ObservedAmbient { + umask: 0o027, + ..observed_a() + }; + assert!( + check_error(Role::A, &AmbientProfile::A, a_with_b_umask).contains("umask"), + "should reject role A observed under the role B umask" + ); + assert!( + check_error(Role::B, &AmbientProfile::A, observed_a()).contains("runnable profile"), + "should reject role B bound to the role A profile" + ); + assert!( + check_error(Role::A, &b_profile, observed_b()).contains("runnable profile"), + "should reject role A bound to an alternate profile" + ); + let closed = AmbientProfile::for_role(Role::B).expect_err("should keep B closed"); + assert!( + format!("{closed:?}").contains("not qualified"), + "should name the missing qualification" + ); +} + +#[test] +fn observed_jobs_mismatch_is_rejected() { + let a_wrong = ObservedAmbient { + jobs: 2, + ..observed_a() + }; + assert!( + check_error(Role::A, &AmbientProfile::A, a_wrong).contains("jobs"), + "should reject A observed with B jobs" + ); + let b_wrong = ObservedAmbient { + jobs: 8, + ..observed_b() + }; + assert!( + check_error(Role::B, &AmbientProfile::alternate_for_tests(), b_wrong).contains("jobs"), + "should reject B observed with unrelated jobs" + ); +} + +fn claim_error(first: &CheckedAmbient, second: &CheckedAmbient, claim: &AmbientClaim) -> String { + match AmbientRecord::pair(first, second, claim) { + Ok(_) => panic!("should reject the pairing"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn normalized_locale_reported_as_varied_is_rejected() { + let record = AmbientRecord::pair(&checked_a(), &checked_b(), &AmbientClaim::standard()) + .expect("should accept the standard claim"); + assert_eq!( + record.claim(), + &AmbientClaim::standard(), + "should keep the verified placement" + ); + for dimension in [Dimension::Locale, Dimension::Timezone] { + let mut claim = AmbientClaim::standard(); + claim.normalized.remove(&dimension); + claim.varied.insert(dimension); + assert!( + claim_error(&checked_a(), &checked_b(), &claim).contains("misreports"), + "should reject {dimension} claimed as varied" + ); + } + let leaking_child = ObservedAmbient { + child_locale: "en_US.UTF-8".to_owned(), + ..observed_b() + }; + assert!( + check_error( + Role::B, + &AmbientProfile::alternate_for_tests(), + leaking_child + ) + .contains("child locale"), + "should reject a compiler child that sees the host locale" + ); +} + +#[test] +fn unsupported_execution_user_claimed_as_varied_is_rejected() { + let mut claim = AmbientClaim::standard(); + claim.unsupported.remove(&Dimension::ExecutionUser); + claim.varied.insert(Dimension::ExecutionUser); + assert!( + claim_error(&checked_a(), &checked_b(), &claim).contains("misreports"), + "should reject an unqualified user claimed as varied" + ); + let other_user = CheckedAmbient::check( + Role::B, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + execution_uid: 502, + ..observed_b() + }, + ) + .expect("should check the alternate user in isolation"); + assert!( + claim_error(&checked_a(), &other_user, &AmbientClaim::standard()) + .contains("execution user"), + "should reject an actually different unqualified user" + ); +} + +#[test] +fn omitted_ambient_dimension_is_rejected() { + for dimension in Dimension::ALL { + let mut claim = AmbientClaim::standard(); + claim.varied.remove(&dimension); + claim.normalized.remove(&dimension); + claim.unsupported.remove(&dimension); + assert!( + claim_error(&checked_a(), &checked_b(), &claim).contains("omits"), + "should reject a claim omitting {dimension}" + ); + } + let mut duplicated = AmbientClaim::standard(); + duplicated.normalized.insert(Dimension::Jobs); + assert!( + claim_error(&checked_a(), &checked_b(), &duplicated).contains("misreports"), + "should reject a dimension placed twice" + ); +} + +#[test] +fn identical_checkout_roots_for_a_and_b_are_rejected() { + for root in [ + "/example/release-a", + "/example/release-a/nested", + "/example", + ] { + let overlapping = CheckedAmbient::check( + Role::B, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + checkout_root: PathBuf::from(root), + ..observed_b() + }, + ) + .expect("should check the alternate root in isolation"); + assert!( + claim_error(&checked_a(), &overlapping, &AmbientClaim::standard()).contains("overlap"), + "should reject checkout root {root} overlapping A" + ); + } + let same_start = CheckedAmbient::check( + Role::B, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + started_at_seconds: observed_a().started_at_seconds, + ..observed_b() + }, + ) + .expect("should check the alternate start in isolation"); + assert!( + claim_error(&checked_a(), &same_start, &AmbientClaim::standard()).contains("StartTime"), + "should reject builds that did not start at different times" + ); +} + +#[test] +fn rust_profile_drift_from_release_tools_json_is_rejected() { + let tools: Value = serde_json::from_str(TOOLS).expect("should parse tracked tools"); + let recipes = tools["recipes"].as_array().expect("should list recipes"); + let mut checked = 0; + for recipe in recipes + .iter() + .filter(|recipe| recipe["script"] == "scripts/release/build.sh") + { + check_profile_drift(&recipe["settings"]) + .expect("should accept the tracked ambient profiles"); + checked += 1; + for (profile, field, value) in [ + ("A", "umask", Value::from("027")), + ("A", "jobs", Value::from(2)), + ("B", "jobs", Value::from(4)), + ("B", "umask", Value::from("022")), + ("B", "locale", Value::from("C")), + ] { + let mut drifted = recipe["settings"].clone(); + drifted["ambient_profiles"][profile][field] = value; + assert!( + check_profile_drift(&drifted) + .is_err_and(|error| format!("{error:?}").contains(profile)), + "should reject drifted {profile}.{field}" + ); + } + let mut missing = recipe["settings"].clone(); + missing["ambient_profiles"] + .as_object_mut() + .expect("should hold profiles") + .remove("B"); + assert!( + check_profile_drift(&missing) + .is_err_and(|error| format!("{error:?}").contains("profile B")), + "should reject a missing B profile" + ); + } + assert_eq!(checked, 4, "should check every artifact recipe"); +} + +#[test] +fn role_b_with_a_locale_or_timezone_is_rejected() { + let same_locale = AmbientProfile::alternate_with_host_for_tests("C", "Pacific/Auckland"); + let observed = ObservedAmbient { + host_locale: "C".to_owned(), + ..observed_b() + }; + assert!( + check_error(Role::B, &same_locale, observed).contains("runnable profile"), + "should reject role B that keeps the role A locale" + ); + let same_timezone = AmbientProfile::alternate_with_host_for_tests("en_US.UTF-8", "UTC"); + let observed = ObservedAmbient { + host_timezone: "UTC".to_owned(), + ..observed_b() + }; + assert!( + check_error(Role::B, &same_timezone, observed).contains("runnable profile"), + "should reject role B that keeps the role A timezone" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/bind.rs b/crates/trusted-server-release/src/recipe/tests/bind.rs new file mode 100644 index 0000000000..c682fc1db1 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/bind.rs @@ -0,0 +1,536 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::*; +use crate::policy::ReleaseTarget; +use crate::preflight::audit_environment; +use crate::preflight::child::build_child_for_tests; + +const OTHER_COMMIT: &str = "fedcba9876543210fedcba9876543210fedcba98"; + +fn audited() -> AuditedEnvironment { + let snapshot: Vec<(OsString, OsString)> = [ + ("PATH", "/usr/bin"), + ("TRUSTED_SERVER__PROXY__SECRET", "fictional"), + ("LANG", "en_US.UTF-8"), + ] + .into_iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect(); + audit_environment(&snapshot).expect("should audit benign environment") +} + +/// A fictional layout with a completed JS handoff and every recipe root. +struct Fixture { + _directory: tempfile::TempDir, + base: PathBuf, + completed: JsBuildCompleted, +} + +impl Fixture { + fn new(js_commit: &str) -> Self { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + for relative in [ + "materials", + "output", + "checkout-a", + "checkout-b", + "tools/bin", + "other-source", + "other-work", + ] { + fs::create_dir_all(base.join(relative)).expect("should create fixture directory"); + } + for tool in ["tools/bin/cargo", "tools/bin/rustc"] { + fs::write(base.join(tool), b"fictional tool").expect("should write fictional tool"); + } + Self { + _directory: directory, + base, + completed: JsBuildCompleted::fixture_for_tests(js_commit), + } + } + + fn retained(&self) -> PathBuf { + fs::canonicalize( + self.completed + .rehash_for_handoff() + .expect("should rehash the fixture handoff"), + ) + .expect("should canonicalize the retained root") + } + + fn request(&self, asset: &str, role: &str, commit: &str) -> RecipeRequest { + self.request_at(asset, role, commit, &self.base.join("target")) + } + + fn request_at( + &self, + asset: &str, + role: &str, + commit: &str, + target_dir: &Path, + ) -> RecipeRequest { + let retained = self.retained(); + let js_dir = retained.join("bundles"); + let js_inventory = retained.join("tsjs-inventory.json"); + RecipeRequest::parse(&RecipeInput { + asset, + variant: "default", + role, + commit, + js_dir: &js_dir, + js_inventory: &js_inventory, + materials: &self.base.join("materials"), + output: &self.base.join("output"), + target_dir, + }) + .expect("should parse the fixture request") + } + + fn ambient(&self, role: Role) -> CheckedAmbient { + match role { + Role::A => CheckedAmbient::check( + Role::A, + &AmbientProfile::A, + ObservedAmbient { + checkout_root: self.base.join("checkout-a"), + ..observed_a() + }, + ), + Role::B => CheckedAmbient::check( + Role::B, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + checkout_root: self.base.join("checkout-b"), + ..observed_b() + }, + ), + } + .expect("should check the fixture ambient") + } + + fn tools(&self) -> SelectedRustTools { + SelectedRustTools::for_tests(&self.base.join("tools")).expect("should select fixture tools") + } + + fn bind_with( + &self, + request: RecipeRequest, + role: Role, + commit: &str, + ) -> Result> { + let commit = CommitId::parse(commit).expect("should parse fixture commit"); + let child = + build_child_for_tests(&audited(), &commit, 1_700_000_000, &child_request(&request)) + .expect("should build the fixture child environment"); + PreparedRecipe::bind(BindInputs { + commit: &commit, + child, + completed: &self.completed, + tools: self.tools(), + ambient: self.ambient(role), + request, + }) + } + + fn bind(&self, asset: &str, role: Role) -> Result> { + let text = if role == Role::A { "A" } else { "B" }; + self.bind_with(self.request(asset, text, COMMIT), role, COMMIT) + } +} + +fn plan_error(prepared: &PreparedRecipe) -> String { + match prepared.check_plan() { + Ok(()) => panic!("should reject the plan"), + Err(error) => format!("{error:?}"), + } +} + +fn bind_error(result: Result>) -> String { + match result { + Ok(_) => panic!("should reject the binding"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn js_handoff_for_other_source_commit_is_rejected() { + Fixture::new(COMMIT) + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the matching handoff"); + let other = Fixture::new(OTHER_COMMIT); + assert!( + bind_error(other.bind("ts-aarch64-apple-darwin", Role::A)) + .contains("another source commit"), + "should reject a JS handoff built from another commit" + ); + assert!( + !other.base.join("target").exists(), + "should not create the target after a rejection" + ); + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", OTHER_COMMIT); + assert!( + bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("another source commit"), + "should reject a request naming another commit" + ); +} + +#[test] +fn js_handoff_mutated_after_completion_is_rejected() { + let changed = Fixture::new(COMMIT); + let request = changed.request("ts-aarch64-apple-darwin", "A", COMMIT); + fs::write(changed.retained().join("bundles/tsjs-core.js"), b"tampered") + .expect("should mutate a retained module"); + assert!( + bind_error(changed.bind_with(request, Role::A, COMMIT)).contains("no longer rehashes"), + "should reject a mutated retained module" + ); + let extra = Fixture::new(COMMIT); + let request = extra.request("ts-aarch64-apple-darwin", "A", COMMIT); + fs::write(extra.retained().join("unrecorded"), b"extra").expect("should add an entry"); + assert!( + bind_error(extra.bind_with(request, Role::A, COMMIT)).contains("no longer rehashes"), + "should reject an unrecorded retained entry" + ); + assert!( + !changed.base.join("target").exists(), + "should not create the target after a rejection" + ); +} + +#[test] +fn request_for_other_js_directory_or_role_is_rejected() { + let fixture = Fixture::new(COMMIT); + let foreign = Fixture::new(COMMIT); + let request = foreign.request("ts-aarch64-apple-darwin", "A", COMMIT); + assert!( + bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("does not name"), + "should reject a request naming another JS handoff directory" + ); + let request = fixture.request("ts-aarch64-apple-darwin", "B", COMMIT); + assert!( + bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("ambient role"), + "should reject an ambient of another role" + ); +} + +#[test] +fn cargo_plan_has_fixed_release_frozen_json_flags() { + for (index, artifact) in FIXED_ARTIFACTS.iter().enumerate() { + let role = if index % 2 == 0 { Role::A } else { Role::B }; + let jobs = if role == Role::A { "4" } else { "2" }; + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind(artifact.name, role) + .expect("should bind every fixed asset"); + let expected_arguments = [ + "build", + "--package", + artifact.package, + "--bin", + artifact.bin, + "--target", + artifact.target.triple(), + "--release", + "--frozen", + "--message-format=json-render-diagnostics", + "--jobs", + jobs, + ]; + assert_eq!( + prepared.plan().arguments, + expected_arguments, + "should use the exact fixed arguments for {}", + artifact.name + ); + assert_eq!( + prepared.plan().program, + fixture.base.join("tools/bin/cargo"), + "should run the selected Cargo" + ); + let checkout = if role == Role::A { + "checkout-a" + } else { + "checkout-b" + }; + assert_eq!( + prepared.plan().working_directory, + fixture.base.join(checkout), + "should run in the role checkout" + ); + assert_environment(&fixture, &prepared, artifact.target); + assert!( + fixture.base.join("target").is_dir(), + "should create the fresh target directory" + ); + prepared + .check_plan() + .expect("should accept the untouched plan"); + } +} + +fn assert_environment(fixture: &Fixture, prepared: &PreparedRecipe, target: ReleaseTarget) { + let retained = fixture.retained(); + let path = |path: PathBuf| path.to_str().expect("should be UTF-8").to_owned(); + let mut expected = BTreeMap::from([ + ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), + ( + format!( + "AWS_LC_SYS_USE_SYSTEM_{}", + target.triple().replace('-', "_") + ), + "0".to_owned(), + ), + ( + "CARGO_HOME".to_owned(), + path(fixture.base.join("materials")), + ), + ( + "CARGO_TARGET_DIR".to_owned(), + path(fixture.base.join("target")), + ), + ("HOME".to_owned(), path(fixture.base.join("materials/home"))), + ("LC_ALL".to_owned(), "C".to_owned()), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), + ("SOURCE_DATE_EPOCH".to_owned(), "1700000000".to_owned()), + ("TRUSTED_SERVER__GIT_VERSION".to_owned(), COMMIT.to_owned()), + ("TRUSTED_SERVER__RELEASE_BUILD".to_owned(), "1".to_owned()), + ( + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY".to_owned(), + path(retained.join("tsjs-inventory.json")), + ), + ( + "TSJS_PREBUILT_DIR".to_owned(), + path(retained.join("bundles")), + ), + ("TZ".to_owned(), "UTC".to_owned()), + ]); + if target.is_darwin() { + expected.insert("MACOSX_DEPLOYMENT_TARGET".to_owned(), "13.0".to_owned()); + } + assert_eq!( + prepared.plan().environment, + expected, + "should hold exactly the child environment plus three recipe variables" + ); +} + +#[test] +fn cargo_plan_rejects_inherited_rustflags_and_selectors() { + for name in [ + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_TARGET", + "RUSTUP_TOOLCHAIN", + "CC", + "TSJS_SKIP_BUILD", + ] { + let snapshot = [(OsString::from(name), OsString::from("fictional"))]; + let rejection = match audit_environment(&snapshot) { + Ok(_) => panic!("should reject inherited {name}"), + Err(error) => format!("{error:?}"), + }; + assert!( + rejection.contains(name), + "should reject inherited {name} before any plan exists" + ); + } + let fixture = Fixture::new(COMMIT); + let mut prepared = fixture + .bind("ts-x86_64-unknown-linux-gnu", Role::A) + .expect("should bind from a benign environment"); + let plan = prepared.plan(); + for name in plan.environment.keys() { + assert!( + !name.starts_with("RUST") && !name.starts_with("CARGO_BUILD"), + "should not carry selector {name}" + ); + } + for argument in &plan.arguments { + assert!( + ![ + "--config", + "--features", + "--all-features", + "--no-default-features", + "--manifest-path" + ] + .iter() + .any(|selector| argument.starts_with(selector)) + && !argument.starts_with('+'), + "should not carry selector argument {argument}" + ); + } + prepared + .plan + .environment + .insert("RUSTFLAGS".to_owned(), "--cfg fictional".to_owned()); + assert!( + plan_error(&prepared).contains("differs"), + "should reject an injected RUSTFLAGS" + ); + prepared.plan.environment.remove("RUSTFLAGS"); + prepared + .check_plan() + .expect("should accept the restored plan"); + prepared.plan.environment.remove("LC_ALL"); + assert!( + plan_error(&prepared).contains("differs"), + "should reject a removed normalization variable" + ); + let fixture = Fixture::new(COMMIT); + let mut prepared = fixture + .bind("ts-x86_64-unknown-linux-gnu", Role::A) + .expect("should bind again"); + for change in ["--features=fictional", "+nightly", "--config=fictional"] { + prepared.plan.arguments.push(change.to_owned()); + assert!( + plan_error(&prepared).contains("differs"), + "should reject added argument {change}" + ); + prepared.plan.arguments.pop(); + } + prepared + .plan + .arguments + .retain(|argument| argument != "--release"); + assert!( + plan_error(&prepared).contains("differs"), + "should reject a dropped --release" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn execute_rejects_boundary_with_other_roots() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + let other = BoundaryRoots::new( + &[fixture.base.join("other-source")], + &[fixture.base.join("tools")], + &[fixture.base.join("other-work")], + ) + .expect("should build unrelated roots"); + let boundary = Boundary::mac_sandbox_for_tests(other).expect("should configure, not run"); + let error = prepared + .execute(&boundary, &DenialProof::for_tests()) + .expect_err("should reject a boundary over other roots"); + assert!( + format!("{error:?}").contains("roots differ"), + "should name the root mismatch" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn execute_rejects_boundary_without_owned_descendant_cleanup() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + let roots = prepared + .boundary_roots() + .expect("should derive exact roots"); + let boundary = + Boundary::mac_sandbox_for_tests(roots.clone()).expect("should configure, not run"); + assert!( + boundary.matches_roots(&roots), + "should hold the exact recipe roots so only cleanup can reject" + ); + let error = prepared + .execute(&boundary, &DenialProof::for_tests()) + .expect_err("should reject a boundary without owned cleanup"); + assert!( + format!("{error:?}").contains("descendant cleanup"), + "should name the missing descendant cleanup" + ); +} + +#[test] +fn qualified_rust_tools_constructor_is_uninhabited() { + let never: fn(QualifiedRustTools) -> ! = |qualification| match qualification {}; + let _constructor: fn(&Path, QualifiedRustTools) -> SelectedRustTools = + SelectedRustTools::qualified; + let _ = never; + let closed = + AmbientProfile::for_role(Role::B).expect_err("should keep the alternate ambient closed"); + assert!( + format!("{closed:?}").contains("not qualified"), + "should name the missing qualification" + ); +} + +#[test] +fn root_filled_between_parse_and_bind_is_rejected() { + for (relative, label) in [("materials", "materials"), ("output", "output")] { + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + fs::write( + fixture.base.join(relative).join("late"), + b"written after parse", + ) + .expect("should fill the root after parsing"); + let error = bind_error(fixture.bind_with(request, Role::A, COMMIT)); + assert!( + error.contains(label) && error.contains("cannot be reused"), + "should reject a {label} root filled after parsing" + ); + assert!( + !fixture.base.join("target").exists(), + "should not create the target after a rejection" + ); + } + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + fs::create_dir(fixture.base.join("target")).expect("should create the target after parse"); + assert!( + bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("cannot be reused"), + "should reject a target created after parsing" + ); +} + +#[test] +fn target_inside_checkout_is_rejected_before_creation() { + let fixture = Fixture::new(COMMIT); + let inside = fixture.base.join("checkout-a/target"); + let request = fixture.request_at("ts-aarch64-apple-darwin", "A", COMMIT, &inside); + assert!( + bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("overlap"), + "should reject a target inside the checkout root" + ); + assert!(!inside.exists(), "should create nothing in the checkout"); + let fixture = Fixture::new(COMMIT); + let inside_tools = fixture.base.join("tools/target"); + let request = fixture.request_at("ts-aarch64-apple-darwin", "A", COMMIT, &inside_tools); + assert!( + bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("overlap"), + "should reject a target inside the tools root" + ); + assert!(!inside_tools.exists(), "should create nothing in the tools"); +} + +#[test] +fn tampered_program_or_working_directory_is_rejected() { + let fixture = Fixture::new(COMMIT); + let mut prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + prepared.plan.program = fixture.base.join("tools/bin/rustc"); + assert!( + plan_error(&prepared).contains("differs"), + "should reject a swapped program" + ); + prepared.plan.program = fixture.base.join("tools/bin/cargo"); + prepared.plan.working_directory = fixture.base.join("checkout-b"); + assert!( + plan_error(&prepared).contains("differs"), + "should reject a swapped working directory" + ); +} From 8678c3efd4860ec94c451b2c10f7ef50619eac6c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 22:32:38 +0530 Subject: [PATCH 067/153] Apply role umasks to confined release commands Add a validated Umask (at most 0o777, shown as three octal digits) to bounded and confined commands. It is set in the child only, before exec. On macOS the sandboxed program inherits it; in Docker the program runs through the standard `/bin/sh -c 'umask NNN && exec "$@"' sh` wrapper, whose script is built only from the validated digits while the program and arguments stay positional. Recipe plans carry the role umask (A 022, B 027) and the plan recheck covers it. Accept exactly AWS_LC_SYS_USE_SYSTEM and its per-target form as plain build switches instead of credentials, so the recipe's own child environment can reach a confined command; every other AWS_ name is still refused as a plain variable. --- crates/trusted-server-release/src/boundary.rs | 2 +- .../src/boundary/command.rs | 21 +++ .../src/boundary/docker.rs | 33 ++++- .../src/boundary/macos.rs | 3 + .../src/boundary/tests/container.rs | 120 ++++++++++++++++++ .../src/boundary/tests/linux.rs | 24 ++++ .../src/boundary/tests/macos.rs | 38 ++++++ .../src/boundary/tests/modes.rs | 16 +++ crates/trusted-server-release/src/process.rs | 52 ++++++++ .../src/process/environment.rs | 20 ++- .../src/process/tests/variables.rs | 29 +++++ crates/trusted-server-release/src/recipe.rs | 29 ++++- .../src/recipe/ambient.rs | 11 ++ .../src/recipe/tests/bind.rs | 86 +++++++++++++ 14 files changed, 476 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-release/src/boundary.rs b/crates/trusted-server-release/src/boundary.rs index dbd418e9f6..9d46cdfa7e 100644 --- a/crates/trusted-server-release/src/boundary.rs +++ b/crates/trusted-server-release/src/boundary.rs @@ -74,7 +74,7 @@ use std::path::Path; use error_stack::Report; use sha2::{Digest as _, Sha256}; -pub(crate) use command::ConfinedCommand; +pub(crate) use command::{ConfinedCommand, Umask}; pub(crate) use docker::{DockerSettings, ImageSelection}; pub(crate) use probe::{DENIED_STATUS, ProbeProgram, ProbeReport, connect_probe, parse_loopback}; pub(crate) use proof::DenialProof; diff --git a/crates/trusted-server-release/src/boundary/command.rs b/crates/trusted-server-release/src/boundary/command.rs index 0f16861779..79fca5b670 100644 --- a/crates/trusted-server-release/src/boundary/command.rs +++ b/crates/trusted-server-release/src/boundary/command.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use error_stack::Report; use crate::error::ReleaseError; +pub(crate) use crate::process::Umask; use crate::process::{BoundedCommand, Cancellation, ProcessLimits}; /// A child to run inside a [`Boundary`](super::Boundary), described by host @@ -28,6 +29,7 @@ pub(crate) struct ConfinedCommand { pub(super) stdin: Option>, pub(super) limits: ProcessLimits, pub(super) cancellation: Option, + pub(super) umask: Option, } impl fmt::Debug for ConfinedCommand { @@ -48,6 +50,7 @@ impl fmt::Debug for ConfinedCommand { .field("stdin_bytes", &self.stdin.as_ref().map(Vec::len)) .field("limits", &self.limits) .field("cancellation", &self.cancellation) + .field("umask", &self.umask) .finish() } } @@ -97,6 +100,7 @@ impl ConfinedCommand { stdin: None, limits, cancellation: None, + umask: None, }) } @@ -158,6 +162,23 @@ impl ConfinedCommand { self } + /// Runs the child under `umask`. + /// + /// macOS sets it in the child just before `sandbox-exec` executes; Linux + /// runs the program through `/bin/sh` of the selected image, which must + /// exist there. The orchestrator's own umask is never changed. + #[must_use] + pub(crate) fn umask(mut self, umask: Umask) -> Self { + self.umask = Some(umask); + self + } + + /// Returns the requested umask, for tests of command construction. + #[cfg(test)] + pub(crate) fn requested_umask(&self) -> Option { + self.umask + } + /// Applies stdin and cancellation to the wrapping bounded command. pub(super) fn finish(&self, mut bounded: BoundedCommand) -> BoundedCommand { if let Some(bytes) = &self.stdin { diff --git a/crates/trusted-server-release/src/boundary/docker.rs b/crates/trusted-server-release/src/boundary/docker.rs index 857a1645f7..3ec99690f0 100644 --- a/crates/trusted-server-release/src/boundary/docker.rs +++ b/crates/trusted-server-release/src/boundary/docker.rs @@ -29,9 +29,9 @@ use std::path::{Path, PathBuf}; use error_stack::Report; use sha2::{Digest as _, Sha256}; -use super::ConfinedCommand; use super::daemon::{DOCKER_SOCKET, DockerClient, check_daemon_socket, host_arguments}; use super::roots::{BoundaryRoots, RootRole, validate_path_text}; +use super::{ConfinedCommand, Umask}; use crate::error::ReleaseError; use crate::process::{ProcessLimits, ProcessOutcome, ProcessResult}; @@ -39,6 +39,10 @@ use crate::process::{ProcessLimits, ProcessOutcome, ProcessResult}; const MOUNT_PREFIX: &str = "/boundary"; /// Configuration fingerprint domain. const FINGERPRINT_DOMAIN: &[u8] = b"trusted-server-boundary-docker-v1\n"; +/// Fixed absolute shell of the selected image that applies a requested umask. +const SHELL: &str = "/bin/sh"; +/// Fixed `argv[0]` of [`SHELL`]; the program and its arguments follow it. +const SHELL_NAME: &str = "sh"; fn docker_error(message: impl std::fmt::Display) -> Report { Report::new(ReleaseError::Boundary).attach(message.to_string()) @@ -242,6 +246,8 @@ pub(crate) struct RunPlan<'a> { pub(crate) program: &'a Path, pub(crate) arguments: &'a [OsString], pub(crate) interactive: bool, + /// Mask applied before the program through [`SHELL`], when set. + pub(crate) umask: Option, } fn mount_argument(mount: &Mount) -> OsString { @@ -288,6 +294,26 @@ fn limit_arguments(settings: &DockerSettings) -> [String; 10] { ] } +/// Returns the fixed shell words that apply `umask` before `exec "$@"`. +/// +/// The script is built only from the validated octal digits; the program and +/// its arguments follow as positional parameters and are never interpolated. +fn umask_prefix(umask: Option) -> Vec { + umask + .map(|umask| { + [ + String::from(SHELL), + String::from("-c"), + format!("umask {umask} && exec \"$@\""), + String::from(SHELL_NAME), + ] + .into_iter() + .map(OsString::from) + .collect() + }) + .unwrap_or_default() +} + /// Returns the complete fixed `docker` argument vector for `plan`. #[must_use] pub(crate) fn run_arguments(settings: &DockerSettings, plan: &RunPlan<'_>) -> Vec { @@ -314,8 +340,9 @@ pub(crate) fn run_arguments(settings: &DockerSettings, plan: &RunPlan<'_>) -> Ve OsString::from("--workdir"), plan.working_directory.as_os_str().to_os_string(), OsString::from(plan.image), - plan.program.as_os_str().to_os_string(), ]); + arguments.extend(umask_prefix(plan.umask)); + arguments.push(plan.program.as_os_str().to_os_string()); arguments.extend(plan.arguments.iter().cloned()); arguments } @@ -394,6 +421,7 @@ impl DockerBoundary { program: Path::new("/"), arguments: &[], interactive: false, + umask: None, }; let mut hasher = Sha256::new(); hasher.update(FINGERPRINT_DOMAIN); @@ -435,6 +463,7 @@ impl DockerBoundary { program: &program, arguments: &command.arguments, interactive: command.stdin.is_some(), + umask: command.umask, }; let arguments = run_arguments(&self.settings, &plan); let outcome = self diff --git a/crates/trusted-server-release/src/boundary/macos.rs b/crates/trusted-server-release/src/boundary/macos.rs index d5688d58cd..3fc5fc4d8e 100644 --- a/crates/trusted-server-release/src/boundary/macos.rs +++ b/crates/trusted-server-release/src/boundary/macos.rs @@ -234,6 +234,9 @@ impl MacProfile { )? .arguments(arguments) .environment(RUN_NONCE_VARIABLE, &run.nonce)?; + if let Some(umask) = command.umask { + bounded = bounded.umask(umask); + } for (name, value) in &command.environment { bounded = bounded.environment(name, value)?; } diff --git a/crates/trusted-server-release/src/boundary/tests/container.rs b/crates/trusted-server-release/src/boundary/tests/container.rs index 8e96f7fbe4..a71771026a 100644 --- a/crates/trusted-server-release/src/boundary/tests/container.rs +++ b/crates/trusted-server-release/src/boundary/tests/container.rs @@ -108,6 +108,7 @@ fn docker_run_arguments_are_exact() { program: Path::new("/boundary/tool/0"), arguments: &arguments, interactive: false, + umask: None, }; let actual: Vec = run_arguments(&docker_settings(Path::new("/usr/bin/docker")), &plan) .iter() @@ -502,3 +503,122 @@ fn effective_seccomp_has_no_process_memory_or_descriptor_transfer_allowance() { } } } + +/// Returns the argv tail after the image for a fixture plan. +fn argv_after_image(umask: Option, program: &str, arguments: &[&str]) -> Vec { + let (_directory, base) = canonical_tempdir(); + let roots = sample_roots(&base); + let mounts = mounts(&roots).expect("should build mounts"); + let image = format!("sha256:{}", "b".repeat(64)); + let arguments: Vec = arguments.iter().map(OsString::from).collect(); + let plan = RunPlan { + config: Path::new("/host/config"), + seccomp: Path::new("/host/seccomp.json"), + name: "ts-release-boundary-fixture", + image: &image, + mounts: &mounts, + environment: &[], + working_directory: Path::new("/boundary/writable/0"), + program: Path::new(program), + arguments: &arguments, + interactive: false, + umask, + }; + let argv: Vec = run_arguments(&docker_settings(Path::new("/usr/bin/docker")), &plan) + .iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect(); + let position = argv + .iter() + .position(|part| *part == image) + .expect("should place the image in argv"); + argv[position + 1..].to_vec() +} + +#[test] +fn umask_validation_accepts_only_permission_bits() { + for valid in [0, 0o022, 0o027, 0o777] { + assert_eq!( + Umask::new(valid) + .expect("should accept a permission mask") + .mask(), + valid, + "should round-trip {valid:#o}" + ); + } + for invalid in [0o1000, 0o4777, 0x1_0000, u32::MAX] { + assert!( + Umask::new(invalid).is_err(), + "should reject {invalid:#o} outside 0..=0o777" + ); + } +} + +#[test] +fn umask_displays_as_three_octal_digits() { + let rendered: Vec = [0, 0o7, 0o022, 0o027, 0o777] + .into_iter() + .map(|mask| Umask::new(mask).expect("should accept mask").to_string()) + .collect(); + assert_eq!( + rendered, + ["000", "007", "022", "027", "777"], + "should render zero-padded three-digit octal" + ); +} + +#[test] +fn docker_argv_without_umask_runs_the_program_directly() { + assert_eq!( + argv_after_image(None, "/boundary/tool/0", &["--flag"]), + ["/boundary/tool/0", "--flag"], + "should not involve a shell without a umask" + ); +} + +#[test] +fn docker_argv_with_umask_uses_the_fixed_shell_idiom() { + let umask = Umask::new(0o027).expect("should accept mask"); + assert_eq!( + argv_after_image(Some(umask), "/boundary/tool/0", &["--flag", "value"]), + [ + "/bin/sh", + "-c", + "umask 027 && exec \"$@\"", + "sh", + "/boundary/tool/0", + "--flag", + "value" + ], + "should wrap the program in the fixed umask script with positional arguments" + ); +} + +#[test] +fn docker_argv_keeps_shell_metacharacters_positional() { + let umask = Umask::new(0o022).expect("should accept mask"); + let hostile = [ + "$(touch /boundary/writable/0/pwned)", + "`id`", + "a; rm -rf /", + "'quoted' \"double\" && || | > <", + "-c", + "$@", + "", + ]; + let tail = argv_after_image(Some(umask), "/boundary/tool/0;$(id)", &hostile); + assert_eq!( + tail[..4], + ["/bin/sh", "-c", "umask 022 && exec \"$@\"", "sh"], + "should keep the script fixed whatever the program and arguments are" + ); + assert_eq!( + tail[4], "/boundary/tool/0;$(id)", + "should pass the program as one positional word" + ); + assert_eq!( + tail[5..], + hostile, + "should pass every hostile argument verbatim as its own word" + ); +} diff --git a/crates/trusted-server-release/src/boundary/tests/linux.rs b/crates/trusted-server-release/src/boundary/tests/linux.rs index 3d29cc3025..d846a42e9a 100644 --- a/crates/trusted-server-release/src/boundary/tests/linux.rs +++ b/crates/trusted-server-release/src/boundary/tests/linux.rs @@ -351,3 +351,27 @@ fn docker_client_rehash_rejects_tampering_between_commands() { "should refuse the next host command after tampering" ); } + +#[test] +fn container_command_reports_the_requested_umask() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let report = |umask: Option| { + let mut command = fixture.command("umask", &[]); + if let Some(mask) = umask { + command = command.umask(Umask::new(mask).expect("should accept mask")); + } + let result = fixture + .boundary + .run_confined(&command, &proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + }; + let default = report(None); + assert!( + default.len() == 3 && default.bytes().all(|digit| (b'0'..=b'7').contains(&digit)), + "should report the runtime default as three octal digits: {default}" + ); + assert_eq!(report(Some(0o027)), "027", "should apply umask 027"); + assert_eq!(report(Some(0o077)), "077", "should apply umask 077"); +} diff --git a/crates/trusted-server-release/src/boundary/tests/macos.rs b/crates/trusted-server-release/src/boundary/tests/macos.rs index 009d7709cc..13931747e7 100644 --- a/crates/trusted-server-release/src/boundary/tests/macos.rs +++ b/crates/trusted-server-release/src/boundary/tests/macos.rs @@ -635,3 +635,41 @@ fn programs_outside_the_roots_are_rejected() { "should reject a program outside every root" ); } + +/// Reads the umask a helper inherits from this process, outside any sandbox. +fn unconfined_umask() -> String { + let mut command = StdCommand::new(test_binary()); + command + .args(helper_arguments(&[])) + .env_clear() + .stdin(std::process::Stdio::null()); + for (name, value) in helper_environment("umask") { + command.env(name, value); + } + let output = command.output().expect("should run the unconfined helper"); + helper_report(&output.stdout) +} + +#[test] +fn confined_command_reports_the_requested_umask() { + let fixture = Fixture::new(); + let proof = fixture.prove(); + let report = |umask: Option| { + let mut command = fixture.command("umask", &[]); + if let Some(mask) = umask { + command = command.umask(Umask::new(mask).expect("should accept mask")); + } + let result = fixture + .boundary + .run_confined(&command, &proof) + .expect("should run confined helper"); + helper_report(result.stdout()) + }; + assert_eq!( + report(None), + unconfined_umask(), + "should inherit the orchestrator umask without a request" + ); + assert_eq!(report(Some(0o027)), "027", "should apply umask 027"); + assert_eq!(report(Some(0o077)), "077", "should apply umask 077"); +} diff --git a/crates/trusted-server-release/src/boundary/tests/modes.rs b/crates/trusted-server-release/src/boundary/tests/modes.rs index fc3f744ce5..e10c4fc30e 100644 --- a/crates/trusted-server-release/src/boundary/tests/modes.rs +++ b/crates/trusted-server-release/src/boundary/tests/modes.rs @@ -83,6 +83,7 @@ pub(super) fn run(mode: &str, arguments: &[String]) -> i32 { "escape-connect" => escape_connect(argument(0)), "unix-exchange" => unix_exchange(argument(0)), "socket" => socket(argument(0)), + "umask" => umask(), _ => HELPER_SETUP_FAILED, } } @@ -322,3 +323,18 @@ fn socket(domain: &str) -> i32 { } report_errno("created", errno) } + +/// Reports this process's umask as three octal digits. +/// +/// Reading requires setting; the previous value is restored at once, and the +/// helper is single-threaded. +fn umask() -> i32 { + // SAFETY: `umask` has no memory-safety preconditions and cannot fail; the + // second call restores the value the first call returned. + let current = unsafe { + let current = libc::umask(0); + libc::umask(current); + current + }; + report(&format!("{current:03o}"), 0) +} diff --git a/crates/trusted-server-release/src/process.rs b/crates/trusted-server-release/src/process.rs index 231e2fe734..f5c7bfb69d 100644 --- a/crates/trusted-server-release/src/process.rs +++ b/crates/trusted-server-release/src/process.rs @@ -189,6 +189,37 @@ pub(crate) enum ProcessFailure { Unsuccessful, } +/// A file-mode creation mask a child runs under, restricted to permission bits. +/// +/// Rendered as three octal digits such as `022` or `027`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::Display)] +#[display("{_0:03o}")] +pub(crate) struct Umask(u16); + +impl Umask { + /// The largest valid mask, `0o777`. + const MAX: u32 = 0o777; + + /// Accepts a numeric mask of at most `0o777`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] with [`ProcessFailure::InvalidCommand`] + /// for any value above `0o777`. + pub(crate) fn new(mask: u32) -> Result> { + match u16::try_from(mask) { + Ok(mask) if u32::from(mask) <= Self::MAX => Ok(Self(mask)), + _ => Err(invalid(format!("umask {mask:#o} is not within 0..=0o777"))), + } + } + + /// Returns the numeric mask. + #[must_use] + pub(crate) fn mask(self) -> u32 { + u32::from(self.0) + } +} + /// A fully specified child command executed under [`ProcessLimits`]. pub(crate) struct BoundedCommand { program: PathBuf, @@ -197,6 +228,7 @@ pub(crate) struct BoundedCommand { environment: BTreeMap, stdin: Option>, limits: ProcessLimits, + umask: Option, cancellation: Option, } @@ -239,6 +271,7 @@ impl BoundedCommand { stdin: None, limits, cancellation: None, + umask: None, }) } @@ -312,6 +345,13 @@ impl BoundedCommand { self } + /// Sets the child's umask before it executes; the parent's is untouched. + #[must_use] + pub(crate) fn umask(mut self, umask: Umask) -> Self { + self.umask = Some(umask); + self + } + fn secrets(&self) -> Vec> { self.environment .values() @@ -345,6 +385,18 @@ impl BoundedCommand { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .process_group(0); + if let Some(umask) = self.umask { + let mode = libc::mode_t::from(umask.0); + // SAFETY: the closure runs in the forked child before exec and + // only calls `umask`, which is async-signal-safe, touches no + // memory and cannot fail; it captures one `Copy` integer. + unsafe { + command.pre_exec(move || { + libc::umask(mode); + Ok(()) + }); + } + } command } diff --git a/crates/trusted-server-release/src/process/environment.rs b/crates/trusted-server-release/src/process/environment.rs index 89b5ca22d6..90a6642f40 100644 --- a/crates/trusted-server-release/src/process/environment.rs +++ b/crates/trusted-server-release/src/process/environment.rs @@ -68,6 +68,9 @@ const INJECTION_EXACT: [&str; 42] = [ ]; /// Uppercased credential name prefixes accepted only as secrets. const CREDENTIAL_PREFIXES: [&str; 2] = ["AWS_", "FASTLY_"]; +/// Uppercased `aws-lc-sys` system-library switch, also per target as +/// `_`. It shares the `AWS_` prefix but carries no credential. +const BUILD_SWITCH: &str = "AWS_LC_SYS_USE_SYSTEM"; /// Uppercased `CARGO_REGISTRIES__TOKEN` registry credential affixes. const CARGO_REGISTRIES_TOKEN: (&str, &str) = ("CARGO_REGISTRIES_", "_TOKEN"); /// Uppercased credential names accepted only as secrets. @@ -110,10 +113,21 @@ pub(super) fn is_credential(name: &OsStr) -> bool { let upper = upper_name(name); let (registries, token) = CARGO_REGISTRIES_TOKEN; let registry_token = upper.starts_with(registries) && upper.ends_with(token); + let build_switch = upper == BUILD_SWITCH + || upper + .strip_prefix(BUILD_SWITCH) + .and_then(|suffix| suffix.strip_prefix('_')) + .is_some_and(|triple| { + !triple.is_empty() + && triple + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }); registry_token - || CREDENTIAL_PREFIXES - .iter() - .any(|prefix| upper.starts_with(prefix)) + || (!build_switch + && CREDENTIAL_PREFIXES + .iter() + .any(|prefix| upper.starts_with(prefix))) || CREDENTIAL_EXACT.contains(&upper.as_str()) } diff --git a/crates/trusted-server-release/src/process/tests/variables.rs b/crates/trusted-server-release/src/process/tests/variables.rs index c5cbdc5481..6b38da4b50 100644 --- a/crates/trusted-server-release/src/process/tests/variables.rs +++ b/crates/trusted-server-release/src/process/tests/variables.rs @@ -200,3 +200,32 @@ fn harmless_names_are_accepted() { ); } } + +#[test] +fn aws_lc_sys_build_switches_are_plain_variables() { + let dir = workspace(); + for name in [ + "AWS_LC_SYS_USE_SYSTEM", + "AWS_LC_SYS_USE_SYSTEM_aarch64_apple_darwin", + ] { + let plain = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "0"); + assert!(plain.is_ok(), "should accept {name} as a plain variable"); + } + for name in [ + "AWS_SECRET_ACCESS_KEY", + "AWS_LC_SYS_OTHER", + "AWS_LC_SYS_USE_SYSTEM_", + "AWS_LC_SYS_USE_SYSTEMX", + "AWS_LC_SYS_USE_SYSTEM_x86-64", + ] { + let credential = BoundedCommand::new(&test_binary(), dir.path(), limits(SLACK)) + .expect("should accept an absolute program") + .environment(name, "fictional"); + assert!( + credential.is_err(), + "should still refuse {name} as a plain variable" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index affb65bc31..99a648fa28 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -5,16 +5,18 @@ //! otherwise reports the closed production selection. use std::collections::BTreeMap; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use error_stack::Report; -use crate::boundary::{Boundary, BoundaryRoots, DenialProof}; +use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, DenialProof, Umask}; use crate::error::ReleaseError; use crate::javascript::JsBuildCompleted; use crate::policy::{FIXED_ARTIFACTS, FixedArtifact}; use crate::preflight::AuditedEnvironment; use crate::preflight::child::{ChildEnvironment, ChildRequest, child_environment}; +use crate::process::ProcessLimits; use crate::source::{ApprovedSource, CommitId, FreshSource}; use fresh::{ absent_in_real_parent, disjoint, empty_directory, normal_absolute, real_directory, real_file, @@ -261,6 +263,28 @@ pub(crate) struct CargoPlan { arguments: Vec, environment: BTreeMap, working_directory: PathBuf, + umask: Umask, +} + +impl CargoPlan { + /// Describes the plan as a confined command running under the role's umask. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for a non-normal program or working + /// directory, or [`ReleaseError::Process`] for a rejected variable. + pub(crate) fn confined_command( + &self, + limits: ProcessLimits, + ) -> Result> { + let mut command = ConfinedCommand::new(&self.program, &self.working_directory, limits)? + .arguments(self.arguments.iter().map(OsString::from)) + .umask(self.umask); + for (name, value) in &self.environment { + command = command.environment(name, value)?; + } + Ok(command) + } } /// Returns the fixed argument vector of the release Cargo build. @@ -428,7 +452,7 @@ impl PreparedRecipe { /// # Errors /// /// Returns [`ReleaseError::Recipe`] when the held plan's program, - /// arguments, environment or working directory differ from the derivation. + /// arguments, environment, working directory or umask differ from the derivation. pub(crate) fn check_plan(&self) -> Result<(), Report> { let expected = plan_for(&self.request, &self.tools, &self.ambient, &self.child)?; if self.plan != expected { @@ -547,6 +571,7 @@ fn plan_for( arguments: cargo_arguments(request.asset, ambient.jobs()), environment, working_directory: ambient.checkout_root().to_owned(), + umask: ambient.umask()?, }) } diff --git a/crates/trusted-server-release/src/recipe/ambient.rs b/crates/trusted-server-release/src/recipe/ambient.rs index b004fdbc94..02ddee7d89 100644 --- a/crates/trusted-server-release/src/recipe/ambient.rs +++ b/crates/trusted-server-release/src/recipe/ambient.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use super::fresh::{disjoint, normal_absolute}; use super::{Role, recipe_error}; +use crate::boundary::Umask; use crate::error::ReleaseError; /// Locale every compiler child sees, whatever the host reports. @@ -245,6 +246,16 @@ impl CheckedAmbient { self.profile.jobs } + /// Returns the umask the profile fixes. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] when the compiled mask is not a + /// valid permission mask. + pub(crate) fn umask(&self) -> Result> { + Umask::new(self.profile.umask) + } + /// Returns the checkout root this build runs in. pub(crate) fn checkout_root(&self) -> &Path { &self.observed.checkout_root diff --git a/crates/trusted-server-release/src/recipe/tests/bind.rs b/crates/trusted-server-release/src/recipe/tests/bind.rs index c682fc1db1..42d61d445e 100644 --- a/crates/trusted-server-release/src/recipe/tests/bind.rs +++ b/crates/trusted-server-release/src/recipe/tests/bind.rs @@ -534,3 +534,89 @@ fn tampered_program_or_working_directory_is_rejected() { "should reject a swapped working directory" ); } + +fn confinement_limits() -> ProcessLimits { + ProcessLimits { + wall: std::time::Duration::from_secs(60), + stdout_max: 1024, + stderr_max: 1024, + } +} + +#[test] +fn plan_carries_the_role_umask() { + for (role, expected) in [(Role::A, 0o022), (Role::B, 0o027)] { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", role) + .expect("should bind the recipe"); + assert_eq!( + prepared.plan().umask, + Umask::new(expected).expect("should accept mask"), + "should derive the umask of role {role:?} from its ambient profile" + ); + } +} + +#[test] +fn tampered_umask_is_rejected_by_the_plan_recheck() { + let fixture = Fixture::new(COMMIT); + let mut prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + prepared.plan.umask = Umask::new(0o000).expect("should accept mask"); + assert!( + plan_error(&prepared).contains("differs"), + "should reject a weakened umask" + ); + prepared.plan.umask = Umask::new(0o027).expect("should accept mask"); + assert!( + plan_error(&prepared).contains("differs"), + "should reject another role's umask" + ); + prepared.plan.umask = Umask::new(0o022).expect("should accept mask"); + prepared + .check_plan() + .expect("should accept the restored umask"); +} + +#[test] +fn confined_command_applies_the_role_umask() { + for (role, expected) in [(Role::A, "022"), (Role::B, "027")] { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", role) + .expect("should bind the recipe"); + let command = prepared + .plan() + .confined_command(confinement_limits()) + .expect("should describe the confined command"); + assert_eq!( + command + .requested_umask() + .map(|umask| umask.to_string()) + .as_deref(), + Some(expected), + "should set the umask of role {role:?} on the confined command" + ); + let described = format!("{command:?}"); + for part in [ + format!("{:?}", prepared.plan().program), + format!("{:?}", prepared.plan().working_directory), + format!( + "{:?}", + prepared + .plan() + .arguments + .iter() + .map(OsString::from) + .collect::>() + ), + ] { + assert!( + described.contains(&part), + "should describe {part} in {described}" + ); + } + } +} From 03fa8e5a622fc7cf86d81195ccdd2f40df50ccfc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 15 Oct 2026 23:28:47 +0530 Subject: [PATCH 068/153] Prepare confined Cargo execution pieces for release recipes Add the locally testable parts of the first real recipe build while execute stays closed: - Translate every path-bearing plan variable (CARGO_TARGET_DIR, CARGO_HOME, HOME, TMPDIR, JS prebuilt paths, RUSTC, PATH) into the execution namespace, with a writable-only inverse for outputs. - Mount materials read-only (vendor only), a fresh writable cargo state root and the target; keep the output root unmounted. - Emit exact source-replacement configuration for crates.io and the locked Git sources. - Require the target std in selected Rust tools and share the tool tree digest with the JS preparation. - Parse Cargo JSON messages strictly and select exactly one final artifact; copy it without following any symlinked component. - Record a private RecipeCompleted, including derived manifest digests that mark a build unqualified, and rehash it at handoff. - Prepare a vendor directory with Cargo checksum files from verified registry originals and the verified Git tree. - Require Wasm debug sections, and accept RUSTC only as the exact translated tool root rustc. --- crates/trusted-server-release/src/boundary.rs | 60 ++ .../src/boundary/command.rs | 45 +- .../src/boundary/command/selector_tests.rs | 61 ++ .../src/boundary/docker.rs | 60 ++ .../src/boundary/docker/host_path_tests.rs | 85 +++ .../src/boundary/macos.rs | 6 +- .../src/boundary/selector_placement_tests.rs | 127 ++++ .../trusted-server-release/src/documents.rs | 4 +- crates/trusted-server-release/src/inspect.rs | 54 ++ .../src/inspect/tests/builders.rs | 16 + .../src/inspect/tests/wasm.rs | 70 +- .../src/inspect/wasm.rs | 18 +- crates/trusted-server-release/src/main.rs | 1 + .../trusted-server-release/src/materials.rs | 5 + .../src/materials/cargo_vendor.rs | 532 +++++++++++++++ .../src/materials/cargo_vendor/git_tree.rs | 301 +++++++++ .../src/materials/cargo_vendor/source.rs | 119 ++++ .../src/materials/cargo_vendor/tests.rs | 4 + .../src/materials/cargo_vendor/tests/git.rs | 464 +++++++++++++ .../materials/cargo_vendor/tests/registry.rs | 454 +++++++++++++ .../materials/cargo_vendor/tests/source.rs | 102 +++ .../materials/cargo_vendor/tests/support.rs | 187 ++++++ .../src/materials/cargo_vendor/writer.rs | 160 +++++ .../src/materials/git.rs | 21 + .../src/materials/git/tests.rs | 1 + .../src/materials/locked.rs | 19 + .../src/materials/npm_bundle.rs | 1 + .../src/materials/tests.rs | 1 + .../trusted-server-release/src/preparation.rs | 76 +-- .../src/process/environment.rs | 47 +- .../src/process/environment/selector_tests.rs | 74 +++ crates/trusted-server-release/src/recipe.rs | 613 ++++++++++++------ .../src/recipe/artifact.rs | 292 +++++++++ .../src/recipe/artifact/tests.rs | 297 +++++++++ .../src/recipe/cargo_output.rs | 215 ++++++ .../src/recipe/cargo_output/tests.rs | 312 +++++++++ .../src/recipe/completed.rs | 223 +++++++ .../src/recipe/fresh.rs | 17 + .../src/recipe/paths.rs | 79 +++ .../src/recipe/paths/fake.rs | 93 +++ .../trusted-server-release/src/recipe/plan.rs | 209 ++++++ .../src/recipe/sources.rs | 35 + .../src/recipe/tests.rs | 79 ++- .../src/recipe/tests/bind.rs | 551 ++++------------ .../src/recipe/tests/complete.rs | 478 ++++++++++++++ .../src/recipe/tests/fixture.rs | 236 +++++++ .../src/recipe/tests/plan.rs | 499 ++++++++++++++ .../src/recipe/tests/sources.rs | 126 ++++ .../src/recipe/tests/tools.rs | 203 ++++++ .../src/recipe/tools.rs | 161 +++++ .../trusted-server-release/src/tool_tree.rs | 109 ++++ .../src/tool_tree/tests.rs | 165 +++++ 52 files changed, 7431 insertions(+), 736 deletions(-) create mode 100644 crates/trusted-server-release/src/boundary/command/selector_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/docker/host_path_tests.rs create mode 100644 crates/trusted-server-release/src/boundary/selector_placement_tests.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/source.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs create mode 100644 crates/trusted-server-release/src/materials/cargo_vendor/writer.rs create mode 100644 crates/trusted-server-release/src/process/environment/selector_tests.rs create mode 100644 crates/trusted-server-release/src/recipe/artifact.rs create mode 100644 crates/trusted-server-release/src/recipe/artifact/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/cargo_output.rs create mode 100644 crates/trusted-server-release/src/recipe/cargo_output/tests.rs create mode 100644 crates/trusted-server-release/src/recipe/completed.rs create mode 100644 crates/trusted-server-release/src/recipe/paths.rs create mode 100644 crates/trusted-server-release/src/recipe/paths/fake.rs create mode 100644 crates/trusted-server-release/src/recipe/plan.rs create mode 100644 crates/trusted-server-release/src/recipe/sources.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/complete.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/fixture.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/plan.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/sources.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/tools.rs create mode 100644 crates/trusted-server-release/src/recipe/tools.rs create mode 100644 crates/trusted-server-release/src/tool_tree.rs create mode 100644 crates/trusted-server-release/src/tool_tree/tests.rs diff --git a/crates/trusted-server-release/src/boundary.rs b/crates/trusted-server-release/src/boundary.rs index 9d46cdfa7e..ea8a5ad7b8 100644 --- a/crates/trusted-server-release/src/boundary.rs +++ b/crates/trusted-server-release/src/boundary.rs @@ -122,6 +122,15 @@ pub(crate) struct Boundary { fingerprint: [u8; 32], } +/// Reports whether `path` equals its rebuilt components, so it has no `.`, +/// doubled or trailing separators (`components` silently drops them). +fn lexically_normal(path: &Path) -> bool { + path.components() + .collect::() + .as_os_str() + == path.as_os_str() +} + /// Binds a platform fingerprint to a fresh per-instance nonce. fn instance_fingerprint(platform: &[u8; 32]) -> Result<[u8; 32], Report> { let nonce: [u8; 16] = random_bytes()?; @@ -169,6 +178,36 @@ impl Boundary { _ => Err(boundary_error("execution path is outside boundary roots")), } } + /// Translates a path reported by a confined command back to the host. + /// + /// Only paths inside writable roots translate: a build output may not + /// name an immutable or tool file. + /// + /// # Errors + /// + /// Returns an error when the path is not inside a writable root. + pub(crate) fn host_path( + &self, + execution: &Path, + ) -> Result> { + match &self.platform { + Platform::LinuxContainer(_) => docker::host_path(&self.roots, execution), + Platform::MacSandbox(_) if !lexically_normal(execution) => Err(boundary_error( + "execution path is not in lexical normal form", + )), + Platform::MacSandbox(_) => match self.roots.locate(execution) { + Some((RootRole::Writable, _, relative)) + if relative + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))) => + { + Ok(execution.to_owned()) + } + _ => Err(boundary_error("execution path is outside writable roots")), + }, + } + } + /// Configures the qualified macOS `sandbox-exec` boundary. /// /// # Errors @@ -321,12 +360,30 @@ impl Boundary { self.scan_writable(ScanPolicy::AliasesOnly) } + /// Requires every tool selector to name exactly `/bin/rustc` + /// in the execution namespace, so a build cannot select another compiler. + fn check_tool_selectors(&self, command: &ConfinedCommand) -> Result<(), Report> { + for (name, value) in command.tool_selector_entries() { + let allowed = self.roots.tools().iter().any(|tool_root| { + self.execution_path(&tool_root.join("bin").join("rustc")) + .is_ok_and(|expected| expected.as_os_str() == value.as_os_str()) + }); + if !allowed { + return Err(boundary_error(format!( + "tool selector {name:?} is not the bin/rustc of a tool root" + ))); + } + } + Ok(()) + } + fn check_placement(&self, command: &ConfinedCommand) -> Result<(), Report> { if self.roots.locate(&command.program).is_none() { return Err(boundary_error( "confined program is outside every boundary root", )); } + self.check_tool_selectors(command)?; match self.roots.locate(&command.working_directory) { Some((RootRole::Immutable | RootRole::Writable, _, _)) => Ok(()), _ => Err(boundary_error( @@ -335,3 +392,6 @@ impl Boundary { } } } + +#[cfg(test)] +mod selector_placement_tests; diff --git a/crates/trusted-server-release/src/boundary/command.rs b/crates/trusted-server-release/src/boundary/command.rs index 79fca5b670..e6b534dbb7 100644 --- a/crates/trusted-server-release/src/boundary/command.rs +++ b/crates/trusted-server-release/src/boundary/command.rs @@ -26,6 +26,8 @@ pub(crate) struct ConfinedCommand { pub(super) arguments: Vec, pub(super) working_directory: PathBuf, pub(super) environment: Vec<(OsString, OsString)>, + /// Names in `environment` added through [`Self::tool_environment`]. + tool_selectors: Vec, pub(super) stdin: Option>, pub(super) limits: ProcessLimits, pub(super) cancellation: Option, @@ -97,6 +99,7 @@ impl ConfinedCommand { arguments: Vec::new(), working_directory: working_directory.to_path_buf(), environment: Vec::new(), + tool_selectors: Vec::new(), stdin: None, limits, cancellation: None, @@ -104,6 +107,26 @@ impl ConfinedCommand { }) } + /// Adds an explicitly selected tool path such as `RUSTC`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] for any name or value + /// [`BoundedCommand::tool_selector_environment`] rejects, including a + /// duplicate. + pub(crate) fn tool_environment( + mut self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + self.validated(None)? + .tool_selector_environment(name.as_ref(), value.as_ref())?; + self.environment + .push((name.as_ref().to_os_string(), value.as_ref().to_os_string())); + self.tool_selectors.push(name.as_ref().to_os_string()); + Ok(self) + } + /// Appends fixed arguments. #[must_use] pub(crate) fn arguments(mut self, arguments: I) -> Self @@ -131,6 +154,19 @@ impl ConfinedCommand { Ok(self) } + /// Returns whether `name` was added through [`Self::tool_environment`]. + pub(super) fn is_tool_selector(&self, name: &OsStr) -> bool { + self.tool_selectors.iter().any(|selected| selected == name) + } + + /// Returns the tool-selector entries, for the boundary's placement check. + pub(super) fn tool_selector_entries(&self) -> impl Iterator { + self.environment + .iter() + .filter(|(name, _)| self.is_tool_selector(name)) + .map(|(name, value)| (name, value)) + } + /// Validates the environment plus `extra` through a scratch /// [`BoundedCommand`]. pub(super) fn validated( @@ -140,7 +176,11 @@ impl ConfinedCommand { let mut validator = BoundedCommand::new(&self.program, &self.working_directory, self.limits)?; for (name, value) in self.environment.iter() { - validator = validator.environment(name, value)?; + validator = if self.tool_selectors.contains(name) { + validator.tool_selector_environment(name, value)? + } else { + validator.environment(name, value)? + }; } if let Some((name, value)) = extra { validator = validator.environment(name, value)?; @@ -190,3 +230,6 @@ impl ConfinedCommand { bounded } } + +#[cfg(test)] +mod selector_tests; diff --git a/crates/trusted-server-release/src/boundary/command/selector_tests.rs b/crates/trusted-server-release/src/boundary/command/selector_tests.rs new file mode 100644 index 0000000000..83fa19bcf6 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/command/selector_tests.rs @@ -0,0 +1,61 @@ +//! Pure tests of confined tool-selector variables; no process is started. + +use std::ffi::OsString; +use std::path::Path; +use std::time::Duration; + +use super::*; + +fn command() -> ConfinedCommand { + ConfinedCommand::new( + Path::new("/example/bin/cargo"), + Path::new("/example/checkout"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe a confined command") +} + +#[test] +fn rustc_needs_the_explicit_tool_method_and_survives_later_variables() { + assert!( + command() + .environment("RUSTC", "/example/bin/rustc") + .is_err(), + "should reject RUSTC through the generic environment" + ); + let described = command() + .tool_environment("RUSTC", "/example/bin/rustc") + .expect("should accept the explicit tool selector") + .environment("TZ", "UTC") + .expect("should accept a later ordinary variable") + .tool_environment("RUSTC_WRAPPER", "/example/wrapper"); + assert!(described.is_err(), "should reject any other tool selector"); + + let described = command() + .tool_environment("RUSTC", "/example/bin/rustc") + .expect("should accept the explicit tool selector") + .environment("TZ", "UTC") + .expect("should accept a later ordinary variable"); + assert_eq!( + described.environment, + [ + ( + OsString::from("RUSTC"), + OsString::from("/example/bin/rustc") + ), + (OsString::from("TZ"), OsString::from("UTC")), + ], + "should carry both variables to the platform runner in order" + ); + assert!( + command() + .tool_environment("RUSTC", "/example/bin/rustc") + .and_then(|command| command.environment("RUSTC", "/example/other")) + .is_err(), + "should reject a second RUSTC through the generic environment" + ); +} diff --git a/crates/trusted-server-release/src/boundary/docker.rs b/crates/trusted-server-release/src/boundary/docker.rs index 3ec99690f0..8ef707daa8 100644 --- a/crates/trusted-server-release/src/boundary/docker.rs +++ b/crates/trusted-server-release/src/boundary/docker.rs @@ -206,6 +206,63 @@ pub(crate) fn container_path( }) } +/// Returns the host path of a container path inside a writable root. +/// +/// Only writable roots map back: a build must never name an immutable or tool +/// file as its output. Dot segments are rejected rather than resolved. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Boundary`] when `container` is not an absolute +/// path below `/boundary/writable/` with a canonical decimal index of +/// an existing writable root and only normal components after it. +pub(crate) fn host_path( + roots: &BoundaryRoots, + container: &Path, +) -> Result> { + // `components` silently drops `.`, doubled and trailing separators, so + // lexical normal form is checked against the original text. + if container.components().collect::().as_os_str() != container.as_os_str() { + return Err(docker_error("container path is not in lexical normal form")); + } + let prefix = Path::new(MOUNT_PREFIX).join(role_directory(RootRole::Writable)); + let relative = container.strip_prefix(&prefix).map_err(|_| { + docker_error(format!( + "{} is not below the writable mount prefix", + container.display() + )) + })?; + let mut components = relative.components(); + let index = components + .next() + .and_then(|component| match component { + std::path::Component::Normal(text) => text.to_str(), + _ => None, + }) + .filter(|text| { + text.bytes().all(|byte| byte.is_ascii_digit()) + && (*text == "0" || !text.starts_with('0')) + }) + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| docker_error("container path has no canonical writable root index"))?; + let root = roots + .writable() + .get(index) + .ok_or_else(|| docker_error(format!("no writable root has index {index}")))?; + let mut host = root.clone(); + for component in components { + match component { + std::path::Component::Normal(part) => host.push(part), + _ => { + return Err(docker_error( + "container path has a dot segment after its root", + )); + } + } + } + Ok(host) +} + /// Builds the bind mounts for `roots`. /// /// # Errors @@ -511,3 +568,6 @@ impl DockerBoundary { aggregate(failures) } } + +#[cfg(test)] +mod host_path_tests; diff --git a/crates/trusted-server-release/src/boundary/docker/host_path_tests.rs b/crates/trusted-server-release/src/boundary/docker/host_path_tests.rs new file mode 100644 index 0000000000..5523ac7a35 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/docker/host_path_tests.rs @@ -0,0 +1,85 @@ +//! Pure tests of the container-to-host path inverse; no process is started. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::*; + +struct Layout { + _directory: tempfile::TempDir, + roots: BoundaryRoots, + base: PathBuf, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + for name in ["source", "tools", "work-a", "work-b"] { + fs::create_dir(base.join(name)).expect("should create root directory"); + } + let roots = BoundaryRoots::new( + &[base.join("source")], + &[base.join("tools")], + &[base.join("work-a"), base.join("work-b")], + ) + .expect("should build roots"); + Layout { + _directory: directory, + roots, + base, + } +} + +#[test] +fn writable_container_paths_map_back_to_their_host_roots() { + let layout = layout(); + for (name, container) in [ + ("work-a", "/boundary/writable/0"), + ("work-b", "/boundary/writable/1"), + ] { + let host = layout.base.join(name); + assert_eq!( + host_path(&layout.roots, Path::new(container)).expect("should map the root itself"), + host, + "should map {container} to its root" + ); + let nested = layout.base.join(name).join("release/example.wasm"); + let forward = container_path(&layout.roots, &nested).expect("should map forward"); + assert_eq!( + host_path(&layout.roots, &forward).expect("should map the nested file back"), + nested, + "should invert container_path for {name}" + ); + } +} + +#[test] +fn immutable_tool_unknown_and_malformed_container_paths_are_rejected() { + let layout = layout(); + for text in [ + "/boundary/immutable/0/file", + "/boundary/tool/0/bin/cargo", + "/boundary/writable/2/file", + "/boundary/writable/x/file", + "/boundary/writable/00/file", + "/boundary/writable/+1/file", + "/boundary/writable/+0/file", + "/boundary/writable/-0/file", + "/boundary/writable/1e0/file", + "/boundary/writable/1/file", + "/boundary/writable/0/../1/file", + "/boundary/writable/0/./file", + "/boundary/writable", + "/boundary/writable/", + "/boundaryx/writable/0/file", + "/boundary/writable/0file", + "boundary/writable/0/file", + "/etc/passwd", + "", + ] { + assert!( + host_path(&layout.roots, Path::new(text)).is_err(), + "should reject container path {text:?}" + ); + } +} diff --git a/crates/trusted-server-release/src/boundary/macos.rs b/crates/trusted-server-release/src/boundary/macos.rs index 3fc5fc4d8e..b9f07a4718 100644 --- a/crates/trusted-server-release/src/boundary/macos.rs +++ b/crates/trusted-server-release/src/boundary/macos.rs @@ -238,7 +238,11 @@ impl MacProfile { bounded = bounded.umask(umask); } for (name, value) in &command.environment { - bounded = bounded.environment(name, value)?; + bounded = if command.is_tool_selector(name) { + bounded.tool_selector_environment(name, value)? + } else { + bounded.environment(name, value)? + }; } Ok(command.finish(bounded)) } diff --git a/crates/trusted-server-release/src/boundary/selector_placement_tests.rs b/crates/trusted-server-release/src/boundary/selector_placement_tests.rs new file mode 100644 index 0000000000..e88df1a2f2 --- /dev/null +++ b/crates/trusted-server-release/src/boundary/selector_placement_tests.rs @@ -0,0 +1,127 @@ +//! Pure tests of tool-selector placement and sandbox wrapping; nothing runs. + +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use super::macos::{MacProfile, RunIdentity}; +use super::*; + +struct Layout { + _directory: tempfile::TempDir, + base: PathBuf, + roots: BoundaryRoots, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + for relative in ["tools/bin", "checkout", "state"] { + fs::create_dir_all(base.join(relative)).expect("should create root"); + } + let roots = BoundaryRoots::new( + &[base.join("checkout")], + &[base.join("tools")], + &[base.join("state")], + ) + .expect("should build roots"); + Layout { + _directory: directory, + base, + roots, + } +} + +fn command(layout: &Layout, rustc: &std::path::Path) -> ConfinedCommand { + ConfinedCommand::new( + &layout.base.join("tools/bin/cargo"), + &layout.base.join("checkout"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe the command") + .tool_environment("RUSTC", rustc) + .expect("should accept an absolute RUSTC") +} + +#[cfg(target_os = "macos")] +#[test] +fn rustc_must_be_the_tool_roots_own_bin_rustc() { + let layout = layout(); + let boundary = + Boundary::mac_sandbox_for_tests(layout.roots.clone()).expect("should configure, not run"); + boundary + .check_placement(&command(&layout, &layout.base.join("tools/bin/rustc"))) + .expect("should accept the tool root's rustc"); + for (label, other) in [ + ( + "another file in the tool root", + layout.base.join("tools/bin/other"), + ), + ( + "a nested lookalike", + layout.base.join("tools/bin/rustc/rustc"), + ), + ("a file in the checkout", layout.base.join("checkout/rustc")), + ("a file outside every root", PathBuf::from("/usr/bin/rustc")), + ("the tool root itself", layout.base.join("tools")), + ] { + assert!( + boundary.check_placement(&command(&layout, &other)).is_err(), + "should reject RUSTC naming {label}" + ); + } +} + +#[test] +fn sandbox_wrap_carries_rustc_but_still_rejects_the_nonce_variable() { + let layout = layout(); + let profile = MacProfile::generate(&layout.roots).expect("should generate the profile"); + let run = RunIdentity::new(&profile).expect("should create run markers"); + profile + .wrap( + &command(&layout, &layout.base.join("tools/bin/rustc")), + &run, + ) + .expect("should wrap a command carrying the explicit compiler"); + let forged = command(&layout, &layout.base.join("tools/bin/rustc")) + .environment(super::macos::RUN_NONCE_VARIABLE, "x") + .expect("should hold the variable until wrapping"); + assert!( + profile.wrap(&forged, &run).is_err(), + "should reject a caller variable named like the run nonce" + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn mac_host_path_maps_only_lexically_normal_writable_paths() { + let layout = layout(); + let boundary = + Boundary::mac_sandbox_for_tests(layout.roots.clone()).expect("should configure, not run"); + let state = layout.base.join("state"); + assert_eq!( + boundary + .host_path(&state.join("release/ts")) + .expect("should accept a normal writable path"), + state.join("release/ts"), + "should map a writable path to itself" + ); + let text = |relative: &str| PathBuf::from(format!("{}/{relative}", state.display())); + for path in [ + text("release/./ts"), + text("release//ts"), + text("release/ts/"), + text("release/../state/ts"), + layout.base.join("checkout/ts"), + layout.base.join("tools/bin/rustc"), + ] { + assert!( + boundary.host_path(&path).is_err(), + "should reject {path:?} as a host path" + ); + } +} diff --git a/crates/trusted-server-release/src/documents.rs b/crates/trusted-server-release/src/documents.rs index 96bb37cb99..1cac250ef8 100644 --- a/crates/trusted-server-release/src/documents.rs +++ b/crates/trusted-server-release/src/documents.rs @@ -35,12 +35,14 @@ pub(crate) enum Role { Bundle, Spdx, Executable, + /// One Cargo `--message-format=json` line. + CargoMessage, } impl Role { fn limit(self) -> u64 { match self { - Self::Metadata => 1024 * 1024, + Self::Metadata | Self::CargoMessage => 1024 * 1024, Self::Source => 64 * 1024 * 1024, Self::Companion | Self::Bundle => 32 * 1024 * 1024, Self::Spdx => 16 * 1024 * 1024, diff --git a/crates/trusted-server-release/src/inspect.rs b/crates/trusted-server-release/src/inspect.rs index 8f1b7051e2..afa62ecb2a 100644 --- a/crates/trusted-server-release/src/inspect.rs +++ b/crates/trusted-server-release/src/inspect.rs @@ -106,6 +106,10 @@ pub(crate) enum Violation { /// The module imports from an unreviewed module or a non-function item. #[display("WebAssembly import is outside the reviewed host ABI")] WasmImport, + /// A required DWARF custom section is missing or empty, so the module + /// was stripped. + #[display("WebAssembly module lacks a required nonempty debug section")] + WasmDebugInfo, } /// Builds the inspection rejection carrying `violation` and a detail line. @@ -349,15 +353,65 @@ impl MachoFacts { } } +/// One custom section observed in a core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmCustomSection { + name: String, + size: usize, +} + +impl WasmCustomSection { + /// Section name as encoded in the module. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Payload length in bytes, excluding the name. + pub(crate) fn size(&self) -> usize { + self.size + } +} + /// Facts observed in an accepted core Wasm module. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WasmFacts { sha256: String, import_modules: Vec, function_imports: usize, + custom_sections: Vec, } +/// DWARF sections whose presence proves the module was not stripped. +const REQUIRED_DEBUG_SECTIONS: [&str; 2] = [".debug_info", ".debug_line"]; + impl WasmFacts { + /// Custom sections in file order. + pub(crate) fn custom_sections(&self) -> &[WasmCustomSection] { + &self.custom_sections + } + + /// Requires nonempty `.debug_info` and `.debug_line` custom sections. + /// + /// # Errors + /// + /// Returns [`Violation::WasmDebugInfo`] naming the first required section + /// that is absent or has an empty payload in every occurrence. + pub(crate) fn require_debug_sections(&self) -> Result<(), Report> { + for required in REQUIRED_DEBUG_SECTIONS { + let present = self + .custom_sections + .iter() + .any(|section| section.name == required && section.size > 0); + if !present { + return Err(rejected( + Violation::WasmDebugInfo, + format!("missing or empty custom section {required}"), + )); + } + } + Ok(()) + } + /// Lowercase hex SHA-256 of the inspected bytes. pub(crate) fn sha256(&self) -> &str { &self.sha256 diff --git a/crates/trusted-server-release/src/inspect/tests/builders.rs b/crates/trusted-server-release/src/inspect/tests/builders.rs index e7b02f6881..66296287f3 100644 --- a/crates/trusted-server-release/src/inspect/tests/builders.rs +++ b/crates/trusted-server-release/src/inspect/tests/builders.rs @@ -491,3 +491,19 @@ fn name_bytes(out: &mut Vec, text: &str) { out.push(u8::try_from(text.len()).expect("should fit one LEB128 byte")); out.extend_from_slice(text.as_bytes()); } + +/// Builds a core Wasm module importing `() -> ()` functions, then appending +/// custom sections `(name, payload)` in order. +pub(super) fn wasm_module_with_customs( + imports: &[(&str, &str)], + customs: &[(&str, &[u8])], +) -> Vec { + let mut out = wasm_module(imports); + for (name, payload) in customs { + let mut body = Vec::new(); + name_bytes(&mut body, name); + body.extend_from_slice(payload); + section(&mut out, 0, &body); + } + out +} diff --git a/crates/trusted-server-release/src/inspect/tests/wasm.rs b/crates/trusted-server-release/src/inspect/tests/wasm.rs index 3d6f52499a..5fb6e44771 100644 --- a/crates/trusted-server-release/src/inspect/tests/wasm.rs +++ b/crates/trusted-server-release/src/inspect/tests/wasm.rs @@ -1,6 +1,6 @@ //! Core Wasm inspection tests. -use super::builders::{section, wasm_module}; +use super::builders::{section, wasm_module, wasm_module_with_customs}; use super::*; #[test] @@ -125,3 +125,71 @@ fn truncated_modules_are_handled_without_panic() { ); } } + +#[test] +fn custom_sections_are_recorded_in_file_order_with_sizes() { + let bytes = wasm_module_with_customs( + &[("fastly_log", "write")], + &[ + (".debug_info", b"info"), + ("name", b"xy"), + (".debug_info", b""), + ], + ); + + let facts = inspect_wasm(&bytes).expect("should accept a module with custom sections"); + + let observed: Vec<(&str, usize)> = facts + .custom_sections() + .iter() + .map(|section| (section.name(), section.size())) + .collect(); + assert_eq!( + observed, + [(".debug_info", 4), ("name", 2), (".debug_info", 0)], + "should record every custom section name and payload size in order" + ); + assert!( + inspect_wasm(&wasm_module(&[])) + .expect("should accept an empty module") + .custom_sections() + .is_empty(), + "should record no custom sections for a bare module" + ); +} + +#[test] +fn debug_sections_are_required_and_nonempty() { + let complete = wasm_module_with_customs( + &[], + &[(".debug_info", b"i"), (".debug_line", b"l"), ("name", b"n")], + ); + inspect_wasm(&complete) + .expect("should accept a module with debug sections") + .require_debug_sections() + .expect("should accept both debug sections"); + + let cases: [(&str, Vec); 4] = [ + ("stripped", wasm_module_with_customs(&[], &[("name", b"n")])), + ( + "missing line", + wasm_module_with_customs(&[], &[(".debug_info", b"i")]), + ), + ( + "missing info", + wasm_module_with_customs(&[], &[(".debug_line", b"l")]), + ), + ( + "empty info", + wasm_module_with_customs(&[], &[(".debug_info", b""), (".debug_line", b"l")]), + ), + ]; + for (label, bytes) in cases { + let facts = inspect_wasm(&bytes).expect("should accept the module itself"); + assert_eq!( + violation(facts.require_debug_sections()), + Violation::WasmDebugInfo, + "should reject a {label} module for the no-strip check" + ); + } +} diff --git a/crates/trusted-server-release/src/inspect/wasm.rs b/crates/trusted-server-release/src/inspect/wasm.rs index 327acf1c74..88558d390c 100644 --- a/crates/trusted-server-release/src/inspect/wasm.rs +++ b/crates/trusted-server-release/src/inspect/wasm.rs @@ -5,7 +5,7 @@ use std::collections::BTreeSet; use error_stack::Report; use wasmparser::{Parser, Payload, TypeRef, Validator, WasmFeatures}; -use super::{Violation, WasmFacts, rejected, sha256_hex}; +use super::{Violation, WasmCustomSection, WasmFacts, rejected, sha256_hex}; use crate::error::ReleaseError; /// Reviewed import modules of the Fastly Compute host ABI. @@ -74,9 +74,25 @@ pub(crate) fn inspect_wasm(bytes: &[u8]) -> Result Result, Report> { + let mut sections = Vec::new(); + for payload in Parser::new(0).parse_all(bytes) { + let payload = payload.map_err(|error| rejected(Violation::WasmValidation, error))?; + if let Payload::CustomSection(reader) = payload { + sections.push(WasmCustomSection { + name: reader.name().to_owned(), + size: reader.data().len(), + }); + } + } + Ok(sections) +} + /// Returns the distinct import modules and the function import count. fn imports(bytes: &[u8]) -> Result<(BTreeSet, usize), Report> { let parse_error = |error| rejected(Violation::WasmValidation, error); diff --git a/crates/trusted-server-release/src/main.rs b/crates/trusted-server-release/src/main.rs index 549b70ab17..d5c95a9c9b 100644 --- a/crates/trusted-server-release/src/main.rs +++ b/crates/trusted-server-release/src/main.rs @@ -26,6 +26,7 @@ mod recipe; mod process; #[allow(dead_code)] mod source; +mod tool_tree; use std::ffi::OsString; use std::io::Write as _; diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs index 7cf5bbd2ff..f47c731746 100644 --- a/crates/trusted-server-release/src/materials.rs +++ b/crates/trusted-server-release/src/materials.rs @@ -5,6 +5,7 @@ //! build completion. mod archive; +mod cargo_vendor; mod extract; mod git; mod locked; @@ -23,6 +24,10 @@ use crate::error::ReleaseError; use crate::process::Cancellation; use crate::source::ApprovedSource; pub(crate) use archive::VerifiedOriginal; +#[allow(unused_imports, reason = "consumed by the Task 9 recipe")] +pub(crate) use cargo_vendor::{ + GitSourceConfig, GitVendorInput, NormalizedManifest, PreparedCargoMaterials, VendorInputs, +}; pub(crate) use extract::ExtractedOriginal; use locked::{CargoMaterials, NpmMaterial, parse_cargo, parse_npm}; pub(crate) use manifest::{ReviewedNpmManifest, RootManifestReview}; diff --git a/crates/trusted-server-release/src/materials/cargo_vendor.rs b/crates/trusted-server-release/src/materials/cargo_vendor.rs new file mode 100644 index 0000000000..33a60ac20d --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor.rs @@ -0,0 +1,532 @@ +//! Cargo directory-source vendoring from verified originals. +//! +//! Registry crates come from archives already authenticated against the +//! approved `Cargo.lock` checksum and extracted under the archive policy. +//! Git packages come from a verified object closure. Each package becomes +//! `vendor/-/` with a producer-written `.cargo-checksum.json` +//! (see [`writer`]). The result is a [`PreparedCargoMaterials`] token whose +//! digest covers the whole vendor tree. +//! +//! # Normalized Git manifests +//! +//! A Git package whose manifest inherits from its workspace +//! (`version.workspace = true`) cannot be used from a directory source as it +//! is. The caller may supply a normalized `Cargo.toml` together with the +//! `Cargo.toml.orig` it was derived from; the pair is accepted only when the +//! original equals the manifest blob of the verified tree. The normalization +//! itself is not authenticated, so its digest is recorded as derived and +//! unqualified in [`PreparedCargoMaterials::derived_manifest_digests`]. + +mod git_tree; +mod source; +mod writer; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, DirBuilder}; +use std::os::unix::fs::{DirBuilderExt as _, MetadataExt as _}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use error_stack::Report; + +use super::git::VerifiedGitMaterial; +use super::locked::GitPackage; +use super::{LockedMaterials, material_error}; +use crate::error::ReleaseError; +use crate::process::Cancellation; +use crate::tool_tree::tool_tree_digest; +use git_tree::{Store, find_package, materialize}; +pub(crate) use source::GitSourceConfig; +use writer::PackageWriter; + +/// Name of the directory below the materials root that holds every package. +pub(crate) const VENDOR_DIRECTORY: &str = "vendor"; +/// Largest manifest parsed. +const MANIFEST_LIMIT: u64 = 1024 * 1024; +/// Largest single vendored file. +const FILE_LIMIT: u64 = 128 * 1024 * 1024; +/// Most files copied from one extracted crate. +const FILE_COUNT_LIMIT: usize = 100_000; + +/// A normalized Git manifest and the original it claims to normalize. +#[derive(Clone, Debug)] +pub(crate) struct NormalizedManifest { + /// The manifest Cargo will read, with workspace inheritance resolved. + pub(crate) cargo_toml: Vec, + /// The manifest as committed; it must equal the verified tree blob. + pub(crate) cargo_toml_orig: Vec, +} + +/// A verified Git closure for one locked Git package. +pub(crate) struct GitVendorInput<'a> { + /// The verified closure for the package's locked source and revision. + pub(crate) material: &'a VerifiedGitMaterial, + /// Locked package name. + pub(crate) name: &'a str, + /// Locked package version. + pub(crate) version: &'a str, + /// Optional normalized manifest pair. + pub(crate) normalized: Option, +} + +/// Every original the vendor tree is prepared from. +pub(crate) struct VendorInputs<'a> { + /// Registry archive paths by exact locked `(name, version)`. + pub(crate) registry_archives: &'a BTreeMap<(String, String), PathBuf>, + /// One verified closure per locked Git package. + pub(crate) git: &'a [GitVendorInput<'a>], +} + +/// A prepared, digest-bound vendor tree. +/// +/// Only [`LockedMaterials::prepare_cargo_vendor`] constructs it outside tests. +#[derive(Debug)] +pub(crate) struct PreparedCargoMaterials { + root: PathBuf, + vendor: PathBuf, + digest: String, + source_commit: String, + cargo_lock_sha256: String, + git_sources: Vec, + derived_manifests: BTreeMap, +} + +impl PreparedCargoMaterials { + /// The materials root that holds only the vendor directory. + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// The directory-source root passed to Cargo. + pub(crate) fn vendor_dir(&self) -> &Path { + &self.vendor + } + + /// Digest of every vendored path, mode, size and content. + pub(crate) fn digest(&self) -> &str { + &self.digest + } + + /// The approved source commit whose lock selected these originals. + pub(crate) fn source_commit(&self) -> &str { + &self.source_commit + } + + /// SHA-256 of the approved raw `Cargo.lock`. + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.cargo_lock_sha256 + } + + /// Distinct locked Git sources, sorted by key, for source replacement. + pub(crate) fn git_sources(&self) -> &[GitSourceConfig] { + &self.git_sources + } + + /// Manifest digests derived from a caller-supplied normalization. + /// + /// Keyed by vendor directory name. These are derived and unqualified: + /// only the accompanying original was matched with the verified tree. + pub(crate) fn derived_manifest_digests(&self) -> &BTreeMap { + &self.derived_manifests + } + + /// Recomputes the digest and requires the root to hold only the vendor tree. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when anything under the root was + /// edited, added, removed or linked since preparation. + pub(crate) fn rehash(&self) -> Result<(), Report> { + let names = fs::read_dir(&self.root) + .map_err(material_error)? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(material_error)?; + if names != [std::ffi::OsString::from(VENDOR_DIRECTORY)] { + return Err(material_error( + "materials root holds something other than the vendor directory", + )); + } + if tool_tree_digest(&self.vendor, &|| Ok(()))? != self.digest { + return Err(material_error( + "vendored materials changed after preparation", + )); + } + Ok(()) + } + + /// Records a derived manifest digest on a test token. + #[cfg(test)] + #[must_use] + pub(crate) fn with_derived_for_tests(mut self, directory: &str, digest: &str) -> Self { + self.derived_manifests + .insert(directory.to_owned(), digest.to_owned()); + self + } + + /// Builds a token over an existing `root/vendor`, for tests only. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when the vendor tree cannot be + /// digested or a selector is not a supported Git source. + #[cfg(test)] + pub(crate) fn for_tests( + root: &Path, + source_commit: &str, + git_selectors: &[&str], + ) -> Result> { + let vendor = root.join(VENDOR_DIRECTORY); + match fs::create_dir(&vendor) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(material_error(error)), + } + Ok(Self { + root: root.to_owned(), + digest: tool_tree_digest(&vendor, &|| Ok(()))?, + vendor, + source_commit: source_commit.to_owned(), + cargo_lock_sha256: "b".repeat(64), + git_sources: git_selectors + .iter() + .map(|selector| GitSourceConfig::parse(selector)) + .collect::>()?, + derived_manifests: BTreeMap::new(), + }) + } +} + +fn attempt_check( + deadline: Instant, + cancellation: &Cancellation, +) -> Result<(), Report> { + if cancellation.is_cancelled() || Instant::now() >= deadline { + return Err(material_error("Cargo vendoring cancelled or timed out")); + } + Ok(()) +} + +/// Requires a vendor directory name that cannot escape or hide. +fn directory_name(name: &str, version: &str) -> Result> { + let joined = format!("{name}-{version}"); + let plain = !joined.starts_with('.') + && joined + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'+')); + if !plain { + return Err(material_error(format!( + "package identity {joined:?} is not a safe vendor directory name" + ))); + } + Ok(joined) +} + +/// Requires a real, empty directory that no symlink aliases. +fn check_output_root(root: &Path) -> Result<(), Report> { + let metadata = fs::symlink_metadata(root).map_err(material_error)?; + let resolved = fs::canonicalize(root).map_err(material_error)?; + if !metadata.is_dir() || resolved.as_os_str() != root.as_os_str() { + return Err(material_error( + "materials root is not a real unaliased directory", + )); + } + if fs::read_dir(root).map_err(material_error)?.next().is_some() { + return Err(material_error("materials root already has entries")); + } + Ok(()) +} + +/// Parses a manifest of at most one MiB into a TOML table. +fn parse_manifest(bytes: &[u8]) -> Result> { + if bytes.len() as u64 > MANIFEST_LIMIT { + return Err(material_error("package manifest exceeds the size limit")); + } + let text = std::str::from_utf8(bytes).map_err(material_error)?; + toml::from_str(text).map_err(material_error) +} + +/// Returns the declared `(name, version)`; the version is `None` when it is +/// not a plain string (inherited from a workspace). +fn declared_identity(table: &toml::Table) -> Option<(String, Option)> { + let package = table.get("package")?; + let name = package.get("name")?.as_str()?.to_owned(); + let version = package + .get("version") + .and_then(toml::Value::as_str) + .map(str::to_owned); + Some((name, version)) +} + +/// Reports whether any table below `value` has `workspace = true`. +fn inherits_workspace(value: &toml::Value) -> bool { + match value { + toml::Value::Table(table) => { + table.get("workspace") == Some(&toml::Value::Boolean(true)) + || table.values().any(inherits_workspace) + } + toml::Value::Array(values) => values.iter().any(inherits_workspace), + _ => false, + } +} + +/// Requires a plain-string identity equal to the locked one. +fn require_identity( + manifest: &[u8], + name: &str, + version: &str, +) -> Result<(), Report> { + let table = parse_manifest(manifest)?; + match declared_identity(&table) { + Some((found_name, Some(found_version))) + if found_name == name && found_version == version => + { + Ok(()) + } + _ => Err(material_error(format!( + "manifest does not declare package {name} {version}" + ))), + } +} + +/// Copies every file of an extracted crate into `writer`. +fn copy_extracted(root: &Path, writer: &mut PackageWriter) -> Result<(), Report> { + let mut pending = vec![(root.to_owned(), String::new())]; + let mut count = 0_usize; + while let Some((directory, prefix)) = pending.pop() { + for entry in fs::read_dir(&directory).map_err(material_error)? { + let entry = entry.map_err(material_error)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| material_error("extracted member is not UTF-8"))?; + let relative = format!("{prefix}{name}"); + let metadata = fs::symlink_metadata(entry.path()).map_err(material_error)?; + if metadata.is_dir() { + pending.push((entry.path(), format!("{relative}/"))); + } else if metadata.is_file() && metadata.len() <= FILE_LIMIT { + count += 1; + if count > FILE_COUNT_LIMIT { + return Err(material_error("extracted crate exceeds the file limit")); + } + let bytes = fs::read(entry.path()).map_err(material_error)?; + writer.write(&relative, &bytes, metadata.mode() & 0o111 != 0)?; + } else { + return Err(material_error( + "extracted crate has an unsafe file type or an oversize file", + )); + } + } + } + Ok(()) +} + +impl LockedMaterials { + /// Requires the supplied inputs to be exactly the locked originals. + fn check_vendor_inputs(&self, inputs: &VendorInputs<'_>) -> Result<(), Report> { + let locked: BTreeSet<_> = self.cargo.registry.keys().collect(); + let supplied: BTreeSet<_> = inputs.registry_archives.keys().collect(); + if locked != supplied { + return Err(material_error( + "registry archives are not exactly the locked registry crates", + )); + } + if inputs.git.len() != self.cargo.git_packages.len() { + return Err(material_error( + "Git inputs are not exactly the locked Git packages", + )); + } + for package in &self.cargo.git_packages { + self.matching_git_input(package, inputs)?; + } + Ok(()) + } + + /// Finds the single input for a locked Git package and checks its bindings. + fn matching_git_input<'a>( + &self, + package: &GitPackage, + inputs: &'a VendorInputs<'a>, + ) -> Result<&'a GitVendorInput<'a>, Report> { + let mut matching = inputs + .git + .iter() + .filter(|input| input.name == package.name && input.version == package.version); + let (Some(input), None) = (matching.next(), matching.next()) else { + return Err(material_error(format!( + "Git package {} {} needs exactly one input", + package.name, package.version + ))); + }; + let material = input.material; + if material.selector() != package.selector + || material.revision() != package.revision + || material.source_commit() != self.source_commit + || material.cargo_lock_sha256() != self.cargo_lock_sha256 + { + return Err(material_error(format!( + "Git closure for {} is not bound to its locked source, revision or approved lock", + package.name + ))); + } + Ok(input) + } + + /// Prepares `output_root/vendor` from verified originals. + /// + /// The root must be an empty real directory. On failure it may hold a + /// partial tree and must be discarded. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for a non-fresh root, a missing, + /// extra or unverifiable original, a manifest that does not declare the + /// locked identity, an ambiguous or missing Git package root, a + /// normalized manifest whose original differs from the tree, a repeated + /// vendor directory, or an expired or cancelled attempt. + pub(crate) fn prepare_cargo_vendor( + &self, + inputs: &VendorInputs<'_>, + output_root: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result> { + attempt_check(deadline, cancellation)?; + check_output_root(output_root)?; + self.check_vendor_inputs(inputs)?; + let sources: BTreeMap = self + .cargo + .git_packages + .iter() + .map(|package| { + GitSourceConfig::parse(&package.selector) + .map(|source| (source.key().to_owned(), source)) + }) + .collect::>()?; + let vendor = output_root.join(VENDOR_DIRECTORY); + DirBuilder::new() + .mode(0o755) + .create(&vendor) + .map_err(material_error)?; + for ((name, version), path) in inputs.registry_archives { + attempt_check(deadline, cancellation)?; + self.vendor_registry(name, version, path, &vendor, deadline, cancellation)?; + } + let mut derived = BTreeMap::new(); + for package in &self.cargo.git_packages { + attempt_check(deadline, cancellation)?; + let input = self.matching_git_input(package, inputs)?; + if let Some(digest) = vendor_git(package, input, &vendor)? { + derived.insert(directory_name(&package.name, &package.version)?, digest); + } + } + attempt_check(deadline, cancellation)?; + Ok(PreparedCargoMaterials { + root: output_root.to_owned(), + digest: tool_tree_digest(&vendor, &|| attempt_check(deadline, cancellation))?, + vendor, + source_commit: self.source_commit.clone(), + cargo_lock_sha256: self.cargo_lock_sha256.clone(), + git_sources: sources.into_values().collect(), + derived_manifests: derived, + }) + } + + fn vendor_registry( + &self, + name: &str, + version: &str, + path: &Path, + vendor: &Path, + deadline: Instant, + cancellation: &Cancellation, + ) -> Result<(), Report> { + let directory = directory_name(name, version)?; + let checksum = self + .cargo + .registry + .get(&(name.to_owned(), version.to_owned())) + .map(|material| material.sha256.clone()) + .ok_or_else(|| material_error("registry crate is not locked"))?; + let original = self.verify_registry(name, version, path, deadline, cancellation)?; + let extracted = self.extract_registry(name, version, original)?; + extracted.verify_unchanged()?; + let manifest = extracted.read_member_checked("Cargo.toml")?; + require_identity(&manifest, name, version)?; + let mut writer = PackageWriter::create(vendor, &directory)?; + copy_extracted(extracted.root(), &mut writer)?; + extracted.verify_unchanged()?; + writer.finish(Some(&checksum))?; + Ok(()) + } +} + +/// Vendors one Git package; returns the normalized manifest digest if one was used. +fn vendor_git( + package: &GitPackage, + input: &GitVendorInput<'_>, + vendor: &Path, +) -> Result, Report> { + let directory = directory_name(&package.name, &package.version)?; + let store = Store::new(input.material.object_store()); + let root = find_package(&store, input.material.root_tree(), &package.name)?; + let committed = store.read("blob", &root.manifest, MANIFEST_LIMIT)?; + let table = parse_manifest(&committed)?; + match declared_identity(&table) { + Some((name, _)) if name != package.name => { + return Err(material_error(format!( + "Git manifest declares package {name}, not {}", + package.name + ))); + } + Some((_, Some(version))) if version != package.version => { + return Err(material_error(format!( + "Git manifest declares {} {version}, not {} {}", + package.name, package.name, package.version + ))); + } + Some(_) => {} + None => return Err(material_error("Git manifest declares no package")), + } + let inherits = inherits_workspace(&toml::Value::Table(table)); + let (manifest, original) = match (&input.normalized, inherits) { + (None, true) => { + return Err(material_error(format!( + "Git package {} inherits from its workspace; a normalized manifest is required", + package.name + ))); + } + (None, false) => (committed, None), + (Some(normalized), _) => { + if normalized.cargo_toml_orig != committed { + return Err(material_error(format!( + "Cargo.toml.orig for {} differs from the verified tree manifest", + package.name + ))); + } + require_identity(&normalized.cargo_toml, &package.name, &package.version)?; + let resolved = parse_manifest(&normalized.cargo_toml)?; + if inherits_workspace(&toml::Value::Table(resolved)) { + return Err(material_error(format!( + "normalized manifest for {} still inherits from a workspace", + package.name + ))); + } + (normalized.cargo_toml.clone(), Some(committed)) + } + }; + let mut writer = PackageWriter::create(vendor, &directory)?; + materialize(&store, &root, &mut writer)?; + writer.write("Cargo.toml", &manifest, false)?; + if let Some(original) = &original { + writer.write("Cargo.toml.orig", original, false)?; + } + let derived = original + .as_ref() + .and_then(|_| writer.digest_of("Cargo.toml").map(str::to_owned)); + writer.finish(None)?; + Ok(derived) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs b/crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs new file mode 100644 index 0000000000..8d1e527dfd --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/git_tree.rs @@ -0,0 +1,301 @@ +//! Reads a verified loose-object Git closure and materializes one package. +//! +//! [`VerifiedGitMaterial`](crate::materials::git::VerifiedGitMaterial) owns a +//! private store of zlib-compressed loose objects whose SHA-1 identities were +//! already checked. Every object is rehashed again here, so a store edited +//! after verification is rejected instead of vendored. + +use std::cell::Cell; +use std::collections::BTreeSet; +use std::fs::OpenOptions; +use std::io::Read as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use flate2::read::ZlibDecoder; + +use super::writer::PackageWriter; +use crate::error::ReleaseError; +use crate::git_objects::checked_sha1; +use crate::materials::material_error; + +/// Largest single object read from the store. +const OBJECT_LIMIT: u64 = 32 * 1024 * 1024; +/// Total object bytes read per vendoring. +const TOTAL_LIMIT: u64 = 512 * 1024 * 1024; +/// Deepest accepted tree nesting. +const DEPTH_LIMIT: usize = 256; +/// Most tree entries visited per vendoring. +const ENTRY_LIMIT: usize = 100_000; +/// Largest manifest examined while looking for a package root. +const MANIFEST_LIMIT: u64 = 1024 * 1024; + +/// One parsed tree entry. +#[derive(Clone, Debug)] +pub(super) struct TreeEntry { + pub(super) name: String, + pub(super) directory: bool, + pub(super) executable: bool, + pub(super) oid: String, +} + +/// A rehashing reader over one verified loose-object store. +pub(super) struct Store<'a> { + root: &'a Path, + remaining: Cell, + entries: Cell, +} + +fn lower_oid(text: &str) -> bool { + text.len() == 40 + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Parses a tree body, rejecting symlinks, submodules and unsafe names. +fn parse_tree(mut bytes: &[u8]) -> Result, Report> { + let mut seen = BTreeSet::new(); + let mut entries = Vec::new(); + while !bytes.is_empty() { + let space = bytes + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| material_error("malformed Git tree mode"))?; + let (directory, executable) = match &bytes[..space] { + b"40000" => (true, false), + b"100644" => (false, false), + b"100755" => (false, true), + _ => { + return Err(material_error( + "Git package symlinks, submodules and noncanonical modes are unreviewed", + )); + } + }; + let rest = &bytes[space + 1..]; + let nul = rest + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| material_error("malformed Git tree name"))?; + let name = &rest[..nul]; + let unsafe_name = name.is_empty() + || !name.is_ascii() + || name + .iter() + .any(|byte| byte.is_ascii_control() || matches!(byte, b'/' | b'\\' | b':')) + || matches!(name, b"." | b"..") + || name.eq_ignore_ascii_case(b".git") + || name.len() > 255; + if unsafe_name { + return Err(material_error("unsafe Git package tree name")); + } + if !seen.insert(name.to_ascii_lowercase()) { + return Err(material_error("Git package tree aliases or repeats a name")); + } + let oid = rest + .get(nul + 1..nul + 21) + .ok_or_else(|| material_error("truncated Git tree identity"))?; + entries.push(TreeEntry { + name: String::from_utf8_lossy(name).into_owned(), + directory, + executable, + oid: hex::encode(oid), + }); + bytes = &rest[nul + 21..]; + } + Ok(entries) +} + +impl<'a> Store<'a> { + /// Opens a store rooted at the verified material's object directory. + pub(super) fn new(root: &'a Path) -> Self { + Self { + root, + remaining: Cell::new(TOTAL_LIMIT), + entries: Cell::new(0), + } + } + + /// Reads and rehashes one object of `kind`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for a missing, linked, oversize, + /// malformed or mis-identified object, or an exhausted total budget. + pub(super) fn read( + &self, + kind: &str, + oid: &str, + limit: u64, + ) -> Result, Report> { + if !lower_oid(oid) { + return Err(material_error("Git object id is not canonical")); + } + let path = self.root.join(&oid[..2]).join(&oid[2..]); + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(&path) + .map_err(|error| { + material_error(format!("Git object {oid} cannot be opened: {error}")) + })?; + let metadata = file.metadata().map_err(material_error)?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(material_error("Git object is not one regular file")); + } + let allowed = limit.min(OBJECT_LIMIT); + let mut inflated = Vec::new(); + ZlibDecoder::new(file) + .take(allowed + 64) + .read_to_end(&mut inflated) + .map_err(|error| material_error(format!("Git object {oid} is not zlib: {error}")))?; + let nul = inflated + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| material_error("Git object has no header terminator"))?; + let header = std::str::from_utf8(&inflated[..nul]).map_err(material_error)?; + let (found_kind, length) = header + .split_once(' ') + .ok_or_else(|| material_error("Git object header is malformed"))?; + let length: u64 = length + .parse() + .ok() + .filter(|length: &u64| length.to_string() == header[found_kind.len() + 1..]) + .ok_or_else(|| material_error("Git object length is not canonical"))?; + let body = &inflated[nul + 1..]; + if found_kind != kind || length != body.len() as u64 || length > allowed { + return Err(material_error( + "Git object kind or length differs from its reference or limit", + )); + } + let remaining = self + .remaining + .get() + .checked_sub(length) + .ok_or_else(|| material_error("Git package reads exceed the total byte budget"))?; + self.remaining.set(remaining); + if checked_sha1(&[&inflated[..=nul], body])? != oid { + return Err(material_error( + "Git object bytes do not match their identity", + )); + } + Ok(inflated.split_off(nul + 1)) + } + + fn tree(&self, oid: &str) -> Result, Report> { + let entries = parse_tree(&self.read("tree", oid, OBJECT_LIMIT)?)?; + let visited = self.entries.get() + entries.len(); + if visited > ENTRY_LIMIT { + return Err(material_error("Git package tree exceeds the entry limit")); + } + self.entries.set(visited); + Ok(entries) + } +} + +/// The unique directory of the requested package inside a repository tree. +#[derive(Clone, Debug)] +pub(super) struct PackageRoot { + pub(super) tree: String, + pub(super) manifest: String, + pub(super) path: String, +} + +/// Returns the package name a manifest declares, if it parses as a manifest. +fn declared_name(manifest: &[u8]) -> Option { + let text = std::str::from_utf8(manifest).ok()?; + let table: toml::Table = toml::from_str(text).ok()?; + table + .get("package")? + .get("name")? + .as_str() + .map(str::to_owned) +} + +/// Finds the only directory whose `Cargo.toml` declares package `name`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] when no directory or more than one +/// directory declares the package, or the tree exceeds a limit. Manifests +/// that do not parse are not candidates; Cargo could not use them either. +pub(super) fn find_package( + store: &Store<'_>, + root_tree: &str, + name: &str, +) -> Result> { + let mut candidates = Vec::new(); + let mut pending = vec![(root_tree.to_owned(), String::new(), 1_usize)]; + while let Some((tree, path, depth)) = pending.pop() { + if depth > DEPTH_LIMIT { + return Err(material_error("Git package tree exceeds the depth limit")); + } + for entry in store.tree(&tree)? { + let child = if path.is_empty() { + entry.name.clone() + } else { + format!("{path}/{}", entry.name) + }; + if entry.directory { + pending.push((entry.oid, child, depth + 1)); + } else if entry.name == "Cargo.toml" { + let manifest = store.read("blob", &entry.oid, MANIFEST_LIMIT)?; + if declared_name(&manifest).as_deref() == Some(name) { + candidates.push(PackageRoot { + tree: tree.clone(), + manifest: entry.oid, + path: path.clone(), + }); + } + } + } + } + match candidates.as_slice() { + [only] => Ok(only.clone()), + [] => Err(material_error(format!( + "no Git directory declares package {name}" + ))), + _ => Err(material_error(format!( + "ambiguous Git package roots: several directories declare package {name}" + ))), + } +} + +/// Writes the package tree below `root.tree` through `writer`. +/// +/// Subdirectories with their own `Cargo.toml` are separate packages and are +/// skipped, as Cargo's own file listing does. The root `Cargo.toml` is not +/// written here: the caller decides which manifest bytes represent it. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for an unreadable object or write. +pub(super) fn materialize( + store: &Store<'_>, + root: &PackageRoot, + writer: &mut PackageWriter, +) -> Result<(), Report> { + let mut pending = vec![(store.tree(&root.tree)?, String::new(), 1_usize)]; + while let Some((entries, prefix, depth)) = pending.pop() { + if depth > DEPTH_LIMIT { + return Err(material_error("Git package tree exceeds the depth limit")); + } + for entry in &entries { + let relative = format!("{prefix}{}", entry.name); + if entry.directory { + let child = store.tree(&entry.oid)?; + let nested_package = child + .iter() + .any(|inner| !inner.directory && inner.name == "Cargo.toml"); + if !nested_package { + pending.push((child, format!("{relative}/"), depth + 1)); + } + } else if relative != "Cargo.toml" { + let bytes = store.read("blob", &entry.oid, OBJECT_LIMIT)?; + writer.write(&relative, &bytes, entry.executable)?; + } + } + } + Ok(()) +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/source.rs b/crates/trusted-server-release/src/materials/cargo_vendor/source.rs new file mode 100644 index 0000000000..b796997c33 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/source.rs @@ -0,0 +1,119 @@ +//! Cargo source identities of locked Git packages, for source replacement. + +use error_stack::Report; + +use crate::error::ReleaseError; +use crate::materials::material_error; + +/// The Git reference a locked source was requested with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum GitReference { + /// No `?` selector in the lock: the repository's default branch. + DefaultBranch, + /// `?tag=`. + Tag(String), + /// `?branch=`. + Branch(String), + /// `?rev=`. + Rev(String), +} + +/// One locked Git source as Cargo's `[source.""]` table spells it. +/// +/// The key is the exact lock source without its `#` suffix, which is +/// how `cargo vendor` names the table. Cargo also needs the `git` location +/// and the reference in the same table: a bare `replace-with` is rejected +/// with "no source location specified". +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GitSourceConfig { + key: String, + url: String, + reference: GitReference, +} + +/// Characters a reference may carry; anything else would need escaping. +/// +/// This deliberately rejects `/` (and so branch names such as `feature/x`, +/// which Cargo writes percent-encoded) and `%`: a locked source needing them +/// fails closed here until the encoding is reviewed and supported. +fn reference_text(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +/// Requires an `https` URL that can sit unescaped inside a TOML basic string. +fn url_text(url: &str) -> bool { + url.strip_prefix("https://").is_some_and(|rest| { + !rest.is_empty() + && rest.bytes().all(|byte| { + byte.is_ascii_graphic() && !matches!(byte, b'"' | b'\\' | b'#' | b'?' | b'%') + }) + }) +} + +impl GitSourceConfig { + /// Parses a lock source selector such as `git+https://example.com/org/repo?tag=v1`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for a selector that is not `git+https`, + /// carries more than one reference, an empty or unusual reference value, + /// or characters that would need escaping in a Cargo configuration. + pub(crate) fn parse(selector: &str) -> Result> { + let location = selector + .strip_prefix("git+") + .ok_or_else(|| material_error("Git source selector must start with git+"))?; + let (url, query) = match location.split_once('?') { + Some((url, query)) => (url, Some(query)), + None => (location, None), + }; + if !url_text(url) { + return Err(material_error( + "Git source URL must be plain https text without escapes", + )); + } + let reference = match query { + None => GitReference::DefaultBranch, + Some(query) => match query.split_once('=') { + Some((kind, value)) if reference_text(value) => match kind { + "tag" => GitReference::Tag(value.to_owned()), + "branch" => GitReference::Branch(value.to_owned()), + "rev" => GitReference::Rev(value.to_owned()), + _ => return Err(material_error("Git source reference kind is unsupported")), + }, + _ => { + return Err(material_error( + "Git source must carry exactly one plain tag, branch or rev", + )); + } + }, + }; + Ok(Self { + key: selector.to_owned(), + url: url.to_owned(), + reference, + }) + } + + /// The exact locked selector, used as the Cargo source table name. + pub(crate) fn key(&self) -> &str { + &self.key + } + + /// The repository URL for the table's `git` key. + pub(crate) fn url(&self) -> &str { + &self.url + } + + /// The reference as a Cargo key and value, or `None` for the default branch. + pub(crate) fn reference(&self) -> Option<(&'static str, &str)> { + match &self.reference { + GitReference::DefaultBranch => None, + GitReference::Tag(value) => Some(("tag", value)), + GitReference::Branch(value) => Some(("branch", value)), + GitReference::Rev(value) => Some(("rev", value)), + } + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests.rs new file mode 100644 index 0000000000..e2b8d6b8d5 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests.rs @@ -0,0 +1,4 @@ +mod git; +mod registry; +mod source; +mod support; diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs new file mode 100644 index 0000000000..944926a7b4 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs @@ -0,0 +1,464 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt as _; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::support::*; +use crate::materials::locked::GitPackage; +use crate::materials::{ + GitVendorInput, LockedMaterials, NormalizedManifest, PreparedCargoMaterials, VendorInputs, +}; + +const INHERITING: &str = "[package]\nname = \"example-core\"\nversion.workspace = true\nedition.workspace = true\n\n[dependencies]\nexample-dep = { workspace = true }\n"; +const NORMALIZED: &str = + "[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2021\"\n"; +const PLAIN: &str = "[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2021\"\n"; + +/// A fictional workspace: root manifest plus `crates/core` and a nested package. +struct Workspace { + objects: Objects, + root_tree: String, + manifest_blob: String, +} + +fn workspace(core_manifest: &str, extra_core_entries: &[(&str, &str, &str)]) -> Workspace { + let objects = Objects::new(); + let manifest_blob = objects.blob(core_manifest.as_bytes()); + let lib = objects.blob(b"pub fn core() {}\n"); + let script = objects.blob(b"#!/bin/sh\n"); + let src = objects.tree(&[("100644", "lib.rs", &lib)]); + let nested_manifest = objects.blob(&manifest("example-nested", "0.1.0")); + let nested_lib = objects.blob(b"pub fn nested() {}\n"); + let nested_src = objects.tree(&[("100644", "lib.rs", &nested_lib)]); + let nested = objects.tree(&[ + ("100644", "Cargo.toml", &nested_manifest), + ("40000", "src", &nested_src), + ]); + let mut entries: Vec<(&str, &str, &str)> = vec![ + ("100644", "Cargo.toml", &manifest_blob), + ("100755", "run.sh", &script), + ("40000", "nested", &nested), + ("40000", "src", &src), + ]; + entries.extend_from_slice(extra_core_entries); + entries.sort_by_key(|entry| entry.1); + let core = objects.tree(&entries); + let crates = objects.tree(&[("40000", "core", &core)]); + let workspace_manifest = objects.blob(b"[workspace]\nmembers = [\"crates/core\"]\n"); + let root_tree = objects.tree(&[ + ("40000", "crates", &crates), + ("100644", "Cargo.toml", &workspace_manifest), + ]); + Workspace { + objects, + root_tree, + manifest_blob, + } +} + +struct Run { + _output: tempfile::TempDir, + root: std::path::PathBuf, + outcome: Result, +} + +fn run( + locked: &LockedMaterials, + material: &crate::materials::git::VerifiedGitMaterial, + normalized: Option, +) -> Run { + let output = temporary(); + let root = output.path().to_owned(); + let package = &locked.cargo.git_packages[0]; + let inputs = [GitVendorInput { + material, + name: &package.name, + version: &package.version, + normalized, + }]; + let outcome = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &inputs, + }, + &root, + deadline(), + &cancellation(), + ) + .map_err(|error| format!("{error:?}")); + Run { + _output: output, + root, + outcome, + } +} + +fn locked_core() -> LockedMaterials { + locked(&[], vec![git_package("example-core", "0.4.0")]) +} + +fn normalized(cargo_toml: &str, orig: &str) -> Option { + Some(NormalizedManifest { + cargo_toml: cargo_toml.as_bytes().to_vec(), + cargo_toml_orig: orig.as_bytes().to_vec(), + }) +} + +fn rejection(run: Run) -> String { + run.outcome.expect_err("should reject the Git package") +} + +#[test] +fn plain_manifest_package_is_vendored_without_derivation() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let result = run(&locked_core(), &material, None); + let materials = result.outcome.expect("should vendor the plain package"); + let package = result.root.join("vendor/example-core-0.4.0"); + assert_eq!( + fs::read(package.join("Cargo.toml")).expect("should read manifest"), + PLAIN.as_bytes(), + "should keep the committed manifest" + ); + assert_eq!( + fs::read(package.join("src/lib.rs")).expect("should read source"), + b"pub fn core() {}\n", + "should copy the tree" + ); + assert_eq!( + fs::metadata(package.join("run.sh")) + .expect("should stat script") + .permissions() + .mode() + & 0o7777, + 0o755, + "should keep the executable bit" + ); + assert!( + !package.join("nested").exists(), + "should skip nested packages" + ); + assert!( + !package.join("Cargo.toml.orig").exists(), + "should not invent an original" + ); + let checksum: Value = serde_json::from_slice( + &fs::read(package.join(".cargo-checksum.json")).expect("should read checksums"), + ) + .expect("should be JSON"); + assert_eq!( + checksum, + json!({ + "files": { + "Cargo.toml": hex::encode(Sha256::digest(PLAIN.as_bytes())), + "run.sh": hex::encode(Sha256::digest(b"#!/bin/sh\n")), + "src/lib.rs": hex::encode(Sha256::digest(b"pub fn core() {}\n")), + }, + "package": null, + }), + "should list files and a null package checksum like cargo vendor" + ); + assert!( + materials.derived_manifest_digests().is_empty(), + "should record no derived manifest" + ); + assert_eq!( + materials.git_sources().len(), + 1, + "should list the locked Git source" + ); + assert_eq!( + materials.git_sources()[0].key(), + SELECTOR, + "should key the source by its selector" + ); + materials.rehash().expect("should rehash the vendor tree"); +} + +#[test] +fn workspace_inheritance_requires_a_matching_normalized_manifest() { + let built = workspace(INHERITING, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let missing = rejection(run(&locked_core(), &material, None)); + assert!( + missing.contains("normalized manifest is required"), + "should require normalization: {missing}" + ); + + let accepted = run( + &locked_core(), + &material, + normalized(NORMALIZED, INHERITING), + ); + let materials = accepted.outcome.expect("should accept the normalized pair"); + let package = accepted.root.join("vendor/example-core-0.4.0"); + assert_eq!( + fs::read(package.join("Cargo.toml")).expect("should read manifest"), + NORMALIZED.as_bytes(), + "should vendor the normalized manifest" + ); + assert_eq!( + fs::read(package.join("Cargo.toml.orig")).expect("should read original"), + INHERITING.as_bytes(), + "should keep the committed manifest as the original" + ); + let digest = hex::encode(Sha256::digest(NORMALIZED.as_bytes())); + assert_eq!( + materials + .derived_manifest_digests() + .get("example-core-0.4.0"), + Some(&digest), + "should record the normalized manifest digest as derived" + ); + let checksum: Value = serde_json::from_slice( + &fs::read(package.join(".cargo-checksum.json")).expect("should read checksums"), + ) + .expect("should be JSON"); + assert_eq!( + checksum["files"]["Cargo.toml"], + json!(digest), + "should checksum the normalized manifest" + ); + assert_eq!( + checksum["files"]["Cargo.toml.orig"], + json!(hex::encode(Sha256::digest(INHERITING.as_bytes()))), + "should checksum the original" + ); +} + +#[test] +fn normalized_manifest_must_derive_from_the_verified_blob_and_resolve_inheritance() { + let built = workspace(INHERITING, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let cases: [(&str, Option, &str); 5] = [ + ( + "forged original", + normalized(NORMALIZED, "[package]\nname = \"example-core\"\n"), + "differs from the verified tree", + ), + ( + "other name", + normalized( + &NORMALIZED.replace("example-core", "example-other"), + INHERITING, + ), + "does not declare package", + ), + ( + "other version", + normalized(&NORMALIZED.replace("0.4.0", "0.4.1"), INHERITING), + "does not declare package", + ), + ( + "still inheriting", + normalized(INHERITING, INHERITING), + "does not declare package", + ), + ( + "inherited dependency", + normalized( + &format!("{NORMALIZED}[dependencies]\nexample-dep = {{ workspace = true }}\n"), + INHERITING, + ), + "still inherits", + ), + ]; + for (label, pair, expected) in cases { + let message = rejection(run(&locked_core(), &material, pair)); + assert!( + message.contains(expected), + "should reject {label}: {message}" + ); + } +} + +#[test] +fn package_identity_and_root_must_be_unambiguous() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let other_version = locked(&[], vec![git_package("example-core", "0.5.0")]); + assert!( + rejection(run(&other_version, &material, None)).contains("declares example-core 0.4.0"), + "should reject a manifest whose version differs from the lock" + ); + let unknown = locked(&[], vec![git_package("example-missing", "0.4.0")]); + assert!( + rejection(run(&unknown, &material, None)).contains("no Git directory declares"), + "should reject a package no directory declares" + ); + + let objects = Objects::new(); + let blob = objects.blob(PLAIN.as_bytes()); + let first = objects.tree(&[("100644", "Cargo.toml", &blob)]); + let second = objects.tree(&[("100644", "Cargo.toml", &blob)]); + let root = objects.tree(&[("40000", "a", &first), ("40000", "b", &second)]); + let material = objects.material(SELECTOR, REVISION, &root); + assert!( + rejection(run(&locked_core(), &material, None)).contains("ambiguous Git package roots"), + "should reject two directories declaring the package" + ); +} + +#[test] +fn tree_entries_that_would_alias_the_manifest_or_escape_are_rejected() { + let objects = Objects::new(); + let blob = objects.blob(PLAIN.as_bytes()); + let orig = objects.blob(b"committed orig\n"); + let tree = objects.tree(&[ + ("100644", "Cargo.toml", &blob), + ("100644", "Cargo.toml.orig", &orig), + ]); + let material = objects.material(SELECTOR, REVISION, &tree); + let conflict = rejection(run( + &locked_core(), + &material, + normalized(NORMALIZED, PLAIN), + )); + assert!( + conflict.contains("repeated"), + "should reject a committed Cargo.toml.orig beside a normalized pair: {conflict}" + ); + + for (label, mode, name) in [ + ("symlink", "120000", "link"), + ("submodule", "160000", "module"), + ("group-writable file", "100664", "odd"), + ("dotdot name", "100644", ".."), + ("backslash name", "100644", "a\\b"), + ("git name", "100644", ".GIT"), + ] { + let objects = Objects::new(); + let manifest = objects.blob(PLAIN.as_bytes()); + let extra = objects.blob(b"x"); + let tree = objects.tree(&[("100644", "Cargo.toml", &manifest), (mode, name, &extra)]); + let material = objects.material(SELECTOR, REVISION, &tree); + assert!( + run(&locked_core(), &material, None).outcome.is_err(), + "should reject a {label} entry" + ); + } +} + +#[test] +fn git_inputs_must_be_exactly_the_locked_bound_closures() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let cases: [(&str, GitPackage); 3] = [ + ( + "selector", + GitPackage { + selector: "git+https://example.com/org/other?tag=v1".to_owned(), + ..git_package("example-core", "0.4.0") + }, + ), + ( + "revision", + GitPackage { + revision: "4".repeat(40), + ..git_package("example-core", "0.4.0") + }, + ), + ( + "tag", + GitPackage { + selector: "git+https://example.com/org/workspace?tag=v2".to_owned(), + ..git_package("example-core", "0.4.0") + }, + ), + ]; + for (label, package) in cases { + let locked = locked(&[], vec![package]); + assert!( + rejection(run(&locked, &material, None)).contains("not bound to its locked source"), + "should reject a closure with another {label}" + ); + } + + let output = temporary(); + let locked = locked_core(); + let none: [GitVendorInput<'_>; 0] = []; + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &none + }, + output.path(), + deadline(), + &cancellation() + ) + .expect_err("should reject a missing closure") + .to_string() + .contains("Original release material"), + "should reject a locked Git package without a closure" + ); + let twice = [ + GitVendorInput { + material: &material, + name: "example-core", + version: "0.4.0", + normalized: None, + }, + GitVendorInput { + material: &material, + name: "example-core", + version: "0.4.0", + normalized: None, + }, + ]; + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &twice + }, + output.path(), + deadline(), + &cancellation() + ) + .is_err(), + "should reject an extra closure" + ); +} + +#[test] +fn store_objects_are_rehashed_before_use() { + let built = workspace(PLAIN, &[]); + let store = built.objects.store.path().to_owned(); + let blob = &built.manifest_blob; + let object = store.join(&blob[..2]).join(&blob[2..]); + let forged_store = Objects::new(); + let forged = forged_store + .blob(b"[package]\nname = \"example-core\"\nversion = \"0.4.0\"\nedition = \"2018\"\n"); + let other = forged_store + .store + .path() + .join(&forged[..2]) + .join(&forged[2..]); + fs::copy(&other, &object).expect("should swap the committed manifest object for forged bytes"); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let message = rejection(run(&locked_core(), &material, None)); + assert!( + message.contains("do not match their identity"), + "should reject an edited store object: {message}" + ); + + let built = workspace(PLAIN, &[]); + let lib = built + .objects + .store + .path() + .read_dir() + .expect("should list store") + .count(); + assert!(lib > 3, "should have written several objects"); + let missing_root = built.objects.material(SELECTOR, REVISION, &"5".repeat(40)); + assert!( + rejection(run(&locked_core(), &missing_root, None)).contains("cannot be opened"), + "should reject a root tree absent from the store" + ); +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs new file mode 100644 index 0000000000..36879f6b56 --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/registry.rs @@ -0,0 +1,454 @@ +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::Path; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::support::*; +use crate::materials::{LockedMaterials, PreparedCargoMaterials, VendorInputs}; + +struct Prepared { + _archives: tempfile::TempDir, + _output: tempfile::TempDir, + outcome: Result, + root: std::path::PathBuf, +} + +fn prepare(locked: &LockedMaterials, entries: &[(&str, &str, &[u8])]) -> Prepared { + prepare_with(locked, entries, |_| {}) +} + +/// Prepares from `entries`, letting `tweak` change the output root first. +fn prepare_with( + locked: &LockedMaterials, + entries: &[(&str, &str, &[u8])], + tweak: impl FnOnce(&Path), +) -> Prepared { + let archive_directory = temporary(); + let output = temporary(); + let registry = archives(archive_directory.path(), entries); + let root = output.path().to_owned(); + tweak(&root); + let outcome = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[], + }, + &root, + deadline(), + &cancellation(), + ) + .map_err(|error| format!("{error:?}")); + Prepared { + _archives: archive_directory, + _output: output, + outcome, + root, + } +} + +fn two_crates() -> (Vec, Vec) { + ( + plain_crate("example-one", "1.0.0"), + plain_crate("example-two", "0.3.1"), + ) +} + +#[test] +fn registry_crates_become_directory_source_packages_with_cargo_checksums() { + let (one, two) = two_crates(); + let entries: [(&str, &str, &[u8]); 2] = [ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ]; + let locked = locked(&entries, Vec::new()); + let prepared = prepare(&locked, &entries); + let materials = prepared.outcome.expect("should prepare the vendor tree"); + let vendor = materials.vendor_dir(); + assert_eq!( + vendor, + prepared.root.join("vendor"), + "should place packages under vendor" + ); + let mut names: Vec = fs::read_dir(vendor) + .expect("should list vendor") + .map(|entry| { + entry + .expect("should read entry") + .file_name() + .into_string() + .expect("should be UTF-8") + }) + .collect(); + names.sort(); + assert_eq!( + names, + ["example-one-1.0.0", "example-two-0.3.1"], + "should create one directory per crate" + ); + + let package = vendor.join("example-one-1.0.0"); + let manifest = manifest("example-one", "1.0.0"); + let expected = json!({ + "files": { + "Cargo.toml": hex::encode(Sha256::digest(&manifest)), + "build.sh": hex::encode(Sha256::digest(b"#!/bin/sh\n")), + "src/lib.rs": hex::encode(Sha256::digest(b"pub fn fictional() {}\n")), + }, + "package": hex::encode(Sha256::digest(&one)), + }); + let raw = fs::read(package.join(".cargo-checksum.json")).expect("should write checksum file"); + let parsed: Value = serde_json::from_slice(&raw).expect("should write JSON"); + assert_eq!( + parsed, expected, + "should list every file and the lock checksum" + ); + assert!( + String::from_utf8(raw) + .expect("should be UTF-8") + .starts_with("{\"files\":{"), + "should write files before package like cargo vendor" + ); + for (relative, mode) in [ + ("Cargo.toml", 0o644), + ("src/lib.rs", 0o644), + ("build.sh", 0o755), + ] { + let metadata = fs::symlink_metadata(package.join(relative)).expect("should stat file"); + assert_eq!( + metadata.permissions().mode() & 0o7777, + mode, + "should set mode of {relative}" + ); + assert_eq!(metadata.nlink(), 1, "should write unlinked {relative}"); + } + assert_eq!(materials.digest().len(), 64, "should digest the whole tree"); + assert_eq!( + materials.source_commit(), + SOURCE_COMMIT, + "should bind the approved source" + ); + assert_eq!( + materials.cargo_lock_sha256(), + LOCK_SHA256, + "should bind the lock digest" + ); + assert!( + materials.git_sources().is_empty(), + "should list no Git sources" + ); + materials + .rehash() + .expect("should rehash the untouched tree"); +} + +#[test] +fn preparation_is_deterministic_and_content_sensitive() { + let (one, two) = two_crates(); + let entries: [(&str, &str, &[u8]); 2] = [ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ]; + let locked_a = locked(&entries, Vec::new()); + let first = prepare(&locked_a, &entries) + .outcome + .expect("should prepare"); + let second = prepare(&locked_a, &entries) + .outcome + .expect("should prepare again"); + assert_eq!( + first.digest(), + second.digest(), + "should reproduce the digest across roots" + ); + + let changed = plain_crate_with("example-one", "1.0.0", b"pub fn other() {}\n"); + let changed_entries: [(&str, &str, &[u8]); 2] = [ + ("example-one", "1.0.0", &changed), + ("example-two", "0.3.1", &two), + ]; + let changed_locked = locked(&changed_entries, Vec::new()); + let third = prepare(&changed_locked, &changed_entries) + .outcome + .expect("should prepare changed"); + assert_ne!( + first.digest(), + third.digest(), + "should change the digest with content" + ); +} + +fn plain_crate_with(name: &str, version: &str, source: &[u8]) -> Vec { + crate_archive( + name, + version, + &[ + ("Cargo.toml", &manifest(name, version), 0o644), + ("src/lib.rs", source, 0o644), + ], + ) +} + +#[test] +fn rehash_detects_edits_additions_removals_and_extra_root_entries() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let package = |root: &Path| root.join("vendor/example-one-1.0.0"); + type Tamper = fn(&Path); + let tampers: [(&str, Tamper); 6] = [ + ("edit", |root| { + let path = root.join("vendor/example-one-1.0.0/src/lib.rs"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("should chmod"); + fs::write(path, b"tampered").expect("should edit"); + }), + ("add", |root| { + fs::write(root.join("vendor/example-one-1.0.0/extra.rs"), b"x").expect("should add"); + }), + ("remove", |root| { + fs::remove_file(root.join("vendor/example-one-1.0.0/src/lib.rs")) + .expect("should remove"); + }), + ("chmod", |root| { + fs::set_permissions( + root.join("vendor/example-one-1.0.0/build.sh"), + fs::Permissions::from_mode(0o644), + ) + .expect("should chmod"); + }), + ("extra root entry", |root| { + fs::create_dir(root.join("cargo-home")).expect("should add root entry"); + }), + ("hard link", |root| { + fs::hard_link( + root.join("vendor/example-one-1.0.0/src/lib.rs"), + root.join("vendor/example-one-1.0.0/linked.rs"), + ) + .expect("should link"); + }), + ]; + for (label, tamper) in tampers { + let prepared = prepare(&locked, &entries); + let materials = prepared.outcome.expect("should prepare"); + assert!( + package(&prepared.root).is_dir(), + "should create the package directory" + ); + tamper(&prepared.root); + assert!( + materials.rehash().is_err(), + "should reject {label} after preparation" + ); + } +} + +#[test] +fn registry_inputs_must_be_exactly_the_locked_crates() { + let (one, two) = two_crates(); + let locked_both = locked( + &[ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ], + Vec::new(), + ); + let missing = prepare(&locked_both, &[("example-one", "1.0.0", &one)]); + assert!( + missing + .outcome + .expect_err("should reject a missing original") + .contains("exactly the locked"), + "should reject a missing archive" + ); + let locked_one = locked(&[("example-one", "1.0.0", &one)], Vec::new()); + let extra = prepare( + &locked_one, + &[ + ("example-one", "1.0.0", &one), + ("example-two", "0.3.1", &two), + ], + ); + assert!( + extra + .outcome + .expect_err("should reject an extra original") + .contains("exactly the locked"), + "should reject an unlocked archive" + ); + let wrong_version = prepare(&locked_one, &[("example-one", "1.0.1", &one)]); + assert!( + wrong_version.outcome.is_err(), + "should reject an archive for another version" + ); +} + +#[test] +fn archive_bytes_must_match_the_locked_checksum() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let other = plain_crate_with("example-one", "1.0.0", b"different\n"); + let prepared = prepare(&locked, &[("example-one", "1.0.0", &other)]); + assert!( + prepared.outcome.is_err(), + "should reject bytes that do not match the lock checksum" + ); +} + +#[test] +fn manifests_must_declare_the_locked_identity_and_nothing_reserved() { + let cases: [(&str, Vec); 5] = [ + ( + "other name", + crate_archive( + "example-one", + "1.0.0", + &[("Cargo.toml", &manifest("example-other", "1.0.0"), 0o644)], + ), + ), + ( + "other version", + crate_archive( + "example-one", + "1.0.0", + &[("Cargo.toml", &manifest("example-one", "1.0.1"), 0o644)], + ), + ), + ( + "missing manifest", + crate_archive("example-one", "1.0.0", &[("src/lib.rs", b"x", 0o644)]), + ), + ( + "unparsable manifest", + crate_archive( + "example-one", + "1.0.0", + &[("Cargo.toml", b"[package", 0o644)], + ), + ), + ( + "reserved checksum file", + crate_archive( + "example-one", + "1.0.0", + &[ + ("Cargo.toml", &manifest("example-one", "1.0.0"), 0o644), + (".cargo-checksum.json", b"{}", 0o644), + ], + ), + ), + ]; + for (label, bytes) in cases { + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &bytes)]; + let locked = locked(&entries, Vec::new()); + assert!( + prepare(&locked, &entries).outcome.is_err(), + "should reject a crate with {label}" + ); + } +} + +#[test] +fn output_root_must_be_a_fresh_real_directory() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let populated = prepare_with(&locked, &entries, |root| { + fs::write(root.join("leftover"), b"x").expect("should populate root"); + }); + assert!( + populated + .outcome + .expect_err("should reject a populated root") + .contains("already has entries"), + "should reject a root with prior entries" + ); + let archive_directory = temporary(); + let registry = archives(archive_directory.path(), &entries); + let real = archive_directory.path().join("real-root"); + fs::create_dir(&real).expect("should create real root"); + let alias = archive_directory.path().join("alias-root"); + std::os::unix::fs::symlink(&real, &alias).expect("should alias root"); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + &alias, + deadline(), + &cancellation(), + ) + .expect_err("should reject a symlinked root") + .to_string() + .contains("Original release material"), + "should reject a symlinked root as a material error" + ); + let missing = archive_directory.path().join("missing-root"); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + &missing, + deadline(), + &cancellation(), + ) + .is_err(), + "should reject a root that does not exist" + ); + let file = archive_directory.path().join("a-file"); + fs::write(&file, b"x").expect("should write file"); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + &file, + deadline(), + &cancellation(), + ) + .is_err(), + "should reject a root that is a file" + ); +} + +#[test] +fn cancelled_or_expired_attempt_prepares_nothing() { + let one = plain_crate("example-one", "1.0.0"); + let entries: [(&str, &str, &[u8]); 1] = [("example-one", "1.0.0", &one)]; + let locked = locked(&entries, Vec::new()); + let archive_directory = temporary(); + let registry = archives(archive_directory.path(), &entries); + let output = temporary(); + let token = cancellation(); + token.cancel(); + assert!( + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &[] + }, + output.path(), + deadline(), + &token, + ) + .is_err(), + "should reject a cancelled attempt" + ); + assert!( + fs::read_dir(output.path()) + .expect("should list root") + .next() + .is_none(), + "should write nothing for a cancelled attempt" + ); +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs new file mode 100644 index 0000000000..23b2eeeaef --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/source.rs @@ -0,0 +1,102 @@ +use crate::materials::cargo_vendor::GitSourceConfig; +use crate::materials::locked::parse_cargo; + +/// Selector, derived URL and derived reference of one locked Git source. +type Case = ( + &'static str, + &'static str, + Option<(&'static str, &'static str)>, +); + +#[test] +fn locked_selectors_map_to_exact_cargo_source_tables() { + let cases: [Case; 4] = [ + ( + "git+https://example.com/org/repo?tag=v1.2.3", + "https://example.com/org/repo", + Some(("tag", "v1.2.3")), + ), + ( + "git+https://example.com/org/repo?branch=release-1", + "https://example.com/org/repo", + Some(("branch", "release-1")), + ), + ( + "git+https://example.com/org/repo?rev=abc123", + "https://example.com/org/repo", + Some(("rev", "abc123")), + ), + ( + "git+https://example.com:8443/org/repo.git", + "https://example.com:8443/org/repo.git", + None, + ), + ]; + for (selector, url, reference) in cases { + let config = GitSourceConfig::parse(selector).expect("should parse the selector"); + assert_eq!( + config.key(), + selector, + "should keep the exact selector as the key" + ); + assert_eq!(config.url(), url, "should derive the repository URL"); + assert_eq!(config.reference(), reference, "should derive the reference"); + } + assert_eq!( + GitSourceConfig::parse("git+https://example.com/org/repo") + .expect("should parse") + .reference(), + None, + "should report the default branch without a reference" + ); +} + +#[test] +fn unusual_selectors_are_rejected() { + for selector in [ + "", + "https://example.com/org/repo?tag=v1", + "git+http://example.com/org/repo?tag=v1", + "git+ssh://example.com/org/repo", + "git+file:///local/repo", + "git+https://", + "git+https://example.com/org/repo?tag=", + "git+https://example.com/org/repo?tag=a&branch=b", + "git+https://example.com/org/repo?tag=a%2Fb", + "git+https://example.com/org/repo?tag=a/b", + "git+https://example.com/org/repo?unknown=a", + "git+https://example.com/org/repo?tag", + "git+https://example.com/org/\"repo", + "git+https://example.com/org\\repo", + "git+https://example.com/org repo", + "git+https://example.com/org/repo#abc", + "git+https://example.com/org/repo\n", + "git+https://example.com/org/%2Frepo", + ] { + assert!( + GitSourceConfig::parse(selector).is_err(), + "should reject selector {selector:?}" + ); + } +} + +#[test] +fn tracked_lockfile_git_packages_all_parse_as_sources() { + let lock = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.lock")) + .expect("should read the tracked Cargo.lock"); + let materials = parse_cargo(&lock).expect("should parse the tracked lockfile"); + assert!( + !materials.git_packages.is_empty(), + "should select a nonzero number of locked Git packages" + ); + for package in &materials.git_packages { + let config = GitSourceConfig::parse(&package.selector) + .expect("should parse every tracked Git source"); + assert!( + config.reference().is_some(), + "should pin {} with a tag, branch or rev", + package.name + ); + assert_eq!(package.revision.len(), 40, "should carry a full revision"); + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs new file mode 100644 index 0000000000..23c855137e --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/support.rs @@ -0,0 +1,187 @@ +//! Fictional originals for vendoring tests; nothing here spawns a process. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use flate2::Compression; +use flate2::write::{GzEncoder, ZlibEncoder}; +use sha2::{Digest as _, Sha256}; + +use crate::git_objects::checked_sha1; +use crate::materials::LockedMaterials; +use crate::materials::git::VerifiedGitMaterial; +use crate::materials::locked::{CargoMaterials, GitPackage, RegistryMaterial}; +use crate::process::Cancellation; + +pub(super) const SOURCE_COMMIT: &str = "1111111111111111111111111111111111111111"; +pub(super) const LOCK_SHA256: &str = + "2222222222222222222222222222222222222222222222222222222222222222"; +pub(super) const SELECTOR: &str = "git+https://example.com/org/workspace?tag=v1"; + +/// A canonical temporary directory. +pub(super) fn temporary() -> tempfile::TempDir { + let root = fs::canonicalize(std::env::temp_dir()).expect("should resolve temporary parent"); + tempfile::tempdir_in(root).expect("should create canonical temporary directory") +} + +pub(super) fn deadline() -> Instant { + Instant::now() + Duration::from_secs(60) +} + +/// Builds a `.crate` archive whose members sit under `-/`. +pub(super) fn crate_archive(name: &str, version: &str, files: &[(&str, &[u8], u32)]) -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast())); + for (path, bytes, mode) in files { + let mut header = tar::Header::new_ustar(); + header.set_mode(*mode); + header.set_size(bytes.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, format!("{name}-{version}/{path}"), *bytes) + .expect("should append fictional member"); + } + builder + .into_inner() + .expect("should finish fictional TAR") + .finish() + .expect("should finish fictional gzip") +} + +pub(super) fn manifest(name: &str, version: &str) -> Vec { + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n") + .into_bytes() +} + +/// A standard two-file crate. +pub(super) fn plain_crate(name: &str, version: &str) -> Vec { + crate_archive( + name, + version, + &[ + ("Cargo.toml", &manifest(name, version), 0o644), + ("src/lib.rs", b"pub fn fictional() {}\n", 0o644), + ("build.sh", b"#!/bin/sh\n", 0o755), + ], + ) +} + +/// Lock state for `registry` archives and `git` packages. +pub(super) fn locked(registry: &[(&str, &str, &[u8])], git: Vec) -> LockedMaterials { + LockedMaterials { + source_commit: SOURCE_COMMIT.to_owned(), + cargo_lock_sha256: LOCK_SHA256.to_owned(), + npm_lock_sha256: "c".repeat(64), + cargo: CargoMaterials { + registry: registry + .iter() + .map(|(name, version, bytes)| { + ( + ((*name).to_owned(), (*version).to_owned()), + RegistryMaterial { + name: (*name).to_owned(), + version: (*version).to_owned(), + sha256: hex::encode(Sha256::digest(bytes)), + }, + ) + }) + .collect(), + git: git + .iter() + .map(|package| (package.selector.clone(), package.revision.clone())) + .collect(), + git_packages: git, + }, + npm: BTreeMap::new(), + } +} + +/// Writes registry archives into `directory` and returns them by identity. +pub(super) fn archives( + directory: &Path, + entries: &[(&str, &str, &[u8])], +) -> BTreeMap<(String, String), PathBuf> { + entries + .iter() + .map(|(name, version, bytes)| { + let path = directory.join(format!("{name}-{version}.crate")); + fs::write(&path, bytes).expect("should write fictional archive"); + (((*name).to_owned(), (*version).to_owned()), path) + }) + .collect() +} + +pub(super) fn cancellation() -> Cancellation { + Cancellation::default() +} + +/// Builds loose Git objects by hand. +pub(super) struct Objects { + pub(super) store: tempfile::TempDir, +} + +impl Objects { + pub(super) fn new() -> Self { + Self { store: temporary() } + } + + pub(super) fn object(&self, kind: &str, bytes: &[u8]) -> String { + let header = format!("{kind} {}\0", bytes.len()); + let oid = checked_sha1(&[header.as_bytes(), bytes]).expect("should hash fixture object"); + let path = self.store.path().join(&oid[..2]).join(&oid[2..]); + fs::create_dir_all(path.parent().expect("should have parent")) + .expect("should create object directory"); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(header.as_bytes()) + .expect("should encode header"); + encoder.write_all(bytes).expect("should encode body"); + fs::write(path, encoder.finish().expect("should finish object")) + .expect("should write fixture object"); + oid + } + + pub(super) fn blob(&self, bytes: &[u8]) -> String { + self.object("blob", bytes) + } + + /// Entries are `(mode, name, oid)`; callers list them in Git order. + pub(super) fn tree(&self, entries: &[(&str, &str, &str)]) -> String { + let mut body = Vec::new(); + for (mode, name, oid) in entries { + body.extend_from_slice(format!("{mode} {name}\0").as_bytes()); + body.extend(hex::decode(oid).expect("should decode oid")); + } + self.object("tree", &body) + } + + /// Wraps the store as verified material for `selector` at `revision`. + pub(super) fn material( + self, + selector: &str, + revision: &str, + root_tree: &str, + ) -> VerifiedGitMaterial { + VerifiedGitMaterial::for_tests( + SOURCE_COMMIT, + LOCK_SHA256, + selector, + revision, + root_tree, + self.store, + ) + } +} + +pub(super) const REVISION: &str = "3333333333333333333333333333333333333333"; + +pub(super) fn git_package(name: &str, version: &str) -> GitPackage { + GitPackage { + name: name.to_owned(), + version: version.to_owned(), + selector: SELECTOR.to_owned(), + revision: REVISION.to_owned(), + } +} diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/writer.rs b/crates/trusted-server-release/src/materials/cargo_vendor/writer.rs new file mode 100644 index 0000000000..435f0fe49d --- /dev/null +++ b/crates/trusted-server-release/src/materials/cargo_vendor/writer.rs @@ -0,0 +1,160 @@ +//! Writes one Cargo directory-source package and its `.cargo-checksum.json`. +//! +//! The file format is the one `cargo vendor` writes and Cargo's directory +//! source reads: `{"files":{"":""},"package":}`. +//! Every listed file is rehashed by Cargo when the package is used; `package` +//! must equal the `Cargo.lock` checksum for registry crates and is `null` for +//! Git packages. + +use std::collections::BTreeMap; +use std::fs::{self, DirBuilder, OpenOptions}; +use std::io::Write as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use error_stack::Report; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use crate::error::ReleaseError; +use crate::materials::material_error; + +/// Reserved name Cargo reads the checksums from. +pub(super) const CHECKSUM_FILE: &str = ".cargo-checksum.json"; +/// Longest accepted package-relative path. +const PATH_LIMIT: usize = 4096; + +#[derive(Serialize)] +struct Checksum<'a> { + files: &'a BTreeMap, + package: Option<&'a str>, +} + +/// One package directory under the vendor root, being written. +pub(super) struct PackageWriter { + directory: PathBuf, + files: BTreeMap, +} + +/// Requires a nonempty `/`-separated path of plain components. +fn check_relative(relative: &str) -> Result<(), Report> { + let plain = !relative.is_empty() + && relative.len() <= PATH_LIMIT + && relative.split('/').all(|part| { + !part.is_empty() + && part != "." + && part != ".." + && !part.contains(['\\', '\0', ':']) + && !part.chars().any(char::is_control) + }); + if !plain || relative == CHECKSUM_FILE { + return Err(material_error(format!( + "vendored path {relative:?} is unsafe or reserved" + ))); + } + Ok(()) +} + +impl PackageWriter { + /// Creates `vendor/`, which must not exist yet. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when the directory exists already + /// (two locked packages with one name and version) or cannot be created. + pub(super) fn create( + vendor: &Path, + directory_name: &str, + ) -> Result> { + let directory = vendor.join(directory_name); + DirBuilder::new() + .mode(0o755) + .create(&directory) + .map_err(|error| { + material_error(format!( + "vendor directory {directory_name} cannot be created exclusively: {error}" + )) + })?; + Ok(Self { + directory, + files: BTreeMap::new(), + }) + } + + /// Writes one file exactly once and records its SHA-256. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] for an unsafe or reserved path, a + /// repeated path, or a failed write. + pub(super) fn write( + &mut self, + relative: &str, + bytes: &[u8], + executable: bool, + ) -> Result<(), Report> { + check_relative(relative)?; + if self.files.contains_key(relative) { + return Err(material_error(format!( + "vendored path {relative} is repeated" + ))); + } + let path = self.directory.join(relative); + if let Some(parent) = path.parent() { + DirBuilder::new() + .recursive(true) + .mode(0o755) + .create(parent) + .map_err(material_error)?; + } + let mode = if executable { 0o755 } else { 0o644 }; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(material_error)?; + file.write_all(bytes).map_err(material_error)?; + file.set_permissions(fs::Permissions::from_mode(mode)) + .map_err(material_error)?; + file.sync_all().map_err(material_error)?; + self.files + .insert(relative.to_owned(), hex::encode(Sha256::digest(bytes))); + Ok(()) + } + + /// Returns the recorded SHA-256 of a written file. + pub(super) fn digest_of(&self, relative: &str) -> Option<&str> { + self.files.get(relative).map(String::as_str) + } + + /// Writes `.cargo-checksum.json` and returns the written file digests. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Material`] when the file cannot be written. + pub(super) fn finish( + self, + package_checksum: Option<&str>, + ) -> Result, Report> { + let body = serde_json::to_vec(&Checksum { + files: &self.files, + package: package_checksum, + }) + .map_err(material_error)?; + let path = self.directory.join(CHECKSUM_FILE); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(material_error)?; + file.write_all(&body).map_err(material_error)?; + file.set_permissions(fs::Permissions::from_mode(0o644)) + .map_err(material_error)?; + file.sync_all().map_err(material_error)?; + Ok(self.files) + } +} diff --git a/crates/trusted-server-release/src/materials/git.rs b/crates/trusted-server-release/src/materials/git.rs index b56e71c01e..9c7673c089 100644 --- a/crates/trusted-server-release/src/materials/git.rs +++ b/crates/trusted-server-release/src/materials/git.rs @@ -50,6 +50,27 @@ pub(crate) struct VerifiedGitMaterial { } impl VerifiedGitMaterial { + /// Wraps a hand-built loose-object store without verifying it, for tests. + #[cfg(test)] + pub(super) fn for_tests( + source_commit: &str, + cargo_lock_sha256: &str, + selector: &str, + revision: &str, + root_tree: &str, + store: tempfile::TempDir, + ) -> Self { + Self { + source_commit: source_commit.to_owned(), + cargo_lock_sha256: cargo_lock_sha256.to_owned(), + selector: selector.to_owned(), + revision: revision.to_owned(), + root_tree: root_tree.to_owned(), + store, + object_ids: BTreeSet::new(), + } + } + /// Returns the independently approved source binding. pub(crate) fn source_commit(&self) -> &str { &self.source_commit diff --git a/crates/trusted-server-release/src/materials/git/tests.rs b/crates/trusted-server-release/src/materials/git/tests.rs index aab4e0bae6..6724f7e5e8 100644 --- a/crates/trusted-server-release/src/materials/git/tests.rs +++ b/crates/trusted-server-release/src/materials/git/tests.rs @@ -51,6 +51,7 @@ fn locked(revision: &str) -> LockedMaterials { cargo: CargoMaterials { registry: BTreeMap::new(), git: BTreeSet::from([(SELECTOR.to_owned(), revision.to_owned())]), + git_packages: Vec::new(), }, npm: BTreeMap::new(), } diff --git a/crates/trusted-server-release/src/materials/locked.rs b/crates/trusted-server-release/src/materials/locked.rs index 57b7f61c33..de6e1ceea6 100644 --- a/crates/trusted-server-release/src/materials/locked.rs +++ b/crates/trusted-server-release/src/materials/locked.rs @@ -30,10 +30,22 @@ pub(super) struct RegistryMaterial { pub(super) sha256: String, } +/// One Git package's exact locked identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct GitPackage { + pub(super) name: String, + pub(super) version: String, + /// Lock source without its `#` suffix, for example + /// `git+https://example.com/org/repo?tag=v1`. + pub(super) selector: String, + pub(super) revision: String, +} + /// Cargo acquisition expectations, not verified dependency trees. pub(super) struct CargoMaterials { pub(super) registry: BTreeMap<(String, String), RegistryMaterial>, pub(super) git: BTreeSet<(String, String)>, + pub(super) git_packages: Vec, } fn text<'a>(value: &'a Value, field: &str) -> Result<&'a str, Report> { @@ -185,6 +197,7 @@ pub(super) fn parse_cargo(bytes: &[u8]) -> Result Result return Err(material_error("unsupported Cargo material source")), } diff --git a/crates/trusted-server-release/src/materials/npm_bundle.rs b/crates/trusted-server-release/src/materials/npm_bundle.rs index a6c8716bc5..d76575079a 100644 --- a/crates/trusted-server-release/src/materials/npm_bundle.rs +++ b/crates/trusted-server-release/src/materials/npm_bundle.rs @@ -278,6 +278,7 @@ mod tests { cargo: crate::materials::locked::CargoMaterials { registry: BTreeMap::new(), git: std::collections::BTreeSet::new(), + git_packages: Vec::new(), }, npm, }; diff --git a/crates/trusted-server-release/src/materials/tests.rs b/crates/trusted-server-release/src/materials/tests.rs index f529d6d692..7efa33d1fd 100644 --- a/crates/trusted-server-release/src/materials/tests.rs +++ b/crates/trusted-server-release/src/materials/tests.rs @@ -70,6 +70,7 @@ fn legacy_root_and_pax_compatibility_require_exact_locked_versions() { cargo: CargoMaterials { registry: BTreeMap::new(), git: Default::default(), + git_packages: Vec::new(), }, npm: BTreeMap::from([( location.clone(), diff --git a/crates/trusted-server-release/src/preparation.rs b/crates/trusted-server-release/src/preparation.rs index 3212d2ad20..c70519c4ef 100644 --- a/crates/trusted-server-release/src/preparation.rs +++ b/crates/trusted-server-release/src/preparation.rs @@ -4,7 +4,6 @@ use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::fs::{self, File}; use std::io::Read as _; -use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -16,11 +15,14 @@ use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, DenialProof}; -use crate::documents::{Role, hash_bounded_with_check, parse_json}; +#[cfg(test)] +use crate::documents::hash_bounded_with_check; +use crate::documents::{Role, parse_json}; use crate::error::ReleaseError; use crate::materials::{LockedMaterials, PreparedNpmOriginals, RootManifestReview}; use crate::process::{Cancellation, ProcessLimits}; use crate::source::{ApprovedSource, FreshSource}; +use crate::tool_tree::tool_tree_digest; pub(super) const LIB: &str = "crates/trusted-server-js/lib"; const PREFIX: &[u8] = b"TS_RELEASE_NPM_OBSERVATION_V1 "; @@ -76,75 +78,7 @@ impl SelectedJsTools { } fn digest(&self, attempt: &JsAttempt) -> Result> { - let mut digest = Sha256::new(); - let mut total_bytes = 0_u64; - let mut pending = vec![self.root.clone()]; - let mut facts = BTreeMap::new(); - while let Some(directory) = pending.pop() { - for entry in fs::read_dir(directory).map_err(preparation_error)? { - attempt.check()?; - let entry = entry.map_err(preparation_error)?; - let path = entry.path(); - let relative = path - .strip_prefix(&self.root) - .map_err(preparation_error)? - .to_str() - .ok_or_else(|| preparation_error("tool path is not UTF-8"))? - .to_owned(); - let metadata = fs::symlink_metadata(&path).map_err(preparation_error)?; - let fact = if metadata.is_dir() { - pending.push(path.clone()); - format!("directory:{}", metadata.permissions().mode() & 0o7777) - } else if metadata.is_file() && metadata.nlink() == 1 { - let raw = - hash_bounded_with_check(&path, 512 * 1024 * 1024, &|| attempt.check())?; - total_bytes += raw.size; - if total_bytes > 2 * 1024 * 1024 * 1024 { - return Err(preparation_error( - "selected tool tree exceeds total byte limit", - )); - } - format!( - "file:{}:{}:{}", - metadata.permissions().mode() & 0o7777, - raw.size, - raw.sha256 - ) - } else if metadata.file_type().is_symlink() { - let target = fs::read_link(&path).map_err(preparation_error)?; - if target.is_absolute() - || !fs::canonicalize(&path) - .map_err(preparation_error)? - .starts_with(&self.root) - { - return Err(preparation_error( - "selected tool alias escapes its owned root", - )); - } - format!( - "alias:{}", - target - .to_str() - .ok_or_else(|| preparation_error("tool alias is not UTF-8"))? - ) - } else { - return Err(preparation_error( - "selected tool contains an unsafe file type or hardlink", - )); - }; - facts.insert(relative, fact); - if facts.len() > 100_000 { - return Err(preparation_error("selected tool tree exceeds file limit")); - } - } - } - for (relative, fact) in facts { - digest.update(relative.as_bytes()); - digest.update([0]); - digest.update(fact.as_bytes()); - digest.update([0]); - } - Ok(hex::encode(digest.finalize())) + tool_tree_digest(&self.root, &|| attempt.check()) } } diff --git a/crates/trusted-server-release/src/process/environment.rs b/crates/trusted-server-release/src/process/environment.rs index 90a6642f40..62af0b98d0 100644 --- a/crates/trusted-server-release/src/process/environment.rs +++ b/crates/trusted-server-release/src/process/environment.rs @@ -131,12 +131,54 @@ pub(super) fn is_credential(name: &OsStr) -> bool { || CREDENTIAL_EXACT.contains(&upper.as_str()) } +/// Exact names an orchestrator may set explicitly through +/// [`BoundedCommand::tool_selector_environment`] despite the injection denylist. +const TOOL_SELECTORS: [&str; 1] = ["RUSTC"]; + impl BoundedCommand { pub(super) fn insert_environment( + self, + name: &OsStr, + value: &OsStr, + secret: bool, + ) -> Result> { + self.insert_checked(name, value, secret, false) + } + + /// Adds one explicitly selected tool path, such as the compiler. + /// + /// The generic [`Self::environment`] keeps rejecting every tool selector, + /// because an inherited or attacker-chosen `RUSTC` swaps the compiler. + /// This method accepts only the names in [`TOOL_SELECTORS`], only with an + /// absolute path, and only once. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Process`] for any other name, a relative or + /// empty value, or a repeated name. + pub(crate) fn tool_selector_environment( + self, + name: impl AsRef, + value: impl AsRef, + ) -> Result> { + let (name, value) = (name.as_ref(), value.as_ref()); + let selectable = TOOL_SELECTORS + .iter() + .any(|selector| name == OsStr::new(selector)); + if !selectable || !value.as_bytes().starts_with(b"/") { + return Err(invalid(format!( + "{name:?} is not an explicitly selectable tool with an absolute path" + ))); + } + self.insert_checked(name, value, false, true) + } + + fn insert_checked( mut self, name: &OsStr, value: &OsStr, secret: bool, + tool_selector: bool, ) -> Result> { let name_bytes = name.as_bytes(); if name_bytes.is_empty() || name_bytes.contains(&b'=') || name_bytes.contains(&0) { @@ -149,7 +191,7 @@ impl BoundedCommand { "environment variable {name:?} value contains NUL" ))); } - if is_injection(name) { + if !tool_selector && is_injection(name) { return Err(invalid(format!( "environment variable {name:?} injects code or configuration" ))); @@ -169,3 +211,6 @@ impl BoundedCommand { Ok(self) } } + +#[cfg(test)] +mod selector_tests; diff --git a/crates/trusted-server-release/src/process/environment/selector_tests.rs b/crates/trusted-server-release/src/process/environment/selector_tests.rs new file mode 100644 index 0000000000..6f7c025bac --- /dev/null +++ b/crates/trusted-server-release/src/process/environment/selector_tests.rs @@ -0,0 +1,74 @@ +//! Pure tests of the explicit tool-selector allowance; no process is started. + +use std::ffi::{OsStr, OsString}; +use std::path::Path; +use std::time::Duration; + +use super::*; +use crate::process::ProcessLimits; + +fn fresh() -> BoundedCommand { + BoundedCommand::new( + Path::new("/example/tool"), + Path::new("/example"), + ProcessLimits { + wall: Duration::from_secs(1), + stdout_max: 1024, + stderr_max: 1024, + }, + ) + .expect("should describe a command") +} + +#[test] +fn generic_environment_still_rejects_every_tool_selector() { + for name in [ + "RUSTC", + "RUSTC_WRAPPER", + "RUSTFLAGS", + "RUSTDOC", + "CARGO_BUILD_RUSTC", + ] { + assert!( + fresh().environment(name, "/example/rustc").is_err(), + "should keep rejecting {name} through the generic environment" + ); + } +} + +#[test] +fn only_rustc_with_an_absolute_path_is_a_selectable_tool() { + let selected = fresh() + .tool_selector_environment("RUSTC", "/example/bin/rustc") + .expect("should accept an explicit absolute RUSTC"); + assert_eq!( + selected + .environment + .get(OsStr::new("RUSTC")) + .map(|entry| entry.value.clone()), + Some(OsString::from("/example/bin/rustc")), + "should carry the selected compiler" + ); + for (name, value) in [ + ("RUSTC_WRAPPER", "/example/wrapper"), + ("RUSTFLAGS", "/example/flags"), + ("RUSTDOC", "/example/rustdoc"), + ("CARGO_BUILD_RUSTC", "/example/rustc"), + ("LD_PRELOAD", "/example/lib.so"), + ("rustc", "/example/rustc"), + ("RUSTC", "relative/rustc"), + ("RUSTC", ""), + ] { + assert!( + fresh().tool_selector_environment(name, value).is_err(), + "should reject {name}={value:?} as a tool selector" + ); + } + assert!( + fresh() + .tool_selector_environment("RUSTC", "/example/bin/rustc") + .and_then(|command| command.tool_selector_environment("RUSTC", "/example/other")) + .is_err(), + "should reject setting RUSTC twice" + ); +} diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index 99a648fa28..2fecfd7986 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -1,35 +1,74 @@ //! Closed recipe layer for one reproducible Cargo build. //! -//! Slice 1 validates and binds every input a later Cargo execution needs, but -//! never starts a process: the execute gate rejects a mismatched boundary and -//! otherwise reports the closed production selection. +//! The recipe validates and binds every input a Cargo execution needs, derives +//! the exact plan in the build's own path namespace, and parses and copies the +//! result of a successful run. The execute gate stays closed: it checks the +//! boundary and plan, then reports the unqualified selection. The real +//! confined run is wired by a later slice. +//! +//! # Roots +//! +//! The checkout, the retained JS handoff and the prepared vendor materials are +//! mounted read-only. The tools are read-only. Two roots are writable: the +//! fresh `cargo_state` root (Cargo home, `HOME` and `TMPDIR`) and the Cargo +//! target directory. The output directory is not mounted at all: the host +//! copies the executable into it after the run. -use std::collections::BTreeMap; -use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::time::Duration; use error_stack::Report; +use sha2::{Digest as _, Sha256}; -use crate::boundary::{Boundary, BoundaryRoots, ConfinedCommand, DenialProof, Umask}; +use crate::boundary::{Boundary, BoundaryRoots, DenialProof}; +use crate::documents::Role as DocumentRole; use crate::error::ReleaseError; +use crate::inspect::inspect_wasm; use crate::javascript::JsBuildCompleted; -use crate::policy::{FIXED_ARTIFACTS, FixedArtifact}; +use crate::materials::PreparedCargoMaterials; +use crate::policy::{FIXED_ARTIFACTS, FixedArtifact, ReleaseTarget}; use crate::preflight::AuditedEnvironment; use crate::preflight::child::{ChildEnvironment, ChildRequest, child_environment}; use crate::process::ProcessLimits; use crate::source::{ApprovedSource, CommitId, FreshSource}; use fresh::{ - absent_in_real_parent, disjoint, empty_directory, normal_absolute, real_directory, real_file, + absent_in_real_parent, disjoint, empty_directory, normal_absolute, prepared_materials, + real_directory, real_file, }; mod ambient; +mod artifact; +mod cargo_output; +mod completed; mod fresh; +mod paths; +mod plan; +mod sources; +mod tools; pub(crate) use ambient::{AmbientProfile, CheckedAmbient}; +pub(crate) use completed::RecipeCompleted; +pub(crate) use paths::ExecutionPaths; +pub(crate) use plan::CargoPlan; +#[allow( + unused_imports, + reason = "the qualified selection has no production caller yet" +)] +pub(crate) use tools::{Authority, QualifiedRustTools, SelectedRustTools, ToolSelection}; + +use artifact::{EXECUTABLE_LIMIT, copy_artifact}; +use cargo_output::{ExpectedBinary, locate_host_binary}; +use completed::SuccessFacts; +use plan::{PlanInputs, check_child_state, plan_for}; /// The only released feature variant of every asset. const DEFAULT_VARIANT: &str = "default"; +/// Wall limit of one Cargo build. +const BUILD_WALL: Duration = Duration::from_secs(3600); +/// Captured output limit of each Cargo stream. +const BUILD_STREAM_MAX: u64 = 64 * 1024 * 1024; + /// Builds a recipe error carrying `message`. pub(crate) fn recipe_error(message: impl std::fmt::Display) -> Report { Report::new(ReleaseError::Recipe).attach(message.to_string()) @@ -132,9 +171,11 @@ pub(crate) struct RecipeInput<'a> { pub(crate) js_dir: &'a Path, /// Retained JS inventory file beside the bundle directory. pub(crate) js_inventory: &'a Path, - /// Fresh empty directory for prepared Cargo materials. + /// Prepared materials root holding exactly the vendor directory. pub(crate) materials: &'a Path, - /// Fresh empty directory receiving the built artifact. + /// Fresh empty directory for Cargo home, `HOME` and `TMPDIR`. + pub(crate) cargo_state: &'a Path, + /// Fresh empty directory receiving the built artifact; never mounted. pub(crate) output: &'a Path, /// Cargo target directory; must not exist yet. pub(crate) target_dir: &'a Path, @@ -146,6 +187,7 @@ fn check_roots(input: &RecipeInput<'_>) -> Result<(), Report> { ("JS bundle directory", input.js_dir), ("JS inventory", input.js_inventory), ("materials root", input.materials), + ("cargo state root", input.cargo_state), ("output root", input.output), ("target directory", input.target_dir), ]; @@ -163,10 +205,17 @@ fn check_roots(input: &RecipeInput<'_>) -> Result<(), Report> { "JS inventory must sit beside the JS bundle directory in the retained root", )); } - empty_directory(paths[2].0, input.materials)?; - empty_directory(paths[3].0, input.output)?; - absent_in_real_parent(paths[4].0, input.target_dir)?; - disjoint(&[("retained JS root", retained), paths[2], paths[3], paths[4]])?; + prepared_materials(paths[2].0, input.materials)?; + empty_directory(paths[3].0, input.cargo_state)?; + empty_directory(paths[4].0, input.output)?; + absent_in_real_parent(paths[5].0, input.target_dir)?; + disjoint(&[ + ("retained JS root", retained), + paths[2], + paths[3], + paths[4], + paths[5], + ])?; Ok(()) } @@ -179,6 +228,7 @@ pub(crate) struct RecipeRequest { js_dir: PathBuf, js_inventory: PathBuf, materials: PathBuf, + cargo_state: PathBuf, output: PathBuf, target_dir: PathBuf, } @@ -190,7 +240,8 @@ impl RecipeRequest { /// /// Returns [`ReleaseError::Recipe`] for an unknown asset or role, a /// non-default variant, a malformed commit, non-normal or aliased paths, a - /// reused root or target, or overlapping roots. + /// materials root that is not exactly a prepared vendor directory, a + /// reused state, output or target root, or overlapping roots. pub(crate) fn parse(input: &RecipeInput<'_>) -> Result> { check_variant(input.variant)?; let asset = AssetId::parse(input.asset)?; @@ -204,139 +255,91 @@ impl RecipeRequest { js_dir: input.js_dir.to_owned(), js_inventory: input.js_inventory.to_owned(), materials: input.materials.to_owned(), + cargo_state: input.cargo_state.to_owned(), output: input.output.to_owned(), target_dir: input.target_dir.to_owned(), }) } -} - -/// A Rust toolchain that passed Task 8 qualification. -/// -/// None has, so this type is uninhabited and production selection stays closed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum QualifiedRustTools {} -/// Fixed `bin/cargo` and `bin/rustc` layout, retained as a measured local candidate. -pub(crate) struct SelectedRustTools { - root: PathBuf, -} - -impl SelectedRustTools { - /// Accepts only a qualified selection; production construction stays closed. - pub(crate) fn qualified(_root: &Path, qualification: QualifiedRustTools) -> Self { - match qualification {} + pub(super) fn asset(&self) -> AssetId { + self.asset } - /// Selects ordinary CI tools without asserting release qualification. - /// - /// # Errors - /// - /// Returns [`ReleaseError::Recipe`] unless `bin/cargo` and `bin/rustc` - /// are regular files below the canonical `root`. - #[cfg(test)] - pub(crate) fn for_tests(root: &Path) -> Result> { - let root = std::fs::canonicalize(root) - .map_err(|error| recipe_error(format!("cannot resolve Rust tools: {error}")))?; - for relative in ["bin/cargo", "bin/rustc"] { - real_file("Rust tool", &root.join(relative))?; - } - Ok(Self { root }) + pub(super) fn js_dir(&self) -> &Path { + &self.js_dir } - fn cargo(&self) -> PathBuf { - self.root.join("bin/cargo") + pub(super) fn js_inventory(&self) -> &Path { + &self.js_inventory } -} -/// Environment variables the recipe adds on top of the allowlisted child environment. -const PREBUILT_DIR_VARIABLE: &str = "TSJS_PREBUILT_DIR"; -const PREBUILT_INVENTORY_VARIABLE: &str = "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY"; -const TARGET_DIR_VARIABLE: &str = "CARGO_TARGET_DIR"; -/// Child variable carrying the authenticated source commit. -const SOURCE_COMMIT_VARIABLE: &str = "TRUSTED_SERVER__GIT_VERSION"; + pub(super) fn target_dir(&self) -> &Path { + &self.target_dir + } -/// The exact Cargo invocation of one recipe; it cannot be started in slice 1. -#[derive(Debug, PartialEq, Eq)] -pub(crate) struct CargoPlan { - asset: AssetId, - program: PathBuf, - arguments: Vec, - environment: BTreeMap, - working_directory: PathBuf, - umask: Umask, -} + /// Private `HOME` below the writable cargo state root. + pub(super) fn home(&self) -> PathBuf { + self.cargo_state.join("home") + } -impl CargoPlan { - /// Describes the plan as a confined command running under the role's umask. - /// - /// # Errors - /// - /// Returns [`ReleaseError::Boundary`] for a non-normal program or working - /// directory, or [`ReleaseError::Process`] for a rejected variable. - pub(crate) fn confined_command( - &self, - limits: ProcessLimits, - ) -> Result> { - let mut command = ConfinedCommand::new(&self.program, &self.working_directory, limits)? - .arguments(self.arguments.iter().map(OsString::from)) - .umask(self.umask); - for (name, value) in &self.environment { - command = command.environment(name, value)?; - } - Ok(command) + /// Private Cargo home below the writable cargo state root. + pub(super) fn cargo_home(&self) -> PathBuf { + self.cargo_state.join("cargo-home") } -} -/// Returns the fixed argument vector of the release Cargo build. -fn cargo_arguments(asset: AssetId, jobs: u32) -> Vec { - let artifact = asset.artifact(); - [ - "build", - "--package", - artifact.package, - "--bin", - artifact.bin, - "--target", - artifact.target.triple(), - "--release", - "--frozen", - "--message-format=json-render-diagnostics", - "--jobs", - ] - .into_iter() - .map(str::to_owned) - .chain([jobs.to_string()]) - .collect() + /// Private `TMPDIR` below the writable cargo state root. + pub(super) fn tmpdir(&self) -> PathBuf { + self.cargo_state.join("tmp") + } } /// Derives the private locations a child build receives from the request. fn child_request(request: &RecipeRequest) -> ChildRequest { ChildRequest { target: request.asset.artifact().target, - home: request.materials.join("home"), - cargo_home: request.materials.clone(), + home: request.home(), + cargo_home: request.cargo_home(), tool_directories: Vec::new(), } } +/// JS handoff digests an attestation binds. +#[derive(Clone, Debug)] +struct JsFacts { + inventory_sha256: String, + settings_sha256: String, + tools_sha256: String, +} + /// Everything [`PreparedRecipe::bind`] cross-checks before a recipe may run. struct BindInputs<'a> { commit: &'a CommitId, child: ChildEnvironment, completed: &'a JsBuildCompleted, tools: SelectedRustTools, + materials: PreparedCargoMaterials, ambient: CheckedAmbient, request: RecipeRequest, } -/// A recipe whose source, JS handoff, tools, ambient and roots are all bound. +/// A recipe whose source, JS handoff, tools, materials, ambient and roots are bound. pub(crate) struct PreparedRecipe { request: RecipeRequest, tools: SelectedRustTools, + tools_digest: String, + materials: PreparedCargoMaterials, ambient: CheckedAmbient, child: ChildEnvironment, retained_root: PathBuf, - plan: CargoPlan, + js: JsFacts, +} + +/// What a successful confined run produced, as the run path reports it. +struct RunOutput { + stdout: Vec, + stderr_sha256: String, + started_at_seconds: u64, + elapsed: Duration, } impl PreparedRecipe { @@ -348,13 +351,19 @@ impl PreparedRecipe { /// # Errors /// /// Returns [`ReleaseError::Recipe`] when the fresh checkout is not the - /// approved commit or not the ambient checkout root, or as [`Self::bind`]. + /// approved commit or not the ambient checkout root, the prepared + /// materials were derived from another `Cargo.lock`, or as [`Self::bind`]. + #[allow( + clippy::too_many_arguments, + reason = "each argument is a distinct authority" + )] pub(crate) fn new( source: &ApprovedSource, fresh: &FreshSource, audited: &AuditedEnvironment, completed: &JsBuildCompleted, tools: SelectedRustTools, + materials: PreparedCargoMaterials, ambient: CheckedAmbient, request: RecipeRequest, ) -> Result> { @@ -366,105 +375,94 @@ impl PreparedRecipe { "fresh checkout is not the ambient checkout root", )); } + let lock = source + .read_tracked("Cargo.lock", DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if hex::encode(Sha256::digest(&lock)) != materials.cargo_lock_sha256() { + return Err(recipe_error( + "prepared materials were derived from another Cargo.lock", + )); + } let child = child_environment(audited, source, &child_request(&request))?; Self::bind(BindInputs { commit: source.commit(), child, completed, tools, + materials, ambient, request, }) } - /// Cross-checks every input and creates the fresh target directory. + /// Cross-checks every input and creates the fresh target and state directories. /// /// # Errors /// - /// Returns [`ReleaseError::Recipe`] when the request, JS handoff or child - /// environment names another commit, the JS handoff no longer rehashes or - /// is not the requested directory, the ambient role differs, a root is no - /// longer fresh, or the checkout, tools, retained JS, materials, output - /// and target roots overlap. + /// Returns [`ReleaseError::Recipe`] when the request, JS handoff, child + /// environment or materials name another commit, the JS handoff no longer + /// rehashes or is not the requested directory, the materials are not the + /// requested root or changed, the tools were selected for another target, + /// the ambient role differs, a root is no longer fresh, or the checkout, + /// tools, retained JS, materials, state and target roots overlap. fn bind(inputs: BindInputs<'_>) -> Result> { + check_commits(&inputs)?; + check_parts(&inputs)?; let BindInputs { - commit, child, completed, tools, + materials, ambient, request, + .. } = inputs; - if request.commit != *commit { - return Err(recipe_error("request names another source commit")); - } - if completed.source_commit() != commit.as_str() { - return Err(recipe_error( - "JS handoff was built from another source commit", - )); - } - if child - .variables() - .get(SOURCE_COMMIT_VARIABLE) - .map(String::as_str) - != Some(commit.as_str()) - { - return Err(recipe_error( - "child environment names another source commit", - )); - } - if ambient.role() != request.role { - return Err(recipe_error("ambient role differs from the request role")); - } - let retained = completed.rehash_for_handoff().map_err(|error| { - error - .change_context(ReleaseError::Recipe) - .attach("JS handoff no longer rehashes") - })?; - let retained_root = std::fs::canonicalize(retained) - .map_err(|error| recipe_error(format!("cannot resolve JS handoff: {error}")))?; - if request.js_dir != retained_root.join("bundles") - || request.js_inventory != retained_root.join("tsjs-inventory.json") - { - return Err(recipe_error("request does not name the JS handoff")); - } - check_fresh_and_disjoint(&request, &ambient, &tools, &retained_root)?; - let plan = plan_for(&request, &tools, &ambient, &child)?; - std::fs::create_dir(&request.target_dir) - .map_err(|error| recipe_error(format!("cannot create fresh target: {error}")))?; + let retained_root = resolve_js_handoff(completed, &request)?; + check_fresh_and_disjoint(&request, &ambient, &tools, &materials, &retained_root)?; + let tools_digest = tools.digest()?; + create_private_roots(&request)?; Ok(Self { + js: JsFacts { + inventory_sha256: completed.inventory_sha256().to_owned(), + settings_sha256: completed.settings_sha256().to_owned(), + tools_sha256: completed.tools_sha256().to_owned(), + }, request, tools, + tools_digest, + materials, ambient, child, retained_root, - plan, }) } - /// Returns the exact planned Cargo invocation. - pub(crate) fn plan(&self) -> &CargoPlan { - &self.plan - } - - /// Re-derives the plan from the bound inputs and compares it with the held one. + /// Derives the exact Cargo invocation in the namespace `paths` describes. /// /// # Errors /// - /// Returns [`ReleaseError::Recipe`] when the held plan's program, - /// arguments, environment, working directory or umask differ from the derivation. - pub(crate) fn check_plan(&self) -> Result<(), Report> { - let expected = plan_for(&self.request, &self.tools, &self.ambient, &self.child)?; - if self.plan != expected { - return Err(recipe_error( - "Cargo plan differs from the plan derived from the bound inputs", - )); - } - Ok(()) + /// Returns [`ReleaseError::Recipe`] when any path variable, the tool + /// directory or the vendor directory is outside the roots `paths` knows. + pub(crate) fn plan( + &self, + paths: &dyn ExecutionPaths, + ) -> Result> { + plan_for(&PlanInputs { + request: &self.request, + tools: &self.tools, + ambient: &self.ambient, + child: &self.child, + materials: &self.materials, + paths, + }) } /// Returns the boundary roles this recipe requires. /// + /// The checkout, retained JS and prepared materials are read-only. Cargo + /// home, `HOME`, `TMPDIR` and the target directory are the only writable + /// roots, and the output directory is not mounted. + /// /// # Errors /// /// Returns [`ReleaseError::Boundary`] for missing or overlapping roots. @@ -473,25 +471,51 @@ impl PreparedRecipe { &[ self.ambient.checkout_root().to_owned(), self.retained_root.clone(), + self.request.materials.clone(), ], - std::slice::from_ref(&self.tools.root), + std::slice::from_ref(&self.tools.root().to_owned()), &[ - self.request.output.clone(), + self.request.cargo_state.clone(), self.request.target_dir.clone(), - self.request.materials.clone(), ], ) } - /// Requires the exact planned roots and owned descendant cleanup. + /// Requires the tools to be the tree measured at binding. /// /// # Errors /// - /// Returns [`ReleaseError::Recipe`] when the plan was tampered with, the - /// boundary roots differ from [`Self::boundary_roots`], or the boundary - /// does not own descendant cleanup. + /// Returns [`ReleaseError::Recipe`] when anything under the tool root was + /// edited, linked, added or removed. + fn check_tools(&self) -> Result<(), Report> { + if self.tools.digest()? != self.tools_digest { + return Err(recipe_error("Rust tools changed after binding")); + } + Ok(()) + } + + /// Requires the prepared materials to be unchanged. + fn check_materials(&self) -> Result<(), Report> { + self.materials.rehash().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("prepared materials changed after binding") + }) + } + + /// Requires unchanged inputs, a derivable plan, the exact planned roots + /// and owned descendant cleanup. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the tools or materials changed, + /// the plan cannot be derived in the boundary's namespace, the boundary + /// roots differ from [`Self::boundary_roots`], or the boundary does not + /// own descendant cleanup. fn check_boundary(&self, boundary: &Boundary) -> Result<(), Report> { - self.check_plan()?; + self.check_tools()?; + self.check_materials()?; + self.plan(boundary)?; if !boundary.matches_roots(&self.boundary_roots()?) { return Err(recipe_error( "boundary roots differ from the exact recipe roots", @@ -505,24 +529,208 @@ impl PreparedRecipe { Ok(()) } - /// Checks every gate, then reports the closed production selection. + /// Checks every gate, then reports the closed selection. /// - /// No process is started in slice 1, even for a matching boundary. + /// The confined command is described but never started: the real run is + /// wired by a later slice, and no tool selection is qualified. /// /// # Errors /// - /// Returns [`ReleaseError::Recipe`] when the plan, roots or descendant - /// cleanup gate fails, then always [`ReleaseError::Unqualified`] because no - /// Rust toolchain or boundary profile is qualified. + /// Returns [`ReleaseError::Recipe`] when an input, plan, roots or + /// descendant cleanup gate fails, then always [`ReleaseError::Unqualified`]. pub(crate) fn execute( self, boundary: &Boundary, _proof: &DenialProof, ) -> Result<(), Report> { self.check_boundary(boundary)?; - Err(Report::new(ReleaseError::Unqualified) - .attach("Cargo execution is closed until Rust tools and boundary are qualified")) + self.plan(boundary)?.confined_command(ProcessLimits { + wall: BUILD_WALL, + stdout_max: BUILD_STREAM_MAX, + stderr_max: BUILD_STREAM_MAX, + })?; + let reason = match self.tools.authority() { + Authority::Qualified => "the confined Cargo run is not wired yet", + Authority::Unqualified => { + "Cargo execution is closed until Rust tools and boundary are qualified" + } + }; + Err(Report::new(ReleaseError::Unqualified).attach(reason)) + } + + /// Turns one successful run into a [`RecipeCompleted`]. + /// + /// Parses Cargo's stream, maps the executable back through a writable + /// root, copies it into the output directory and, for Wasm, requires the + /// debug sections a stripped module lacks. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the tools or materials changed, + /// the stream is not exactly one fresh debug-info binary, the executable + /// cannot be copied, or a Wasm artifact is stripped, or + /// [`ReleaseError::Inspect`] when the Wasm module is rejected. + fn complete( + self, + run: RunOutput, + boundary_fingerprint: [u8; 32], + paths: &dyn ExecutionPaths, + ) -> Result> { + self.check_tools()?; + self.check_materials()?; + let plan_digest = self.plan(paths)?.digest()?; + let artifact = self.request.asset.artifact(); + let manifest = paths.execution_path( + &self + .ambient + .checkout_root() + .join("crates") + .join(artifact.package) + .join("Cargo.toml"), + )?; + let release_dir = paths + .execution_path(&self.request.target_dir)? + .join(artifact.target.triple()) + .join("release"); + let located = locate_host_binary( + &run.stdout, + &ExpectedBinary { + bin: artifact.bin, + manifest: &manifest, + release_dir: &release_dir, + }, + paths, + )?; + let copied = copy_artifact( + &located.host, + &self.request.target_dir, + &self.request.output, + artifact.name, + EXECUTABLE_LIMIT, + )?; + if artifact.target == ReleaseTarget::Wasm32Wasip1 { + let bytes = std::fs::read(copied.path()).map_err(|error| { + recipe_error(format!("cannot read the copied artifact: {error}")) + })?; + if hex::encode(Sha256::digest(&bytes)) != copied.sha256() { + return Err(recipe_error("copied artifact changed before inspection")); + } + inspect_wasm(&bytes)?.require_debug_sections()?; + } + if self.tools.authority() == Authority::Qualified + && !self.materials.derived_manifest_digests().is_empty() + { + return Err(Report::new(ReleaseError::Unqualified) + .attach("a build from derived manifests cannot carry qualified authority")); + } + RecipeCompleted::record_success(SuccessFacts { + asset: self.request.asset, + role: self.request.role, + commit: self.request.commit.clone(), + cargo_lock_sha256: self.materials.cargo_lock_sha256().to_owned(), + materials_digest: self.materials.digest().to_owned(), + js_inventory_sha256: self.js.inventory_sha256.clone(), + js_settings_sha256: self.js.settings_sha256.clone(), + js_tools_sha256: self.js.tools_sha256.clone(), + rust_tools_digest: self.tools_digest.clone(), + boundary_fingerprint, + plan_digest, + ambient: self.ambient.clone(), + started_at_seconds: run.started_at_seconds, + elapsed: run.elapsed, + stdout: run.stdout, + stderr_sha256: run.stderr_sha256, + artifact: copied, + output_dir: self.request.output.clone(), + derived_manifest_digests: self.materials.derived_manifest_digests().clone(), + }) + } +} + +/// Child variable carrying the authenticated source commit. +const SOURCE_COMMIT_VARIABLE: &str = "TRUSTED_SERVER__GIT_VERSION"; + +/// Requires the request, JS handoff, child environment and materials to name +/// the approved commit. +fn check_commits(inputs: &BindInputs<'_>) -> Result<(), Report> { + let commit = inputs.commit; + if inputs.request.commit != *commit { + return Err(recipe_error("request names another source commit")); } + if inputs.completed.source_commit() != commit.as_str() { + return Err(recipe_error( + "JS handoff was built from another source commit", + )); + } + let child_commit = inputs + .child + .variables() + .get(SOURCE_COMMIT_VARIABLE) + .map(String::as_str); + if child_commit != Some(commit.as_str()) { + return Err(recipe_error( + "child environment names another source commit", + )); + } + if inputs.materials.source_commit() != commit.as_str() { + return Err(recipe_error( + "prepared materials were derived from another source commit", + )); + } + Ok(()) +} + +/// Requires the materials, tools, ambient role and child state to match the request. +fn check_parts(inputs: &BindInputs<'_>) -> Result<(), Report> { + let request = &inputs.request; + if inputs.materials.root().as_os_str() != request.materials.as_os_str() { + return Err(recipe_error("request does not name the prepared materials")); + } + inputs.materials.rehash().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("prepared materials no longer rehash") + })?; + if inputs.tools.target() != request.asset.artifact().target { + return Err(recipe_error( + "Rust tools were selected for another target than the asset", + )); + } + if inputs.ambient.role() != request.role { + return Err(recipe_error("ambient role differs from the request role")); + } + check_child_state(request, &inputs.child) +} + +/// Rehashes the JS handoff and requires the request to name its directories. +fn resolve_js_handoff( + completed: &JsBuildCompleted, + request: &RecipeRequest, +) -> Result> { + let retained = completed.rehash_for_handoff().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("JS handoff no longer rehashes") + })?; + let retained_root = std::fs::canonicalize(retained) + .map_err(|error| recipe_error(format!("cannot resolve JS handoff: {error}")))?; + if request.js_dir != retained_root.join("bundles") + || request.js_inventory != retained_root.join("tsjs-inventory.json") + { + return Err(recipe_error("request does not name the JS handoff")); + } + Ok(retained_root) +} + +/// Creates the fresh target directory and the private cargo state directories. +fn create_private_roots(request: &RecipeRequest) -> Result<(), Report> { + std::fs::create_dir(&request.target_dir) + .map_err(|error| recipe_error(format!("cannot create fresh target: {error}")))?; + for state in [request.home(), request.cargo_home(), request.tmpdir()] { + std::fs::create_dir(&state) + .map_err(|error| recipe_error(format!("cannot create cargo state: {error}")))?; + } + Ok(()) } /// Re-checks root freshness and disjointness immediately before the target is created. @@ -530,50 +738,25 @@ fn check_fresh_and_disjoint( request: &RecipeRequest, ambient: &CheckedAmbient, tools: &SelectedRustTools, + materials: &PreparedCargoMaterials, retained_root: &Path, ) -> Result<(), Report> { - empty_directory("materials root", &request.materials)?; + prepared_materials("materials root", &request.materials)?; + empty_directory("cargo state root", &request.cargo_state)?; empty_directory("output root", &request.output)?; absent_in_real_parent("target directory", &request.target_dir)?; let checkout = std::fs::canonicalize(ambient.checkout_root()) .map_err(|error| recipe_error(format!("cannot resolve checkout root: {error}")))?; disjoint(&[ ("checkout root", checkout.as_path()), - ("Rust tools root", tools.root.as_path()), + ("Rust tools root", tools.root()), ("retained JS root", retained_root), - ("materials root", request.materials.as_path()), + ("materials root", materials.root()), + ("cargo state root", request.cargo_state.as_path()), ("output root", request.output.as_path()), ("target directory", request.target_dir.as_path()), ]) } -/// Derives the exact Cargo invocation from the allowlisted child environment. -fn plan_for( - request: &RecipeRequest, - tools: &SelectedRustTools, - ambient: &CheckedAmbient, - child: &ChildEnvironment, -) -> Result> { - let mut environment = child.variables().clone(); - for (name, path) in [ - (TARGET_DIR_VARIABLE, &request.target_dir), - (PREBUILT_DIR_VARIABLE, &request.js_dir), - (PREBUILT_INVENTORY_VARIABLE, &request.js_inventory), - ] { - let text = path - .to_str() - .ok_or_else(|| recipe_error(format!("{name} is not UTF-8")))?; - environment.insert(name.to_owned(), text.to_owned()); - } - Ok(CargoPlan { - asset: request.asset, - program: tools.cargo(), - arguments: cargo_arguments(request.asset, ambient.jobs()), - environment, - working_directory: ambient.checkout_root().to_owned(), - umask: ambient.umask()?, - }) -} - #[cfg(test)] mod tests; diff --git a/crates/trusted-server-release/src/recipe/artifact.rs b/crates/trusted-server-release/src/recipe/artifact.rs new file mode 100644 index 0000000000..12e3c01b09 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/artifact.rs @@ -0,0 +1,292 @@ +//! Host-side copy of the built executable into the fresh output directory. +//! +//! The executable stays inside the writable build root, which a confined +//! child could still hold open or relink, so it is never published in place. +//! It is read once through a held descriptor and written into a new file +//! created exclusively and without following links, hashing exactly the bytes +//! written. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::recipe_error; +use crate::error::ReleaseError; + +/// Largest executable the recipe copies. +pub(crate) const EXECUTABLE_LIMIT: u64 = 512 * 1024 * 1024; +/// Mode of the published asset, independent of the process umask. +const ASSET_MODE: u32 = 0o755; +/// Longest accepted asset file name. +const NAME_LIMIT: usize = 160; + +/// The copied asset and the digest of exactly the bytes written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CopiedArtifact { + name: String, + size: u64, + sha256: String, + path: PathBuf, +} + +impl CopiedArtifact { + /// Asset file name inside the output directory. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Written size in bytes. + pub(crate) fn size(&self) -> u64 { + self.size + } + + /// Lowercase hex SHA-256 of the written bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Host path of the written file. + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +fn artifact_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("artifact copy: {detail}")) +} + +/// Requires a flat file name of letters, digits, `.`, `-` and `_` that does +/// not start with a dot. +fn check_name(name: &str) -> Result<(), Report> { + let first_ok = name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let body_ok = name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')); + if name.len() > NAME_LIMIT || !first_ok || !body_ok || name.contains("..") { + return Err(artifact_error(format!( + "output name {name:?} is not a safe flat name" + ))); + } + Ok(()) +} + +/// Requires an existing real directory that no symlink aliases. +fn check_output_directory(output_dir: &Path) -> Result<(), Report> { + let metadata = fs::symlink_metadata(output_dir).map_err(|error| { + artifact_error(format!("output directory cannot be inspected: {error}")) + })?; + let resolved = fs::canonicalize(output_dir) + .map_err(|error| artifact_error(format!("output directory cannot be resolved: {error}")))?; + if !metadata.is_dir() || resolved.as_os_str() != output_dir.as_os_str() { + return Err(artifact_error( + "output directory is not a real directory reached without symlinks", + )); + } + Ok(()) +} + +/// Requires every directory from the writable `root` down to the source's +/// parent to be a real directory, so no parent symlink can redirect the read. +/// +/// Dot segments and sources outside `root` are rejected. The path is +/// re-resolved at the end and must equal itself. The leaf is opened with +/// `O_NOFOLLOW` separately. A confined child no longer runs when this is +/// called, so only a concurrent host process could race the walk. +fn check_source_path(root: &Path, source: &Path) -> Result<(), Report> { + let relative = source + .strip_prefix(root) + .map_err(|_| artifact_error("source is outside the writable root"))?; + let normal = source.components().collect::().as_os_str() == source.as_os_str() + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))); + let parent = source + .parent() + .filter(|_| normal && source.is_absolute() && !relative.as_os_str().is_empty()) + .ok_or_else(|| artifact_error("source is not an absolute normal path below the root"))?; + let mut current = root.to_path_buf(); + let mut directories = vec![root.to_path_buf()]; + directories.extend( + parent + .strip_prefix(root) + .map_err(|_| artifact_error("source parent is outside the writable root"))? + .components() + .map(|component| { + current.push(component); + current.clone() + }), + ); + for directory in &directories { + let metadata = fs::symlink_metadata(directory).map_err(|error| { + artifact_error(format!("source directory cannot be inspected: {error}")) + })?; + if !metadata.is_dir() { + return Err(artifact_error(format!( + "source directory {} is a symlink or not a directory", + directory.display() + ))); + } + } + let resolved = fs::canonicalize(parent) + .map_err(|error| artifact_error(format!("source directory cannot be resolved: {error}")))?; + if resolved != parent || !resolved.starts_with(root) { + return Err(artifact_error( + "source directory resolves outside the writable root", + )); + } + Ok(()) +} + +/// Opens the source without following a leaf symlink and checks its facts. +fn open_source(source: &Path, limit: u64) -> Result<(File, fs::Metadata), Report> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(source) + .map_err(|error| artifact_error(format!("source cannot be opened: {error}")))?; + let metadata = file + .metadata() + .map_err(|error| artifact_error(format!("source cannot be inspected: {error}")))?; + if !metadata.is_file() { + return Err(artifact_error("source is not a regular file")); + } + // Cargo hard-links the executable into `deps/`; a third link is foreign. + if !(1..=2).contains(&metadata.nlink()) { + return Err(artifact_error(format!( + "source has {} hard links; at most the Cargo deps link is expected", + metadata.nlink() + ))); + } + if metadata.len() > limit { + return Err(artifact_error(format!( + "source exceeds the {limit} byte limit" + ))); + } + Ok((file, metadata)) +} + +/// Streams `source` into `destination`, returning the byte count and digest. +fn stream( + source: &File, + before: &fs::Metadata, + destination: &mut File, +) -> Result<(u64, String), Report> { + let mut input = source; + let mut digest = Sha256::new(); + let mut size = 0_u64; + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let count = input + .read(&mut buffer) + .map_err(|error| artifact_error(format!("source read failed: {error}")))?; + if count == 0 { + break; + } + size += count as u64; + if size > before.len() { + return Err(artifact_error("source grows while it is copied")); + } + digest.update(&buffer[..count]); + destination + .write_all(&buffer[..count]) + .map_err(|error| artifact_error(format!("output write failed: {error}")))?; + } + let after = source + .metadata() + .map_err(|error| artifact_error(format!("source cannot be re-inspected: {error}")))?; + let stable = ( + before.dev(), + before.ino(), + before.len(), + before.mtime(), + before.mtime_nsec(), + ) == ( + after.dev(), + after.ino(), + after.len(), + after.mtime(), + after.mtime_nsec(), + ); + if size != before.len() || !stable { + return Err(artifact_error("source changes while it is copied")); + } + Ok((size, hex::encode(digest.finalize()))) +} + +/// Copies the executable at `source`, below the writable `source_root`, to +/// `output_dir/name`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for an unsafe name, a source path outside +/// `source_root`, with dot segments or below a symlinked directory, an aliased output +/// directory, a source that is a symlink, not a regular file, has more than +/// two hard links, exceeds `limit` or changes while copied, or an output that +/// already exists (including as a symlink). A failed copy removes the file it +/// created. +pub(crate) fn copy_artifact( + source: &Path, + source_root: &Path, + output_dir: &Path, + name: &str, + limit: u64, +) -> Result> { + check_name(name)?; + check_output_directory(output_dir)?; + check_source_path(source_root, source)?; + let (input, before) = open_source(source, limit)?; + let path = output_dir.join(name); + let mut destination = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path) + .map_err(|error| { + artifact_error(format!( + "output {name} cannot be created exclusively: {error}" + )) + })?; + let written = stream(&input, &before, &mut destination) + .and_then(|(size, sha256)| finish(&destination, &path, size).map(|()| (size, sha256))); + match written { + Ok((size, sha256)) => Ok(CopiedArtifact { + name: name.to_owned(), + size, + sha256, + path, + }), + Err(error) => { + // The file was created exclusively above, so it is ours to remove. + drop(destination); + let _ = fs::remove_file(&path); + Err(error) + } + } +} + +/// Sets the final mode, syncs and verifies the written file. +fn finish(destination: &File, path: &Path, size: u64) -> Result<(), Report> { + destination + .set_permissions(fs::Permissions::from_mode(ASSET_MODE)) + .and_then(|()| destination.sync_all()) + .map_err(|error| artifact_error(format!("output cannot be finalized: {error}")))?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| artifact_error(format!("output cannot be re-inspected: {error}")))?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() != size { + return Err(artifact_error( + "output is not one regular file of the written size", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/artifact/tests.rs b/crates/trusted-server-release/src/recipe/artifact/tests.rs new file mode 100644 index 0000000000..c9ecd21d99 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/artifact/tests.rs @@ -0,0 +1,297 @@ +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink}; +use std::path::PathBuf; + +use sha2::{Digest as _, Sha256}; + +use super::*; + +const LIMIT: u64 = 1024; + +struct Layout { + _directory: tempfile::TempDir, + target: PathBuf, + output: PathBuf, +} + +fn layout() -> Layout { + let directory = tempfile::tempdir().expect("should create layout"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize layout"); + let target = base.join("target/release"); + let output = base.join("output"); + fs::create_dir_all(&target).expect("should create target release directory"); + fs::create_dir(&output).expect("should create output directory"); + fs::write(target.join("example-bin"), b"fictional executable").expect("should write source"); + Layout { + _directory: directory, + target, + output, + } +} + +impl Layout { + /// The writable root that holds `release/`. + fn root(&self) -> PathBuf { + self.target + .parent() + .expect("should have a writable root") + .to_path_buf() + } +} + +fn copy(layout: &Layout, name: &str) -> Result> { + copy_artifact( + &layout.target.join("example-bin"), + &layout.root(), + &layout.output, + name, + LIMIT, + ) +} + +fn rejection(layout: &Layout, name: &str) -> String { + match copy(layout, name) { + Ok(_) => panic!("should reject the copy"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn copy_records_name_size_and_sha256_of_the_written_bytes() { + let layout = layout(); + let copied = copy(&layout, "ts-example-asset").expect("should copy the executable"); + assert_eq!( + copied.name(), + "ts-example-asset", + "should keep the asset name" + ); + assert_eq!(copied.size(), 20, "should record the byte count"); + assert_eq!( + copied.sha256(), + hex::encode(Sha256::digest(b"fictional executable")), + "should hash the source bytes" + ); + let written = layout.output.join("ts-example-asset"); + assert_eq!( + fs::read(&written).expect("should read the output"), + b"fictional executable", + "should write identical bytes" + ); + let metadata = fs::symlink_metadata(&written).expect("should stat the output"); + assert!( + metadata.is_file() && metadata.nlink() == 1, + "should write one regular file" + ); + assert_eq!( + metadata.permissions().mode() & 0o7777, + 0o755, + "should set the mode independent of the umask" + ); + assert_eq!(copied.path(), written, "should report the output path"); +} + +#[test] +fn cargo_deps_hardlink_is_accepted_but_more_links_are_not() { + let layout = layout(); + fs::create_dir(layout.target.join("deps")).expect("should create deps"); + fs::hard_link( + layout.target.join("example-bin"), + layout.target.join("deps/example-bin-1234"), + ) + .expect("should link like Cargo does"); + copy(&layout, "ts-example-asset").expect("should accept the two-link Cargo layout"); + fs::hard_link( + layout.target.join("example-bin"), + layout.target.join("deps/extra"), + ) + .expect("should add a third link"); + assert!( + rejection(&layout, "ts-example-other").contains("link"), + "should reject a source with three links" + ); + assert!( + !layout.output.join("ts-example-other").exists(), + "should leave no output after a rejection" + ); +} + +#[test] +fn symlink_directory_and_oversize_sources_are_rejected_without_output() { + let layout = layout(); + fs::remove_file(layout.target.join("example-bin")).expect("should remove source"); + fs::write(layout.target.join("real"), b"x").expect("should write real file"); + symlink("real", layout.target.join("example-bin")).expect("should create source symlink"); + assert!( + rejection(&layout, "ts-example-asset").contains("source"), + "should reject a symlink source" + ); + fs::remove_file(layout.target.join("example-bin")).expect("should remove symlink"); + fs::create_dir(layout.target.join("example-bin")).expect("should create directory source"); + assert!( + rejection(&layout, "ts-example-asset").contains("source"), + "should reject a directory source" + ); + fs::remove_dir(layout.target.join("example-bin")).expect("should remove directory"); + fs::write(layout.target.join("example-bin"), vec![0_u8; 1025]).expect("should write oversize"); + assert!( + rejection(&layout, "ts-example-asset").contains("limit"), + "should reject an oversize source" + ); + assert_eq!( + fs::read_dir(&layout.output) + .expect("should list output") + .count(), + 0, + "should leave the output directory empty after every rejection" + ); +} + +#[test] +fn existing_or_aliased_output_is_never_overwritten() { + let layout = layout(); + fs::write(layout.output.join("ts-example-asset"), b"previous").expect("should write existing"); + assert!( + rejection(&layout, "ts-example-asset").contains("output"), + "should reject an existing file" + ); + assert_eq!( + fs::read(layout.output.join("ts-example-asset")).expect("should read existing"), + b"previous", + "should leave the existing file untouched" + ); + let victim = layout.target.join("victim"); + fs::write(&victim, b"victim").expect("should write victim"); + symlink(&victim, layout.output.join("ts-example-link")).expect("should plant symlink"); + assert!( + rejection(&layout, "ts-example-link").contains("output"), + "should reject a planted symlink" + ); + assert_eq!( + fs::read(&victim).expect("should read victim"), + b"victim", + "should not write through the link" + ); + symlink("missing", layout.output.join("ts-example-dangling")) + .expect("should plant dangling link"); + assert!( + rejection(&layout, "ts-example-dangling").contains("output"), + "should reject a dangling link" + ); + fs::create_dir(layout.output.join("ts-example-dir")).expect("should plant directory"); + assert!( + rejection(&layout, "ts-example-dir").contains("output"), + "should reject a directory" + ); +} + +#[test] +fn unsafe_names_and_aliased_output_directories_are_rejected() { + let layout = layout(); + for name in [ + "", + ".", + "..", + "a/b", + "../escape", + "/abs", + "has space", + "nul\0byte", + ".hidden", + ] { + assert!( + copy(&layout, name).is_err(), + "should reject asset file name {name:?}" + ); + } + let alias = layout + .output + .parent() + .expect("should have parent") + .join("alias"); + symlink(&layout.output, &alias).expect("should alias the output directory"); + let error = copy_artifact( + &layout.target.join("example-bin"), + &layout.root(), + &alias, + "ts-example-asset", + LIMIT, + ) + .expect_err("should reject a symlinked output directory"); + assert!( + format!("{error:?}").contains("output"), + "should name the output directory" + ); + assert!( + fs::read_dir(&layout.output) + .expect("should list output") + .next() + .is_none(), + "should write nothing through the alias" + ); +} + +#[test] +fn symlinked_source_directories_below_the_writable_root_are_rejected() { + let layout = layout(); + let root = layout.root(); + let real = root + .parent() + .expect("should have parent") + .join("real-release"); + fs::rename(&layout.target, &real).expect("should move the release directory"); + symlink(&real, &layout.target).expect("should alias the release directory"); + assert!( + rejection(&layout, "ts-example-asset").contains("source directory"), + "should reject a symlinked release directory" + ); + + let layout = self::layout(); + let root = layout.root(); + let triple = root.join("example-triple"); + let outside = root + .parent() + .expect("should have parent") + .join("outside-triple"); + fs::create_dir_all(outside.join("release")).expect("should create the outside tree"); + fs::write(outside.join("release/example-bin"), b"outside executable") + .expect("should write the outside executable"); + symlink(&outside, &triple).expect("should alias the triple directory"); + let error = copy_artifact( + &triple.join("release/example-bin"), + &root, + &layout.output, + "ts-example-asset", + LIMIT, + ) + .expect_err("should reject a symlinked triple directory"); + assert!( + format!("{error:?}").contains("source directory"), + "should name the aliased source directory" + ); + assert!( + fs::read_dir(&layout.output) + .expect("should list output") + .next() + .is_none(), + "should copy nothing through the alias" + ); +} + +#[test] +fn source_outside_the_writable_root_or_with_dot_segments_is_rejected() { + let layout = layout(); + let root = layout.root(); + for source in [ + layout.output.join("example-bin"), + root.join("release/../release/example-bin"), + root.join("release/./example-bin"), + PathBuf::from("relative/example-bin"), + ] { + let error = copy_artifact(&source, &root, &layout.output, "ts-example-asset", LIMIT) + .expect_err("should reject the source path"); + assert!( + format!("{error:?}").contains("source"), + "should reject {source:?} as a source path" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/cargo_output.rs b/crates/trusted-server-release/src/recipe/cargo_output.rs new file mode 100644 index 0000000000..6fc845c311 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/cargo_output.rs @@ -0,0 +1,215 @@ +//! Strict parser for Cargo's `--message-format=json` build stream. +//! +//! Cargo prints one JSON object per line. The recipe accepts the stream only +//! when every line is a strict, bounded JSON object, the stream ends in one +//! successful `build-finished`, and exactly one freshly compiled, debug-info +//! carrying binary artifact of the requested package and binary names was +//! produced, whose executable is a direct child of the target's release +//! directory. The parser never opens a file: it returns where Cargo says the +//! executable is, and the caller still re-reads and hashes those bytes. + +use std::path::{Component, Path, PathBuf}; + +use error_stack::Report; +use serde_json::Value; + +use super::fresh::normal_absolute; +use super::paths::ExecutionPaths; +use super::recipe_error; +use crate::documents::{Role, parse_json}; +use crate::error::ReleaseError; + +/// `profile.debuginfo` the release profile must report (limited debug info). +const REQUIRED_DEBUGINFO: u64 = 1; + +/// What the single binary artifact must be, in the execution namespace. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ExpectedBinary<'a> { + /// Cargo binary target name. + pub(crate) bin: &'a str, + /// Checkout path of the package manifest, as the child sees it. + pub(crate) manifest: &'a Path, + /// The `//release` directory, as the child sees it. + pub(crate) release_dir: &'a Path, +} + +/// The located executable in both namespaces. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocatedBinary { + /// Executable path as the confined child reported it. + pub(crate) execution: PathBuf, + /// The same file on the host, inside a writable root. + pub(crate) host: PathBuf, +} + +fn output_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("cargo output: {detail}")) +} + +/// Splits `stdout` into strict JSON objects, one per newline-terminated line. +fn messages(stdout: &[u8]) -> Result, Report> { + if stdout.is_empty() { + return Ok(Vec::new()); + } + let body = stdout + .strip_suffix(b"\n") + .ok_or_else(|| output_error("final line is not newline-terminated"))?; + body.split(|byte| *byte == b'\n') + .enumerate() + .map(|(index, line)| { + let value = parse_json(line, Role::CargoMessage).map_err(|error| { + error.change_context(ReleaseError::Recipe).attach(format!( + "cargo output line {} is not strict JSON", + index + 1 + )) + })?; + let has_reason = value + .as_object() + .is_some_and(|object| object.get("reason").is_some_and(Value::is_string)); + if !has_reason { + return Err(output_error(format!( + "line {} is not an object with a string reason", + index + 1 + ))); + } + Ok(value) + }) + .collect() +} + +/// Requires the last message to be the only successful `build-finished`. +fn require_success(messages: &[Value]) -> Result<(), Report> { + let reason = |message: &Value| message["reason"].as_str().map(str::to_owned); + let finished = messages + .iter() + .filter(|message| reason(message).as_deref() == Some("build-finished")) + .count(); + let last = messages.last(); + let ends_finished = + last.is_some_and(|message| reason(message).as_deref() == Some("build-finished")); + if finished != 1 || !ends_finished { + return Err(output_error( + "stream must end with exactly one build-finished message", + )); + } + let success = last + .and_then(|message| message.get("success")) + .and_then(Value::as_bool); + if success != Some(true) { + return Err(output_error("build-finished did not report success true")); + } + Ok(()) +} + +fn is_artifact(message: &Value) -> bool { + message["reason"].as_str() == Some("compiler-artifact") +} + +fn kinds(message: &Value) -> Option> { + message["target"]["kind"] + .as_array()? + .iter() + .map(Value::as_str) + .collect() +} + +fn is_requested(message: &Value, expected: &ExpectedBinary<'_>) -> bool { + kinds(message).as_deref() == Some(&["bin"][..]) + && message["target"]["name"].as_str() == Some(expected.bin) + && message["manifest_path"].as_str().map(Path::new) == Some(expected.manifest) +} + +/// Validates the one requested artifact and returns its executable path. +fn executable_of( + message: &Value, + expected: &ExpectedBinary<'_>, +) -> Result> { + if message["fresh"].as_bool() != Some(false) { + return Err(output_error( + "binary artifact is not reported fresh false; a cached artifact proves nothing", + )); + } + if message["profile"]["debuginfo"].as_u64() != Some(REQUIRED_DEBUGINFO) { + return Err(output_error(format!( + "binary artifact profile debuginfo is not {REQUIRED_DEBUGINFO}" + ))); + } + let text = message["executable"] + .as_str() + .ok_or_else(|| output_error("binary artifact has no executable path"))?; + let executable = PathBuf::from(text); + normal_absolute("cargo executable", &executable) + .map_err(|_| output_error("executable path is not an absolute normal path"))?; + let relative = executable + .strip_prefix(expected.release_dir) + .map_err(|_| output_error("executable is outside the release directory"))?; + let mut parts = relative.components(); + match (parts.next(), parts.next()) { + (Some(Component::Normal(_)), None) => Ok(executable), + _ => Err(output_error( + "executable is not a direct child of the release directory", + )), + } +} + +/// Parses the build stream and returns the container path of the executable. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for a non-strict or oversize line, a +/// stream not ending in exactly one successful `build-finished`, zero or more +/// than one artifact with `target.kind == ["bin"]`, the requested name and +/// manifest, an additional binary artifact, a fresh or unreported-freshness +/// artifact, `profile.debuginfo != 1`, or an executable that is missing, +/// relative, dotted, outside or nested below the release directory. +pub(crate) fn locate_binary( + stdout: &[u8], + expected: &ExpectedBinary<'_>, +) -> Result> { + let messages = messages(stdout)?; + require_success(&messages)?; + let artifacts: Vec<&Value> = messages + .iter() + .filter(|message| is_artifact(message)) + .collect(); + let matching: Vec<&&Value> = artifacts + .iter() + .filter(|message| is_requested(message, expected)) + .collect(); + let [requested] = matching.as_slice() else { + return Err(output_error(format!( + "expected exactly one binary artifact for {}, found {}", + expected.bin, + matching.len() + ))); + }; + if artifacts.iter().any(|message| { + !std::ptr::eq(*message, **requested) + && kinds(message).is_none_or(|kinds| kinds.contains(&"bin")) + }) { + return Err(output_error("unexpected additional binary artifact")); + } + executable_of(requested, expected) +} + +/// Parses the build stream and maps the executable into a writable host root. +/// +/// # Errors +/// +/// As [`locate_binary`], or [`ReleaseError::Recipe`] when the executable does +/// not map back through a writable root. +pub(crate) fn locate_host_binary( + stdout: &[u8], + expected: &ExpectedBinary<'_>, + paths: &dyn ExecutionPaths, +) -> Result> { + let execution = locate_binary(stdout, expected)?; + let host = paths + .host_path(&execution) + .map_err(|error| error.change_context(ReleaseError::Recipe)) + .map_err(|error| error.attach("executable is outside every writable root"))?; + Ok(LocatedBinary { execution, host }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/recipe/cargo_output/tests.rs b/crates/trusted-server-release/src/recipe/cargo_output/tests.rs new file mode 100644 index 0000000000..99d63bc60c --- /dev/null +++ b/crates/trusted-server-release/src/recipe/cargo_output/tests.rs @@ -0,0 +1,312 @@ +use std::path::Path; + +use serde_json::{Value, json}; + +use super::*; +use crate::recipe::paths::PrefixPaths; + +const MANIFEST: &str = "/ts/immutable/0/crates/example-adapter/Cargo.toml"; +const RELEASE: &str = "/ts/writable/1/wasm32-example/release"; + +/// A message edit applied before serialization. +type Edit = fn(&mut Value); + +fn expected() -> ExpectedBinary<'static> { + ExpectedBinary { + bin: "example-bin", + manifest: Path::new(MANIFEST), + release_dir: Path::new(RELEASE), + } +} + +fn artifact() -> Value { + json!({ + "reason": "compiler-artifact", + "package_id": "path+file:///example#0.1.0", + "manifest_path": MANIFEST, + "target": {"kind": ["bin"], "crate_types": ["bin"], "name": "example-bin"}, + "profile": {"opt_level": "3", "debuginfo": 1, "debug_assertions": false}, + "filenames": [format!("{RELEASE}/example-bin.wasm")], + "executable": format!("{RELEASE}/example-bin.wasm"), + "fresh": false + }) +} + +fn build_script() -> Value { + json!({ + "reason": "compiler-artifact", + "manifest_path": MANIFEST, + "target": {"kind": ["custom-build"], "crate_types": ["bin"], "name": "build-script-build"}, + "profile": {"debuginfo": 0}, + "executable": null, + "fresh": false + }) +} + +fn finished(success: &Value) -> Value { + json!({"reason": "build-finished", "success": success}) +} + +fn lines(messages: &[Value]) -> Vec { + let mut out = Vec::new(); + for message in messages { + out.extend_from_slice(message.to_string().as_bytes()); + out.push(b'\n'); + } + out +} + +fn rejection(stdout: &[u8]) -> String { + match locate_binary(stdout, &expected()) { + Ok(path) => panic!("should reject the output, got {path:?}"), + Err(error) => format!("{error:?}"), + } +} + +fn modified(edit: impl FnOnce(&mut Value)) -> Vec { + let mut message = artifact(); + edit(&mut message); + lines(&[build_script(), message, finished(&json!(true))]) +} + +#[test] +fn single_fresh_false_debuginfo_one_artifact_is_located() { + let stdout = lines(&[ + build_script(), + json!({"reason": "build-script-executed", "linked_libs": [], "env": []}), + artifact(), + finished(&json!(true)), + ]); + assert_eq!( + locate_binary(&stdout, &expected()).expect("should locate the executable"), + Path::new("/ts/writable/1/wasm32-example/release/example-bin.wasm"), + "should return the container executable path" + ); +} + +#[test] +fn build_must_end_with_successful_build_finished() { + let cases: [(&str, Vec); 6] = [ + ("missing", lines(&[build_script(), artifact()])), + ("empty", Vec::new()), + ("failed", lines(&[artifact(), finished(&json!(false))])), + ( + "non-boolean success", + lines(&[artifact(), finished(&json!("true"))]), + ), + ("not last", lines(&[finished(&json!(true)), artifact()])), + ( + "twice", + lines(&[artifact(), finished(&json!(true)), finished(&json!(true))]), + ), + ]; + for (label, stdout) in cases { + assert!( + rejection(&stdout).contains("build-finished"), + "should reject a {label} build-finished" + ); + } + assert!( + rejection(&lines(&[artifact(), json!({"reason": "build-finished"})])) + .contains("build-finished"), + "should reject build-finished without success" + ); +} + +#[test] +fn zero_or_duplicate_matching_artifacts_are_rejected() { + let none = lines(&[build_script(), finished(&json!(true))]); + assert!( + rejection(&none).contains("exactly one"), + "should reject zero binary artifacts" + ); + let twice = lines(&[artifact(), artifact(), finished(&json!(true))]); + assert!( + rejection(&twice).contains("exactly one"), + "should reject two binary artifacts" + ); + let mut stray = artifact(); + stray["target"]["name"] = json!("other-bin"); + stray["executable"] = json!(format!("{RELEASE}/other-bin.wasm")); + let extra = lines(&[artifact(), stray, finished(&json!(true))]); + assert!( + rejection(&extra).contains("unexpected"), + "should reject an additional binary artifact" + ); +} + +#[test] +fn wrong_kind_name_or_manifest_is_rejected() { + let edits: [(&str, Edit); 6] = [ + ("lib kind", |m| m["target"]["kind"] = json!(["lib"])), + ("bin and lib kind", |m| { + m["target"]["kind"] = json!(["bin", "lib"]) + }), + ("string kind", |m| m["target"]["kind"] = json!("bin")), + ("other name", |m| m["target"]["name"] = json!("other-bin")), + ("other manifest", |m| { + m["manifest_path"] = json!("/ts/immutable/0/crates/other/Cargo.toml"); + }), + ("relative manifest", |m| { + m["manifest_path"] = json!("crates/example-adapter/Cargo.toml"); + }), + ]; + for (label, edit) in edits { + assert!( + rejection(&modified(edit)).contains("exactly one"), + "should reject an artifact with {label}" + ); + } +} + +#[test] +fn fresh_or_unmeasured_artifact_is_rejected() { + let edits: [(&str, Edit); 4] = [ + ("fresh true", |m| m["fresh"] = json!(true)), + ("fresh missing", |m| { + m.as_object_mut().expect("should be object").remove("fresh"); + }), + ("fresh string", |m| m["fresh"] = json!("false")), + ("fresh null", |m| m["fresh"] = Value::Null), + ]; + for (label, edit) in edits { + assert!( + rejection(&modified(edit)).contains("fresh"), + "should reject an artifact with {label}" + ); + } +} + +#[test] +fn debuginfo_must_be_exactly_one() { + let edits: [(&str, Edit); 6] = [ + ("zero", |m| m["profile"]["debuginfo"] = json!(0)), + ("two", |m| m["profile"]["debuginfo"] = json!(2)), + ("string one", |m| m["profile"]["debuginfo"] = json!("1")), + ("float one", |m| m["profile"]["debuginfo"] = json!(1.5)), + ("missing", |m| { + m["profile"] + .as_object_mut() + .expect("should be object") + .remove("debuginfo"); + }), + ("no profile", |m| { + m.as_object_mut() + .expect("should be object") + .remove("profile"); + }), + ]; + for (label, edit) in edits { + assert!( + rejection(&modified(edit)).contains("debuginfo"), + "should reject an artifact with {label} debuginfo" + ); + } +} + +#[test] +fn executable_must_be_a_direct_child_of_the_release_directory() { + let cases: [(&str, Value); 9] = [ + ("null", Value::Null), + ("number", json!(7)), + ("relative", json!("release/example-bin.wasm")), + ("outside", json!("/ts/immutable/0/example-bin.wasm")), + ( + "sibling prefix", + json!("/ts/writable/1/wasm32-example/release-evil/x"), + ), + ("nested", json!(format!("{RELEASE}/deps/example-bin.wasm"))), + ("dotdot", json!(format!("{RELEASE}/../example-bin.wasm"))), + ("directory itself", json!(RELEASE)), + ( + "trailing slash", + json!(format!("{RELEASE}/example-bin.wasm/")), + ), + ]; + for (label, value) in cases { + let stdout = modified(|m| m["executable"] = value); + assert!( + rejection(&stdout).contains("executable"), + "should reject a {label} executable" + ); + } +} + +#[test] +fn malformed_lines_are_rejected() { + let good = lines(&[artifact(), finished(&json!(true))]); + let mut oversize = br#"{"reason":"build-script-executed","pad":""#.to_vec(); + oversize.resize(oversize.len() + 1024 * 1024, b'a'); + oversize.extend_from_slice(b"\"}\n"); + oversize.extend_from_slice(&good); + let cases: [(&str, Vec); 9] = [ + ( + "non-JSON", + [b"Compiling example\n".as_slice(), &good].concat(), + ), + ("blank line", [b"\n".as_slice(), &good].concat()), + ( + "duplicate keys", + [ + br#"{"reason":"build-script-executed","reason":"compiler-artifact"}"#.as_slice(), + b"\n", + &good, + ] + .concat(), + ), + ("array line", [b"[]\n".as_slice(), &good].concat()), + ("null line", [b"null\n".as_slice(), &good].concat()), + ( + "missing reason", + [b"{\"x\":1}\n".as_slice(), &good].concat(), + ), + ("oversize", oversize), + ( + "invalid UTF-8", + [b"{\"reason\":\"\xff\"}\n".as_slice(), &good].concat(), + ), + ("unterminated final line", good[..good.len() - 1].to_vec()), + ]; + for (label, stdout) in cases { + let message = rejection(&stdout); + assert!( + message.contains("line") || message.contains("Document"), + "should reject {label} output: {message}" + ); + } +} + +#[test] +fn host_path_maps_only_through_writable_roots() { + let host = Path::new("/host/target"); + let paths = PrefixPaths::new() + .with(Path::new("/host/checkout"), "/ts/immutable/0", false) + .with(host, "/ts/writable/1", true); + let located = locate_host_binary( + &lines(&[artifact(), finished(&json!(true))]), + &expected(), + &paths, + ) + .expect("should locate and map the executable"); + assert_eq!( + located.execution, + Path::new("/ts/writable/1/wasm32-example/release/example-bin.wasm"), + "should keep the container path" + ); + assert_eq!( + located.host, + Path::new("/host/target/wasm32-example/release/example-bin.wasm"), + "should map back into the writable host root" + ); + let immutable_only = + PrefixPaths::new().with(Path::new("/host/target"), "/ts/writable/1", false); + assert!( + locate_host_binary( + &lines(&[artifact(), finished(&json!(true))]), + &expected(), + &immutable_only + ) + .is_err(), + "should reject an executable that maps only through a non-writable root" + ); +} diff --git a/crates/trusted-server-release/src/recipe/completed.rs b/crates/trusted-server-release/src/recipe/completed.rs new file mode 100644 index 0000000000..7281c5fbea --- /dev/null +++ b/crates/trusted-server-release/src/recipe/completed.rs @@ -0,0 +1,223 @@ +//! The proof a recipe build ran to completion, with its copied artifact. +//! +//! [`RecipeCompleted`] has private fields and no public constructor: +//! [`RecipeCompleted::record_success`] is called only from the recipe's +//! successful-run path, after Cargo's output was parsed and the artifact was +//! copied and inspected. It binds everything an attestation needs and can +//! re-measure the retained output before handoff. + +use std::fs; +use std::io::Write as _; +use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::artifact::{CopiedArtifact, EXECUTABLE_LIMIT}; +use super::{AssetId, CheckedAmbient, Role, recipe_error}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::source::CommitId; + +/// File name of the retained Cargo message stream. +const STDOUT_FILE: &str = "cargo-stdout.jsonl"; + +/// Facts about one successful run, supplied by the recipe's run path. +pub(super) struct SuccessFacts { + pub(super) asset: AssetId, + pub(super) role: Role, + pub(super) commit: CommitId, + pub(super) cargo_lock_sha256: String, + pub(super) materials_digest: String, + pub(super) js_inventory_sha256: String, + pub(super) js_settings_sha256: String, + pub(super) js_tools_sha256: String, + pub(super) rust_tools_digest: String, + pub(super) boundary_fingerprint: [u8; 32], + pub(super) plan_digest: String, + pub(super) ambient: CheckedAmbient, + pub(super) started_at_seconds: u64, + pub(super) elapsed: Duration, + pub(super) stdout: Vec, + pub(super) stderr_sha256: String, + pub(super) artifact: CopiedArtifact, + pub(super) output_dir: PathBuf, + pub(super) derived_manifest_digests: std::collections::BTreeMap, +} + +/// A built, copied and bound artifact. +pub(crate) struct RecipeCompleted { + facts: SuccessFacts, + stdout_sha256: String, + retained: tempfile::TempDir, +} + +impl RecipeCompleted { + /// Records one successful run and retains its Cargo message stream. + /// + /// Only the recipe's successful-run path calls this. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the stream cannot be retained. + pub(super) fn record_success(facts: SuccessFacts) -> Result> { + let retained = tempfile::tempdir() + .map_err(|error| recipe_error(format!("cannot retain Cargo output: {error}")))?; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(retained.path().join(STDOUT_FILE)) + .map_err(|error| recipe_error(format!("cannot retain Cargo output: {error}")))?; + file.write_all(&facts.stdout) + .and_then(|()| file.sync_all()) + .map_err(|error| recipe_error(format!("cannot retain Cargo output: {error}")))?; + let stdout_sha256 = hex::encode(Sha256::digest(&facts.stdout)); + Ok(Self { + facts, + stdout_sha256, + retained, + }) + } + + /// The asset that was built. + pub(crate) fn asset(&self) -> AssetId { + self.facts.asset + } + + /// The role of this build. + pub(crate) fn role(&self) -> Role { + self.facts.role + } + + /// The approved source commit that was built. + pub(crate) fn commit(&self) -> &CommitId { + &self.facts.commit + } + + /// SHA-256 of the approved raw `Cargo.lock`. + pub(crate) fn cargo_lock_sha256(&self) -> &str { + &self.facts.cargo_lock_sha256 + } + + /// Digest of the vendored dependency tree. + pub(crate) fn materials_digest(&self) -> &str { + &self.facts.materials_digest + } + + /// Digests of normalized Git manifests the build consumed, by vendor directory. + /// + /// These manifests are derived and unauthenticated: only their original + /// was matched with the verified tree. A non-empty set marks the build as + /// built from derived inputs, so it is unqualified, and a qualified + /// authority must refuse to attest it. + pub(crate) fn derived_manifest_digests(&self) -> &std::collections::BTreeMap { + &self.facts.derived_manifest_digests + } + + /// The JS handoff digests: inventory, settings and tools, in that order. + pub(crate) fn js_digests(&self) -> [&str; 3] { + [ + &self.facts.js_inventory_sha256, + &self.facts.js_settings_sha256, + &self.facts.js_tools_sha256, + ] + } + + /// Digest of the Rust tool tree measured at binding. + pub(crate) fn rust_tools_digest(&self) -> &str { + &self.facts.rust_tools_digest + } + + /// Execution fingerprint of the boundary instance that ran the build. + pub(crate) fn boundary_fingerprint(&self) -> [u8; 32] { + self.facts.boundary_fingerprint + } + + /// Digest of the exact Cargo plan that ran. + pub(crate) fn plan_digest(&self) -> &str { + &self.facts.plan_digest + } + + /// The ambient conditions the build ran under. + pub(crate) fn ambient(&self) -> &CheckedAmbient { + &self.facts.ambient + } + + /// Unix start time of the build in seconds. + pub(crate) fn started_at_seconds(&self) -> u64 { + self.facts.started_at_seconds + } + + /// Wall time the build took. + pub(crate) fn elapsed(&self) -> Duration { + self.facts.elapsed + } + + /// SHA-256 of Cargo's message stream. + pub(crate) fn stdout_sha256(&self) -> &str { + &self.stdout_sha256 + } + + /// SHA-256 of the captured diagnostics stream. + pub(crate) fn stderr_sha256(&self) -> &str { + &self.facts.stderr_sha256 + } + + /// The copied artifact. + pub(crate) fn artifact(&self) -> &CopiedArtifact { + &self.facts.artifact + } + + /// Re-measures the output directory and retained stream before handoff. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the output directory holds + /// anything but the artifact, the artifact is not one regular file with + /// one link and the recorded size and SHA-256, or the retained Cargo + /// stream no longer matches its digest. + pub(crate) fn rehash_for_handoff(&self) -> Result<&Path, Report> { + let artifact = &self.facts.artifact; + let names = fs::read_dir(&self.facts.output_dir) + .map_err(|error| recipe_error(format!("output cannot be listed: {error}")))? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(|error| recipe_error(format!("output cannot be listed: {error}")))?; + if names != [std::ffi::OsString::from(artifact.name())] { + return Err(recipe_error( + "output directory holds more than the artifact", + )); + } + let metadata = fs::symlink_metadata(artifact.path()) + .map_err(|error| recipe_error(format!("artifact cannot be inspected: {error}")))?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(recipe_error("artifact is not one regular unlinked file")); + } + let measured = hash_bounded_with_check(artifact.path(), EXECUTABLE_LIMIT, &|| Ok(())) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if measured.size != artifact.size() || measured.sha256 != artifact.sha256() { + return Err(recipe_error("artifact bytes changed after the copy")); + } + let stream = hash_bounded_with_check( + &self.retained.path().join(STDOUT_FILE), + 64 * 1024 * 1024, + &|| Ok(()), + ) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + if stream.sha256 != self.stdout_sha256 { + return Err(recipe_error("retained Cargo output changed")); + } + Ok(&self.facts.output_dir) + } +} + +#[cfg(test)] +impl RecipeCompleted { + /// Path of the retained Cargo message stream, for tamper tests. + pub(crate) fn retained_stdout_path(&self) -> PathBuf { + self.retained.path().join(STDOUT_FILE) + } +} diff --git a/crates/trusted-server-release/src/recipe/fresh.rs b/crates/trusted-server-release/src/recipe/fresh.rs index 636698d6fc..ccab97af43 100644 --- a/crates/trusted-server-release/src/recipe/fresh.rs +++ b/crates/trusted-server-release/src/recipe/fresh.rs @@ -112,3 +112,20 @@ pub(super) fn disjoint(paths: &[(&str, &Path)]) -> Result<(), Report Result<(), Report> { + real_directory(label, path)?; + let names = fs::read_dir(path) + .map_err(|error| recipe_error(format!("{label} cannot be listed: {error}")))? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>() + .map_err(|error| recipe_error(format!("{label} cannot be listed: {error}")))?; + if names != [std::ffi::OsString::from("vendor")] { + return Err(recipe_error(format!( + "{label} must hold exactly the prepared vendor directory" + ))); + } + real_directory("vendor directory", &path.join("vendor")) +} diff --git a/crates/trusted-server-release/src/recipe/paths.rs b/crates/trusted-server-release/src/recipe/paths.rs new file mode 100644 index 0000000000..7270445ca4 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/paths.rs @@ -0,0 +1,79 @@ +//! Translation between host paths and the build's execution namespace. +//! +//! The confined command sees the boundary roots under their execution paths +//! (identical on macOS, `/boundary//` mount points in the Linux container). Every +//! path the recipe hands to Cargo, and every path Cargo reports back, crosses +//! this seam, so the recipe never guesses a mount layout. + +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::recipe_error; +use crate::boundary::Boundary; +use crate::error::ReleaseError; + +/// Maps host paths into the execution namespace and writable paths back. +pub(crate) trait ExecutionPaths { + /// Translates a host path inside any boundary root. + /// + /// # Errors + /// + /// Returns an error when `host` is outside every root. + fn execution_path(&self, host: &Path) -> Result>; + + /// Translates an execution path inside a writable root back to the host. + /// + /// # Errors + /// + /// Returns an error when `execution` is outside every writable root, so + /// a build can never name an immutable or tool file as its output. + fn host_path(&self, execution: &Path) -> Result>; +} + +impl ExecutionPaths for Boundary { + fn execution_path(&self, host: &Path) -> Result> { + Boundary::execution_path(self, host) + } + + fn host_path(&self, execution: &Path) -> Result> { + Boundary::host_path(self, execution) + } +} + +/// Translates `host` and requires UTF-8 text without `:` or NUL, so the +/// result can travel in an environment variable, a `PATH` or a Cargo config. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when `host` is outside the roots or the +/// translation is not plain text. +pub(super) fn execution_text( + paths: &dyn ExecutionPaths, + label: &str, + host: &Path, +) -> Result> { + let translated = paths + .execution_path(host) + .map_err(|error| error.change_context(ReleaseError::Recipe)) + .map_err(|error| error.attach(format!("{label} is outside the boundary roots")))?; + translated + .to_str() + .filter(|text| { + translated.is_absolute() + && !text.contains([':', '"', '\\']) + && !text.chars().any(char::is_control) + }) + .map(str::to_owned) + .ok_or_else(|| { + recipe_error(format!( + "{label} has an execution path that is not plain absolute UTF-8" + )) + }) +} + +#[cfg(test)] +pub(super) use fake::PrefixPaths; + +#[cfg(test)] +mod fake; diff --git a/crates/trusted-server-release/src/recipe/paths/fake.rs b/crates/trusted-server-release/src/recipe/paths/fake.rs new file mode 100644 index 0000000000..ddb3b2b0a1 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/paths/fake.rs @@ -0,0 +1,93 @@ +//! A prefix-table translator for tests that never touch a real boundary. + +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::ExecutionPaths; +use crate::error::ReleaseError; +use crate::recipe::recipe_error; + +/// One mapped root: host prefix, execution prefix and writability. +#[derive(Clone, Debug)] +pub(crate) struct MappedRoot { + host: PathBuf, + execution: PathBuf, + writable: bool, +} + +/// Translates through an explicit table of mapped roots. +#[derive(Clone, Debug, Default)] +pub(crate) struct PrefixPaths { + roots: Vec, +} + +impl PrefixPaths { + /// Creates an empty table. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Adds a root mapped from `host` to `execution`. + #[must_use] + pub(crate) fn with(mut self, host: &Path, execution: &str, writable: bool) -> Self { + self.roots.push(MappedRoot { + host: host.to_owned(), + execution: PathBuf::from(execution), + writable, + }); + self + } +} + +impl ExecutionPaths for PrefixPaths { + fn execution_path(&self, host: &Path) -> Result> { + self.roots + .iter() + .find_map(|root| { + host.strip_prefix(&root.host).ok().map(|relative| { + if relative.as_os_str().is_empty() { + root.execution.clone() + } else { + root.execution.join(relative) + } + }) + }) + .ok_or_else(|| recipe_error(format!("{} is outside the fake roots", host.display()))) + } + + fn host_path(&self, execution: &Path) -> Result> { + let normal = execution.components().collect::().as_os_str() + == execution.as_os_str() + && !execution + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)); + if !normal { + return Err(recipe_error(format!( + "{} is not in lexical normal form", + execution.display() + ))); + } + self.roots + .iter() + .filter(|root| root.writable) + .find_map(|root| { + execution + .strip_prefix(&root.execution) + .ok() + .map(|relative| { + if relative.as_os_str().is_empty() { + root.host.clone() + } else { + root.host.join(relative) + } + }) + }) + .ok_or_else(|| { + recipe_error(format!( + "{} is outside the fake writable roots", + execution.display() + )) + }) + } +} diff --git a/crates/trusted-server-release/src/recipe/plan.rs b/crates/trusted-server-release/src/recipe/plan.rs new file mode 100644 index 0000000000..6c7db3c9df --- /dev/null +++ b/crates/trusted-server-release/src/recipe/plan.rs @@ -0,0 +1,209 @@ +//! The exact Cargo invocation of one recipe, in the build's own namespace. +//! +//! Host paths exist only in `program` and `working_directory`, which +//! [`ConfinedCommand`] translates itself. Every path inside the argument +//! vector and the environment goes through [`ExecutionPaths`], so a root the +//! boundary does not mount is an error here instead of a dangling path in the +//! child. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +use error_stack::Report; +use serde_json::json; +use sha2::{Digest as _, Sha256}; + +use super::ambient::CheckedAmbient; +use super::paths::{ExecutionPaths, execution_text}; +use super::sources::source_replacement_arguments; +use super::tools::SelectedRustTools; +use super::{AssetId, RecipeRequest, recipe_error}; +use crate::boundary::{ConfinedCommand, Umask}; +use crate::documents::{Role, canonical_json}; +use crate::error::ReleaseError; +use crate::materials::PreparedCargoMaterials; +use crate::preflight::child::ChildEnvironment; +use crate::process::ProcessLimits; + +const PREBUILT_DIR_VARIABLE: &str = "TSJS_PREBUILT_DIR"; +const PREBUILT_INVENTORY_VARIABLE: &str = "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY"; +const TARGET_DIR_VARIABLE: &str = "CARGO_TARGET_DIR"; +const CARGO_HOME_VARIABLE: &str = "CARGO_HOME"; +const HOME_VARIABLE: &str = "HOME"; +const TMPDIR_VARIABLE: &str = "TMPDIR"; +const RUSTC_VARIABLE: &str = "RUSTC"; +const PATH_VARIABLE: &str = "PATH"; + +/// The exact Cargo invocation of one recipe. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CargoPlan { + pub(super) asset: AssetId, + pub(super) program: PathBuf, + pub(super) arguments: Vec, + pub(super) environment: BTreeMap, + pub(super) working_directory: PathBuf, + pub(super) umask: Umask, +} + +impl CargoPlan { + /// Describes the plan as a confined command running under the role's umask. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Boundary`] for a non-normal program or working + /// directory, or [`ReleaseError::Process`] for a rejected variable. + pub(crate) fn confined_command( + &self, + limits: ProcessLimits, + ) -> Result> { + let mut command = ConfinedCommand::new(&self.program, &self.working_directory, limits)? + .arguments(self.arguments.iter().map(OsString::from)) + .umask(self.umask); + for (name, value) in &self.environment { + // The compiler path is the one tool selector the plan sets, on purpose. + command = if name == RUSTC_VARIABLE { + command.tool_environment(name, value)? + } else { + command.environment(name, value)? + }; + } + Ok(command) + } + + /// Returns the SHA-256 of the canonical description of the whole plan. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when a path is not UTF-8. + pub(crate) fn digest(&self) -> Result> { + let text = |label: &str, path: &std::path::Path| { + path.to_str() + .map(str::to_owned) + .ok_or_else(|| recipe_error(format!("plan {label} is not UTF-8"))) + }; + let description = json!({ + "asset": self.asset.artifact().name, + "program": text("program", &self.program)?, + "arguments": self.arguments, + "environment": self.environment, + "working_directory": text("working directory", &self.working_directory)?, + "umask": self.umask.to_string(), + }); + let bytes = canonical_json(&description, Role::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + Ok(hex::encode(Sha256::digest(bytes))) + } +} + +/// Returns the fixed argument vector of the release Cargo build. +/// +/// The only `--config` entries are the exact source replacements. +pub(super) fn cargo_arguments(asset: AssetId, jobs: u32, replacement: &[String]) -> Vec { + let artifact = asset.artifact(); + [ + "build", + "--package", + artifact.package, + "--bin", + artifact.bin, + "--target", + artifact.target.triple(), + "--release", + "--frozen", + ] + .into_iter() + .map(str::to_owned) + .chain(replacement.iter().cloned()) + .chain( + ["--message-format=json-render-diagnostics", "--jobs"] + .into_iter() + .map(str::to_owned), + ) + .chain([jobs.to_string()]) + .collect() +} + +/// Requires the child's `HOME` and `CARGO_HOME` to be the request's private state. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when either variable is missing or names +/// another location. +pub(super) fn check_child_state( + request: &RecipeRequest, + child: &ChildEnvironment, +) -> Result<(), Report> { + for (name, host) in [ + (HOME_VARIABLE, request.home()), + (CARGO_HOME_VARIABLE, request.cargo_home()), + ] { + if child.variables().get(name).map(String::as_str) != host.to_str() { + return Err(recipe_error(format!( + "child {name} is not the request's private cargo state" + ))); + } + } + Ok(()) +} + +/// Everything [`plan_for`] derives the plan from. +pub(super) struct PlanInputs<'a> { + pub(super) request: &'a RecipeRequest, + pub(super) tools: &'a SelectedRustTools, + pub(super) ambient: &'a CheckedAmbient, + pub(super) child: &'a ChildEnvironment, + pub(super) materials: &'a PreparedCargoMaterials, + pub(super) paths: &'a dyn ExecutionPaths, +} + +/// Derives the exact Cargo invocation from the bound inputs. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the child environment's `HOME` or +/// `CARGO_HOME` is not the request's private state, or any path variable, +/// the tool directory or the vendor directory lies outside the boundary roots +/// or is not plain text. +pub(super) fn plan_for(inputs: &PlanInputs<'_>) -> Result> { + let PlanInputs { + request, + tools, + ambient, + child, + materials, + paths, + } = inputs; + check_child_state(request, child)?; + let cargo_home = request.cargo_home(); + let home = request.home(); + let tmpdir = request.tmpdir(); + let rustc = tools.rustc(); + let mut environment = child.variables().clone(); + for (name, host) in [ + (TARGET_DIR_VARIABLE, request.target_dir()), + (CARGO_HOME_VARIABLE, cargo_home.as_path()), + (HOME_VARIABLE, home.as_path()), + (TMPDIR_VARIABLE, tmpdir.as_path()), + (PREBUILT_DIR_VARIABLE, request.js_dir()), + (PREBUILT_INVENTORY_VARIABLE, request.js_inventory()), + (RUSTC_VARIABLE, rustc.as_path()), + ] { + environment.insert(name.to_owned(), execution_text(*paths, name, host)?); + } + let tool_bin = execution_text(*paths, PATH_VARIABLE, &tools.bin_dir())?; + environment.insert( + PATH_VARIABLE.to_owned(), + format!("{tool_bin}:/usr/bin:/bin"), + ); + let vendor = execution_text(*paths, "vendor directory", materials.vendor_dir())?; + let replacement = source_replacement_arguments(&vendor, materials.git_sources()); + Ok(CargoPlan { + asset: request.asset(), + program: tools.cargo(), + arguments: cargo_arguments(request.asset(), ambient.jobs(), &replacement), + environment, + working_directory: ambient.checkout_root().to_owned(), + umask: ambient.umask()?, + }) +} diff --git a/crates/trusted-server-release/src/recipe/sources.rs b/crates/trusted-server-release/src/recipe/sources.rs new file mode 100644 index 0000000000..7347c7753f --- /dev/null +++ b/crates/trusted-server-release/src/recipe/sources.rs @@ -0,0 +1,35 @@ +//! Cargo source-replacement arguments for the vendored dependency tree. + +use crate::materials::GitSourceConfig; + +/// Name of the directory source every locked source is replaced with. +pub(super) const VENDOR_SOURCE: &str = "ts-vendor"; + +/// Returns the `--config` pairs replacing every locked source with the vendor +/// directory. +/// +/// `vendor_directory` is the directory as the confined build sees it and must +/// already be plain text without quotes or backslashes. Each Git source is a +/// full `[source.""]` table (`git`, reference, `replace-with`), because +/// Cargo rejects a source table without a location. +pub(super) fn source_replacement_arguments( + vendor_directory: &str, + git_sources: &[GitSourceConfig], +) -> Vec { + let mut entries = vec![format!("source.crates-io.replace-with=\"{VENDOR_SOURCE}\"")]; + for source in git_sources { + let key = source.key(); + entries.push(format!("source.\"{key}\".git=\"{}\"", source.url())); + if let Some((kind, value)) = source.reference() { + entries.push(format!("source.\"{key}\".{kind}=\"{value}\"")); + } + entries.push(format!("source.\"{key}\".replace-with=\"{VENDOR_SOURCE}\"")); + } + entries.push(format!( + "source.{VENDOR_SOURCE}.directory=\"{vendor_directory}\"" + )); + entries + .into_iter() + .flat_map(|entry| ["--config".to_owned(), entry]) + .collect() +} diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs index 19fe6db8e8..aa83a082c8 100644 --- a/crates/trusted-server-release/src/recipe/tests.rs +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -102,7 +102,8 @@ impl Workspace { .expect("should write JS module"); fs::write(root.join("js/tsjs-inventory.json"), b"fictional inventory") .expect("should write JS inventory"); - fs::create_dir(root.join("materials")).expect("should create materials root"); + fs::create_dir_all(root.join("materials/vendor")).expect("should create materials root"); + fs::create_dir(root.join("cargo-state")).expect("should create cargo state root"); fs::create_dir(root.join("output")).expect("should create output root"); Self { directory, root } } @@ -111,7 +112,7 @@ impl Workspace { self.root.join(relative) } - fn input<'a>(&'a self, paths: &'a [PathBuf; 5]) -> RecipeInput<'a> { + fn input<'a>(&'a self, paths: &'a [PathBuf; 6]) -> RecipeInput<'a> { RecipeInput { asset: "ts-aarch64-apple-darwin", variant: "default", @@ -120,16 +121,18 @@ impl Workspace { js_dir: &paths[0], js_inventory: &paths[1], materials: &paths[2], - output: &paths[3], - target_dir: &paths[4], + cargo_state: &paths[3], + output: &paths[4], + target_dir: &paths[5], } } - fn defaults(&self) -> [PathBuf; 5] { + fn defaults(&self) -> [PathBuf; 6] { [ self.path("js/bundles"), self.path("js/tsjs-inventory.json"), self.path("materials"), + self.path("cargo-state"), self.path("output"), self.path("target"), ] @@ -138,7 +141,7 @@ impl Workspace { const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; -fn parse_error(paths: &[PathBuf; 5], workspace: &Workspace) -> String { +fn parse_error(paths: &[PathBuf; 6], workspace: &Workspace) -> String { let error = match RecipeRequest::parse(&workspace.input(paths)) { Ok(_) => panic!("should reject the request"), Err(error) => error, @@ -156,7 +159,7 @@ fn relative_or_dotted_paths_are_rejected() { workspace.path("output/../output"), workspace.path("./output"), ]; - for index in 0..5 { + for index in 0..6 { for bad in &bad_values { let mut paths = workspace.defaults(); paths[index] = bad.clone(); @@ -178,7 +181,7 @@ fn existing_nonempty_output_root_is_rejected() { "should reject an output root with prior entries" ); let mut absent = workspace.defaults(); - absent[3] = workspace.path("missing-output"); + absent[4] = workspace.path("missing-output"); assert!( parse_error(&absent, &workspace).contains("cannot be resolved"), "should reject an output root that does not exist" @@ -202,9 +205,48 @@ fn symlinked_or_preexisting_materials_root_is_rejected() { ); fs::write(workspace.path("materials/cache"), b"prepared earlier") .expect("should populate materials"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("exactly the prepared vendor"), + "should reject a materials root holding more than the vendor directory" + ); +} + +#[test] +fn materials_root_must_hold_exactly_a_vendor_directory() { + let workspace = Workspace::new(); + RecipeRequest::parse(&workspace.input(&workspace.defaults())) + .expect("should accept the prepared layout"); + fs::remove_dir(workspace.path("materials/vendor")).expect("should remove vendor"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("exactly the prepared vendor"), + "should reject an empty materials root" + ); + fs::write(workspace.path("materials/vendor"), b"not a directory") + .expect("should write a vendor file"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("vendor directory"), + "should reject a vendor entry that is a file" + ); + fs::remove_file(workspace.path("materials/vendor")).expect("should remove vendor file"); + fs::create_dir_all(workspace.path("elsewhere")).expect("should create alias target"); + symlink( + workspace.path("elsewhere"), + workspace.path("materials/vendor"), + ) + .expect("should alias vendor"); + assert!( + parse_error(&workspace.defaults(), &workspace).contains("symlink"), + "should reject a symlinked vendor directory" + ); +} + +#[test] +fn reused_cargo_state_root_is_rejected() { + let workspace = Workspace::new(); + fs::create_dir(workspace.path("cargo-state/home")).expect("should create previous state"); assert!( parse_error(&workspace.defaults(), &workspace).contains("cannot be reused"), - "should reject a pre-populated materials root" + "should reject a cargo state root with prior entries" ); } @@ -221,11 +263,13 @@ fn reused_target_directory_is_rejected() { #[test] fn overlapping_roots_are_rejected() { let workspace = Workspace::new(); - let cases: [(usize, &str); 4] = [ - (3, "materials"), - (4, "output/target"), - (4, "materials/target"), - (4, "js/target"), + let cases: [(usize, &str); 6] = [ + (3, "output"), + (4, "cargo-state"), + (5, "output/target"), + (5, "cargo-state/target"), + (5, "materials/target"), + (5, "js/target"), ]; for (index, relative) in cases { let mut paths = workspace.defaults(); @@ -281,6 +325,11 @@ fn checked_b() -> CheckedAmbient { mod ambient; mod bind; +mod complete; +mod fixture; +mod plan; +mod sources; +mod tools; #[test] fn symlinked_ancestor_of_target_parent_is_rejected() { @@ -293,7 +342,7 @@ fn symlinked_ancestor_of_target_parent_is_rejected() { .expect("should create parent alias"); fs::create_dir(workspace.path("real-parent/inner")).expect("should create inner parent"); let mut paths = workspace.defaults(); - paths[4] = workspace.path("linked-parent/inner/target"); + paths[5] = workspace.path("linked-parent/inner/target"); assert!( parse_error(&paths, &workspace).contains("symlink"), "should reject a target below a symlinked ancestor" diff --git a/crates/trusted-server-release/src/recipe/tests/bind.rs b/crates/trusted-server-release/src/recipe/tests/bind.rs index 42d61d445e..ec4c408c44 100644 --- a/crates/trusted-server-release/src/recipe/tests/bind.rs +++ b/crates/trusted-server-release/src/recipe/tests/bind.rs @@ -1,161 +1,10 @@ -use std::collections::BTreeMap; -use std::ffi::OsString; use std::fs; -use std::path::{Path, PathBuf}; +use super::fixture::*; use super::*; -use crate::policy::ReleaseTarget; -use crate::preflight::audit_environment; -use crate::preflight::child::build_child_for_tests; - -const OTHER_COMMIT: &str = "fedcba9876543210fedcba9876543210fedcba98"; - -fn audited() -> AuditedEnvironment { - let snapshot: Vec<(OsString, OsString)> = [ - ("PATH", "/usr/bin"), - ("TRUSTED_SERVER__PROXY__SECRET", "fictional"), - ("LANG", "en_US.UTF-8"), - ] - .into_iter() - .map(|(name, value)| (OsString::from(name), OsString::from(value))) - .collect(); - audit_environment(&snapshot).expect("should audit benign environment") -} - -/// A fictional layout with a completed JS handoff and every recipe root. -struct Fixture { - _directory: tempfile::TempDir, - base: PathBuf, - completed: JsBuildCompleted, -} - -impl Fixture { - fn new(js_commit: &str) -> Self { - let directory = tempfile::tempdir().expect("should create fixture directory"); - let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); - for relative in [ - "materials", - "output", - "checkout-a", - "checkout-b", - "tools/bin", - "other-source", - "other-work", - ] { - fs::create_dir_all(base.join(relative)).expect("should create fixture directory"); - } - for tool in ["tools/bin/cargo", "tools/bin/rustc"] { - fs::write(base.join(tool), b"fictional tool").expect("should write fictional tool"); - } - Self { - _directory: directory, - base, - completed: JsBuildCompleted::fixture_for_tests(js_commit), - } - } - - fn retained(&self) -> PathBuf { - fs::canonicalize( - self.completed - .rehash_for_handoff() - .expect("should rehash the fixture handoff"), - ) - .expect("should canonicalize the retained root") - } - - fn request(&self, asset: &str, role: &str, commit: &str) -> RecipeRequest { - self.request_at(asset, role, commit, &self.base.join("target")) - } - - fn request_at( - &self, - asset: &str, - role: &str, - commit: &str, - target_dir: &Path, - ) -> RecipeRequest { - let retained = self.retained(); - let js_dir = retained.join("bundles"); - let js_inventory = retained.join("tsjs-inventory.json"); - RecipeRequest::parse(&RecipeInput { - asset, - variant: "default", - role, - commit, - js_dir: &js_dir, - js_inventory: &js_inventory, - materials: &self.base.join("materials"), - output: &self.base.join("output"), - target_dir, - }) - .expect("should parse the fixture request") - } - - fn ambient(&self, role: Role) -> CheckedAmbient { - match role { - Role::A => CheckedAmbient::check( - Role::A, - &AmbientProfile::A, - ObservedAmbient { - checkout_root: self.base.join("checkout-a"), - ..observed_a() - }, - ), - Role::B => CheckedAmbient::check( - Role::B, - &AmbientProfile::alternate_for_tests(), - ObservedAmbient { - checkout_root: self.base.join("checkout-b"), - ..observed_b() - }, - ), - } - .expect("should check the fixture ambient") - } - - fn tools(&self) -> SelectedRustTools { - SelectedRustTools::for_tests(&self.base.join("tools")).expect("should select fixture tools") - } - - fn bind_with( - &self, - request: RecipeRequest, - role: Role, - commit: &str, - ) -> Result> { - let commit = CommitId::parse(commit).expect("should parse fixture commit"); - let child = - build_child_for_tests(&audited(), &commit, 1_700_000_000, &child_request(&request)) - .expect("should build the fixture child environment"); - PreparedRecipe::bind(BindInputs { - commit: &commit, - child, - completed: &self.completed, - tools: self.tools(), - ambient: self.ambient(role), - request, - }) - } - - fn bind(&self, asset: &str, role: Role) -> Result> { - let text = if role == Role::A { "A" } else { "B" }; - self.bind_with(self.request(asset, text, COMMIT), role, COMMIT) - } -} - -fn plan_error(prepared: &PreparedRecipe) -> String { - match prepared.check_plan() { - Ok(()) => panic!("should reject the plan"), - Err(error) => format!("{error:?}"), - } -} - -fn bind_error(result: Result>) -> String { - match result { - Ok(_) => panic!("should reject the binding"), - Err(error) => format!("{error:?}"), - } -} +use crate::boundary::BoundaryRoots; +#[cfg(target_os = "macos")] +use crate::boundary::{Boundary, DenialProof}; #[test] fn js_handoff_for_other_source_commit_is_rejected() { @@ -220,188 +69,28 @@ fn request_for_other_js_directory_or_role_is_rejected() { } #[test] -fn cargo_plan_has_fixed_release_frozen_json_flags() { - for (index, artifact) in FIXED_ARTIFACTS.iter().enumerate() { - let role = if index % 2 == 0 { Role::A } else { Role::B }; - let jobs = if role == Role::A { "4" } else { "2" }; - let fixture = Fixture::new(COMMIT); - let prepared = fixture - .bind(artifact.name, role) - .expect("should bind every fixed asset"); - let expected_arguments = [ - "build", - "--package", - artifact.package, - "--bin", - artifact.bin, - "--target", - artifact.target.triple(), - "--release", - "--frozen", - "--message-format=json-render-diagnostics", - "--jobs", - jobs, - ]; - assert_eq!( - prepared.plan().arguments, - expected_arguments, - "should use the exact fixed arguments for {}", - artifact.name - ); - assert_eq!( - prepared.plan().program, - fixture.base.join("tools/bin/cargo"), - "should run the selected Cargo" - ); - let checkout = if role == Role::A { - "checkout-a" - } else { - "checkout-b" - }; - assert_eq!( - prepared.plan().working_directory, - fixture.base.join(checkout), - "should run in the role checkout" - ); - assert_environment(&fixture, &prepared, artifact.target); - assert!( - fixture.base.join("target").is_dir(), - "should create the fresh target directory" - ); - prepared - .check_plan() - .expect("should accept the untouched plan"); - } -} - -fn assert_environment(fixture: &Fixture, prepared: &PreparedRecipe, target: ReleaseTarget) { - let retained = fixture.retained(); - let path = |path: PathBuf| path.to_str().expect("should be UTF-8").to_owned(); - let mut expected = BTreeMap::from([ - ("AWS_LC_SYS_USE_SYSTEM".to_owned(), "0".to_owned()), - ( - format!( - "AWS_LC_SYS_USE_SYSTEM_{}", - target.triple().replace('-', "_") - ), - "0".to_owned(), - ), - ( - "CARGO_HOME".to_owned(), - path(fixture.base.join("materials")), - ), - ( - "CARGO_TARGET_DIR".to_owned(), - path(fixture.base.join("target")), - ), - ("HOME".to_owned(), path(fixture.base.join("materials/home"))), - ("LC_ALL".to_owned(), "C".to_owned()), - ("LIBSQLITE3_SYS_USE_PKG_CONFIG".to_owned(), "0".to_owned()), - ("SOURCE_DATE_EPOCH".to_owned(), "1700000000".to_owned()), - ("TRUSTED_SERVER__GIT_VERSION".to_owned(), COMMIT.to_owned()), - ("TRUSTED_SERVER__RELEASE_BUILD".to_owned(), "1".to_owned()), - ( - "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY".to_owned(), - path(retained.join("tsjs-inventory.json")), - ), - ( - "TSJS_PREBUILT_DIR".to_owned(), - path(retained.join("bundles")), - ), - ("TZ".to_owned(), "UTC".to_owned()), - ]); - if target.is_darwin() { - expected.insert("MACOSX_DEPLOYMENT_TARGET".to_owned(), "13.0".to_owned()); - } - assert_eq!( - prepared.plan().environment, - expected, - "should hold exactly the child environment plus three recipe variables" - ); -} - -#[test] -fn cargo_plan_rejects_inherited_rustflags_and_selectors() { - for name in [ - "RUSTFLAGS", - "CARGO_ENCODED_RUSTFLAGS", - "CARGO_BUILD_TARGET", - "RUSTUP_TOOLCHAIN", - "CC", - "TSJS_SKIP_BUILD", - ] { - let snapshot = [(OsString::from(name), OsString::from("fictional"))]; - let rejection = match audit_environment(&snapshot) { - Ok(_) => panic!("should reject inherited {name}"), - Err(error) => format!("{error:?}"), - }; - assert!( - rejection.contains(name), - "should reject inherited {name} before any plan exists" - ); - } - let fixture = Fixture::new(COMMIT); - let mut prepared = fixture - .bind("ts-x86_64-unknown-linux-gnu", Role::A) - .expect("should bind from a benign environment"); - let plan = prepared.plan(); - for name in plan.environment.keys() { - assert!( - !name.starts_with("RUST") && !name.starts_with("CARGO_BUILD"), - "should not carry selector {name}" - ); - } - for argument in &plan.arguments { - assert!( - ![ - "--config", - "--features", - "--all-features", - "--no-default-features", - "--manifest-path" - ] - .iter() - .any(|selector| argument.starts_with(selector)) - && !argument.starts_with('+'), - "should not carry selector argument {argument}" - ); - } - prepared - .plan - .environment - .insert("RUSTFLAGS".to_owned(), "--cfg fictional".to_owned()); - assert!( - plan_error(&prepared).contains("differs"), - "should reject an injected RUSTFLAGS" - ); - prepared.plan.environment.remove("RUSTFLAGS"); - prepared - .check_plan() - .expect("should accept the restored plan"); - prepared.plan.environment.remove("LC_ALL"); - assert!( - plan_error(&prepared).contains("differs"), - "should reject a removed normalization variable" - ); +fn bind_creates_fresh_target_and_private_cargo_state() { let fixture = Fixture::new(COMMIT); - let mut prepared = fixture - .bind("ts-x86_64-unknown-linux-gnu", Role::A) - .expect("should bind again"); - for change in ["--features=fictional", "+nightly", "--config=fictional"] { - prepared.plan.arguments.push(change.to_owned()); + fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + for relative in [ + "target", + "cargo-state/home", + "cargo-state/cargo-home", + "cargo-state/tmp", + ] { assert!( - plan_error(&prepared).contains("differs"), - "should reject added argument {change}" + fixture.base.join(relative).is_dir(), + "should create {relative}" ); - prepared.plan.arguments.pop(); } - prepared - .plan - .arguments - .retain(|argument| argument != "--release"); - assert!( - plan_error(&prepared).contains("differs"), - "should reject a dropped --release" + assert_eq!( + fs::read_dir(fixture.base.join("output")) + .expect("should list output") + .count(), + 0, + "should leave the unmounted output root empty" ); } @@ -423,8 +112,8 @@ fn execute_rejects_boundary_with_other_roots() { .execute(&boundary, &DenialProof::for_tests()) .expect_err("should reject a boundary over other roots"); assert!( - format!("{error:?}").contains("roots differ"), - "should name the root mismatch" + format!("{error:?}").contains("outside the boundary roots"), + "should reject plan paths outside the other roots: {error:?}" ); } @@ -467,9 +156,25 @@ fn qualified_rust_tools_constructor_is_uninhabited() { ); } +#[test] +fn ordinary_ci_selection_carries_no_release_authority() { + let fixture = Fixture::new(COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + assert_eq!( + tools.authority(), + Authority::Unqualified, + "should never report a test selection as qualified" + ); + assert_eq!( + ToolSelection::OrdinaryCi.authority(), + Authority::Unqualified, + "should report the ordinary CI selection as unqualified" + ); +} + #[test] fn root_filled_between_parse_and_bind_is_rejected() { - for (relative, label) in [("materials", "materials"), ("output", "output")] { + for (relative, label) in [("cargo-state", "cargo state"), ("output", "output")] { let fixture = Fixture::new(COMMIT); let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); fs::write( @@ -496,6 +201,53 @@ fn root_filled_between_parse_and_bind_is_rejected() { ); } +#[test] +fn materials_changed_or_foreign_after_preparation_are_rejected() { + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let materials = fixture.materials(COMMIT); + fs::write( + fixture + .base + .join("materials/vendor/example-1.0.0/Cargo.toml"), + b"tampered after preparation", + ) + .expect("should edit a vendored file"); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + assert!( + bind_error(fixture.bind_parts(request, Role::A, COMMIT, tools, materials)) + .contains("no longer rehash"), + "should reject materials edited after preparation" + ); + assert!( + !fixture.base.join("target").exists(), + "should not create the target after a rejection" + ); + + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let foreign = fixture.materials(OTHER_COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + assert!( + bind_error(fixture.bind_parts(request, Role::A, COMMIT, tools, foreign)) + .contains("another source commit"), + "should reject materials derived from another commit" + ); +} + +#[test] +fn tools_selected_for_another_target_are_rejected() { + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let tools = fixture.tools_for(ReleaseTarget::X86_64UnknownLinuxGnu); + let materials = fixture.materials(COMMIT); + assert!( + bind_error(fixture.bind_parts(request, Role::A, COMMIT, tools, materials)) + .contains("another target"), + "should reject tools whose standard library was checked for another target" + ); +} + #[test] fn target_inside_checkout_is_rejected_before_creation() { let fixture = Fixture::new(COMMIT); @@ -517,106 +269,47 @@ fn target_inside_checkout_is_rejected_before_creation() { } #[test] -fn tampered_program_or_working_directory_is_rejected() { +fn boundary_roots_mount_materials_read_only_state_and_target_writable_output_unmounted() { let fixture = Fixture::new(COMMIT); - let mut prepared = fixture + let prepared = fixture .bind("ts-aarch64-apple-darwin", Role::A) .expect("should bind the recipe"); - prepared.plan.program = fixture.base.join("tools/bin/rustc"); - assert!( - plan_error(&prepared).contains("differs"), - "should reject a swapped program" + let expected = BoundaryRoots::new( + &[ + fixture.base.join("checkout-a"), + fixture.retained(), + fixture.base.join("materials"), + ], + &[fixture.base.join("tools")], + &[ + fixture.base.join("cargo-state"), + fixture.base.join("target"), + ], + ) + .expect("should build the expected roots"); + assert_eq!( + prepared.boundary_roots().expect("should derive roots"), + expected, + "should mount exactly the planned roles" ); - prepared.plan.program = fixture.base.join("tools/bin/cargo"); - prepared.plan.working_directory = fixture.base.join("checkout-b"); + let all: Vec<_> = expected + .entries() + .map(|(_, _, path)| path.to_owned()) + .collect(); assert!( - plan_error(&prepared).contains("differs"), - "should reject a swapped working directory" + !all.iter() + .any(|path| path.starts_with(fixture.base.join("output"))), + "should not mount the output root" ); -} - -fn confinement_limits() -> ProcessLimits { - ProcessLimits { - wall: std::time::Duration::from_secs(60), - stdout_max: 1024, - stderr_max: 1024, - } -} - -#[test] -fn plan_carries_the_role_umask() { - for (role, expected) in [(Role::A, 0o022), (Role::B, 0o027)] { - let fixture = Fixture::new(COMMIT); - let prepared = fixture - .bind("ts-aarch64-apple-darwin", role) - .expect("should bind the recipe"); - assert_eq!( - prepared.plan().umask, - Umask::new(expected).expect("should accept mask"), - "should derive the umask of role {role:?} from its ambient profile" - ); - } -} - -#[test] -fn tampered_umask_is_rejected_by_the_plan_recheck() { - let fixture = Fixture::new(COMMIT); - let mut prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) - .expect("should bind the recipe"); - prepared.plan.umask = Umask::new(0o000).expect("should accept mask"); - assert!( - plan_error(&prepared).contains("differs"), - "should reject a weakened umask" + assert_eq!( + expected.writable().len(), + 2, + "should expose exactly two writable roots" ); - prepared.plan.umask = Umask::new(0o027).expect("should accept mask"); assert!( - plan_error(&prepared).contains("differs"), - "should reject another role's umask" + expected + .immutable() + .contains(&fixture.base.join("materials")), + "should mount the vendor materials read-only" ); - prepared.plan.umask = Umask::new(0o022).expect("should accept mask"); - prepared - .check_plan() - .expect("should accept the restored umask"); -} - -#[test] -fn confined_command_applies_the_role_umask() { - for (role, expected) in [(Role::A, "022"), (Role::B, "027")] { - let fixture = Fixture::new(COMMIT); - let prepared = fixture - .bind("ts-aarch64-apple-darwin", role) - .expect("should bind the recipe"); - let command = prepared - .plan() - .confined_command(confinement_limits()) - .expect("should describe the confined command"); - assert_eq!( - command - .requested_umask() - .map(|umask| umask.to_string()) - .as_deref(), - Some(expected), - "should set the umask of role {role:?} on the confined command" - ); - let described = format!("{command:?}"); - for part in [ - format!("{:?}", prepared.plan().program), - format!("{:?}", prepared.plan().working_directory), - format!( - "{:?}", - prepared - .plan() - .arguments - .iter() - .map(OsString::from) - .collect::>() - ), - ] { - assert!( - described.contains(&part), - "should describe {part} in {described}" - ); - } - } } diff --git a/crates/trusted-server-release/src/recipe/tests/complete.rs b/crates/trusted-server-release/src/recipe/tests/complete.rs new file mode 100644 index 0000000000..464cf117ee --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/complete.rs @@ -0,0 +1,478 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::time::Duration; + +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::fixture::*; +use super::*; + +const FINGERPRINT: [u8; 32] = [7; 32]; + +fn wasm_with(customs: &[(&str, &[u8])]) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + for (name, data) in customs { + let mut body = vec![u8::try_from(name.len()).expect("should fit")]; + body.extend_from_slice(name.as_bytes()); + body.extend_from_slice(data); + out.push(0); + out.push(u8::try_from(body.len()).expect("should fit")); + out.extend_from_slice(&body); + } + out +} + +/// Cargo's executable file name for `asset`. +fn executable_name(asset: &str) -> String { + let artifact = AssetId::parse(asset) + .expect("should resolve asset") + .artifact(); + if artifact.target == ReleaseTarget::Wasm32Wasip1 { + format!("{}.wasm", artifact.bin) + } else { + artifact.bin.to_owned() + } +} + +struct Built { + fixture: Fixture, + prepared: Option, + asset: &'static str, + role: Role, +} + +fn built(asset: &'static str, role: Role, bytes: &[u8]) -> Built { + built_with(asset, role, bytes, false) +} + +/// Builds a bound recipe, optionally over materials with a derived manifest. +fn built_with(asset: &'static str, role: Role, bytes: &[u8], derived: bool) -> Built { + let fixture = Fixture::new(COMMIT); + let prepared = if derived { + let text = if role == Role::A { "A" } else { "B" }; + let request = fixture.request(asset, text, COMMIT); + let tools = fixture.tools_for(request.asset.artifact().target); + let materials = fixture + .materials(COMMIT) + .with_derived_for_tests("example-1.0.0", &"d".repeat(64)); + fixture.bind_parts(request, role, COMMIT, tools, materials) + } else { + fixture.bind(asset, role) + } + .expect("should bind the recipe"); + let artifact = AssetId::parse(asset) + .expect("should resolve asset") + .artifact(); + let release = fixture + .base + .join("target") + .join(artifact.target.triple()) + .join("release"); + fs::create_dir_all(&release).expect("should create the release directory"); + fs::write(release.join(executable_name(asset)), bytes).expect("should write the executable"); + Built { + fixture, + prepared: Some(prepared), + asset, + role, + } +} + +impl Built { + fn prepared(&self) -> &PreparedRecipe { + self.prepared + .as_ref() + .expect("should hold the prepared recipe") + } + + fn stdout(&self, edit: impl FnOnce(&mut Value)) -> Vec { + let artifact = AssetId::parse(self.asset) + .expect("should resolve asset") + .artifact(); + let release = format!("/boundary/writable/1/{}/release", artifact.target.triple()); + let mut message = json!({ + "reason": "compiler-artifact", + "manifest_path": format!("/boundary/immutable/0/crates/{}/Cargo.toml", artifact.package), + "target": {"kind": ["bin"], "crate_types": ["bin"], "name": artifact.bin}, + "profile": {"opt_level": "3", "debuginfo": 1}, + "executable": format!("{release}/{}", executable_name(self.asset)), + "fresh": false + }); + edit(&mut message); + let mut out = Vec::new(); + for line in [ + json!({"reason": "build-script-executed", "env": []}), + message, + json!({"reason": "build-finished", "success": true}), + ] { + out.extend_from_slice(line.to_string().as_bytes()); + out.push(b'\n'); + } + out + } + + fn run(&self, stdout: Vec) -> RunOutput { + RunOutput { + stdout, + stderr_sha256: hex::encode(Sha256::digest(b"fictional diagnostics")), + started_at_seconds: 1_700_000_000, + elapsed: Duration::from_secs(42), + } + } + + fn finish(&mut self, stdout: Vec) -> Result> { + let paths = self.fixture.paths(self.role); + let run = self.run(stdout); + self.prepared + .take() + .expect("should hold the prepared recipe") + .complete(run, FINGERPRINT, &paths) + } +} + +fn rejection(result: Result>) -> String { + match result { + Ok(_) => panic!("should reject the completion"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn successful_run_binds_every_input_and_copies_the_artifact() { + let bytes = b"fictional native executable"; + let mut built = built("ts-aarch64-apple-darwin", Role::A, bytes); + let stdout = built.stdout(|_| {}); + let expected_plan = built + .prepared() + .plan(&built.fixture.paths(Role::A)) + .expect("should derive the plan") + .digest() + .expect("should digest the plan"); + let materials_digest = built.prepared().materials.digest().to_owned(); + let tools_digest = built.prepared().tools_digest.clone(); + let js = [ + built.fixture.completed.inventory_sha256().to_owned(), + built.fixture.completed.settings_sha256().to_owned(), + built.fixture.completed.tools_sha256().to_owned(), + ]; + let output = built.fixture.base.join("output"); + let completed = built + .finish(stdout.clone()) + .expect("should complete the run"); + + assert_eq!( + completed.asset().artifact().name, + "ts-aarch64-apple-darwin", + "should bind the asset" + ); + assert_eq!(completed.role(), Role::A, "should bind the role"); + assert_eq!( + completed.commit().as_str(), + COMMIT, + "should bind the commit" + ); + assert_eq!( + completed.cargo_lock_sha256(), + "b".repeat(64), + "should bind the lock digest" + ); + assert_eq!( + completed.materials_digest(), + materials_digest, + "should bind the materials digest" + ); + assert_eq!( + completed.js_digests(), + js.each_ref().map(String::as_str), + "should bind the JS handoff" + ); + assert_eq!( + completed.rust_tools_digest(), + tools_digest, + "should bind the Rust tools digest" + ); + assert_eq!( + completed.boundary_fingerprint(), + FINGERPRINT, + "should bind the boundary instance" + ); + assert_eq!( + completed.plan_digest(), + expected_plan, + "should bind the exact plan" + ); + assert_eq!( + completed.ambient().role(), + Role::A, + "should bind the ambient" + ); + assert_eq!( + completed.started_at_seconds(), + 1_700_000_000, + "should bind the start time" + ); + assert_eq!( + completed.elapsed(), + Duration::from_secs(42), + "should bind the elapsed time" + ); + assert_eq!( + completed.stdout_sha256(), + hex::encode(Sha256::digest(&stdout)), + "should bind the Cargo stream" + ); + assert_eq!( + completed.stderr_sha256(), + hex::encode(Sha256::digest(b"fictional diagnostics")), + "should bind the diagnostics digest" + ); + assert_eq!( + completed.artifact().name(), + "ts-aarch64-apple-darwin", + "should name the artifact" + ); + assert_eq!( + completed.artifact().size(), + bytes.len() as u64, + "should record the size" + ); + assert_eq!( + completed.artifact().sha256(), + hex::encode(Sha256::digest(bytes)), + "should record the digest" + ); + assert_eq!( + fs::read(output.join("ts-aarch64-apple-darwin")).expect("should read output"), + bytes, + "should copy the bytes" + ); + assert_eq!( + completed + .rehash_for_handoff() + .expect("should rehash the untouched output"), + output, + "should return the output directory" + ); +} + +#[test] +fn rehash_rejects_changed_output_and_retained_stream() { + type Tamper = fn(&std::path::Path, &RecipeCompleted); + let tampers: [(&str, Tamper); 6] = [ + ("edit", |output, _| { + fs::write( + output.join("ts-aarch64-apple-darwin"), + b"tampered executable", + ) + .expect("should edit"); + }), + ("extra entry", |output, _| { + fs::write(output.join("extra"), b"x").expect("should add an entry"); + }), + ("hard link", |output, _| { + fs::hard_link( + output.join("ts-aarch64-apple-darwin"), + output.join("..").join("link"), + ) + .expect("should link"); + }), + ("removal", |output, _| { + fs::remove_file(output.join("ts-aarch64-apple-darwin")).expect("should remove"); + }), + ("symlink swap", |output, _| { + let path = output.join("ts-aarch64-apple-darwin"); + fs::remove_file(&path).expect("should remove"); + fs::write( + output.join("..").join("elsewhere"), + b"fictional native executable", + ) + .expect("should write target"); + symlink(output.join("..").join("elsewhere"), path).expect("should swap in a symlink"); + }), + ("retained stream", |_, completed| { + fs::write(completed.retained_stdout_path(), b"tampered stream").expect("should edit"); + }), + ]; + for (label, tamper) in tampers { + let mut built = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + let stdout = built.stdout(|_| {}); + let output = built.fixture.base.join("output"); + let completed = built.finish(stdout).expect("should complete the run"); + completed + .rehash_for_handoff() + .expect("should rehash before tampering"); + tamper(&output, &completed); + assert!( + completed.rehash_for_handoff().is_err(), + "should reject {label} before handoff" + ); + } +} + +#[test] +fn malformed_streams_complete_nothing_and_copy_nothing() { + type Edit = fn(&mut Value); + let edits: [(&str, Edit); 5] = [ + ("fresh", |m| m["fresh"] = json!(true)), + ("debuginfo", |m| m["profile"]["debuginfo"] = json!(0)), + ("name", |m| m["target"]["name"] = json!("other")), + ("outside executable", |m| { + m["executable"] = json!("/boundary/immutable/0/ts"); + }), + ("executable in a read-only root", |m| { + m["executable"] = json!("/boundary/immutable/0/target/release/ts"); + }), + ]; + for (label, edit) in edits { + let mut built = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + let stdout = built.stdout(edit); + let output = built.fixture.base.join("output"); + assert!( + built.finish(stdout).is_err(), + "should reject a stream with {label}" + ); + assert_eq!( + fs::read_dir(output).expect("should list output").count(), + 0, + "should copy nothing after a {label} rejection" + ); + } +} + +#[test] +fn changed_tools_or_materials_or_existing_output_reject_completion() { + let mut built_tools = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + let stdout = built_tools.stdout(|_| {}); + fs::write(built_tools.fixture.base.join("tools/bin/cargo"), b"swapped") + .expect("should swap cargo"); + assert!( + rejection(built_tools.finish(stdout)).contains("Rust tools changed"), + "should reject tools changed after the run" + ); + + let mut built_materials = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + let stdout = built_materials.stdout(|_| {}); + fs::write( + built_materials + .fixture + .base + .join("materials/vendor/example-1.0.0/Cargo.toml"), + b"swapped", + ) + .expect("should swap a dependency"); + assert!( + rejection(built_materials.finish(stdout)).contains("changed after binding"), + "should reject materials changed after the run" + ); + + let mut built_output = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + let stdout = built_output.stdout(|_| {}); + fs::write( + built_output + .fixture + .base + .join("output/ts-aarch64-apple-darwin"), + b"planted", + ) + .expect("should plant an output"); + assert!( + rejection(built_output.finish(stdout)).contains("exclusively"), + "should reject an output that exists already" + ); +} + +#[test] +fn wasm_artifacts_must_keep_debug_sections() { + let asset = "trusted-server-fastly.wasm"; + let stripped = wasm_with(&[("name", b"n")]); + let mut built_stripped = built(asset, Role::A, &stripped); + let stdout = built_stripped.stdout(|_| {}); + let message = rejection(built_stripped.finish(stdout)); + assert!( + message.contains("debug section") || message.contains("WasmDebugInfo"), + "should reject a stripped module: {message}" + ); + + let kept = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut built_kept = built(asset, Role::A, &kept); + let stdout = built_kept.stdout(|_| {}); + let completed = built_kept + .finish(stdout) + .expect("should accept a module with debug sections"); + assert_eq!( + completed.artifact().name(), + asset, + "should publish under the asset name" + ); + + let mut built_text = built(asset, Role::A, b"not wasm at all"); + let stdout = built_text.stdout(|_| {}); + assert!( + built_text.finish(stdout).is_err(), + "should reject bytes that are not a module" + ); +} + +#[test] +fn execute_stays_closed_for_an_ordinary_ci_selection() { + let built = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + assert_eq!( + built.prepared().tools.authority(), + Authority::Unqualified, + "should carry no release authority into the execute gate" + ); +} + +#[test] +fn derived_manifest_digests_are_carried_into_the_completion() { + let mut plain = built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + ); + let stdout = plain.stdout(|_| {}); + let completed = plain.finish(stdout).expect("should complete the run"); + assert!( + completed.derived_manifest_digests().is_empty(), + "should carry no derived manifest when none was used" + ); + + let mut derived = built_with( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional native executable", + true, + ); + let stdout = derived.stdout(|_| {}); + let completed = derived.finish(stdout).expect("should complete the run"); + assert_eq!( + completed.derived_manifest_digests().get("example-1.0.0"), + Some(&"d".repeat(64)), + "should carry the derived manifest digest so the build stays marked unqualified" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/fixture.rs b/crates/trusted-server-release/src/recipe/tests/fixture.rs new file mode 100644 index 0000000000..ab7661e2bf --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/fixture.rs @@ -0,0 +1,236 @@ +//! A fictional layout with a completed JS handoff and every recipe root. + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::super::ambient::ObservedAmbient; +use super::super::paths::PrefixPaths; +use super::*; +use crate::boundary::Umask; +use crate::materials::PreparedCargoMaterials; +use crate::policy::{FIXED_ARTIFACTS, ReleaseTarget}; +use crate::preflight::audit_environment; +use crate::preflight::child::build_child_for_tests; +use crate::process::ProcessLimits; + +pub(super) const OTHER_COMMIT: &str = "fedcba9876543210fedcba9876543210fedcba98"; +pub(super) const GIT_SELECTOR: &str = "git+https://example.com/org/workspace?tag=v1"; + +pub(super) fn audited() -> AuditedEnvironment { + let snapshot: Vec<(OsString, OsString)> = [ + ("PATH", "/usr/bin"), + ("TRUSTED_SERVER__PROXY__SECRET", "fictional"), + ("LANG", "en_US.UTF-8"), + ] + .into_iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect(); + audit_environment(&snapshot).expect("should audit benign environment") +} + +pub(super) struct Fixture { + pub(super) _directory: tempfile::TempDir, + pub(super) base: PathBuf, + pub(super) completed: JsBuildCompleted, +} + +impl Fixture { + pub(super) fn new(js_commit: &str) -> Self { + let directory = tempfile::tempdir().expect("should create fixture directory"); + let base = fs::canonicalize(directory.path()).expect("should canonicalize fixture"); + for relative in [ + "materials/vendor/example-1.0.0", + "cargo-state", + "output", + "checkout-a", + "checkout-b", + "tools/bin", + "other-source", + "other-work", + ] { + fs::create_dir_all(base.join(relative)).expect("should create fixture directory"); + } + fs::write( + base.join("materials/vendor/example-1.0.0/Cargo.toml"), + b"fictional package", + ) + .expect("should write fictional vendored package"); + for tool in ["tools/bin/cargo", "tools/bin/rustc"] { + fs::write(base.join(tool), b"fictional tool").expect("should write fictional tool"); + } + for artifact in &FIXED_ARTIFACTS { + let standard = base + .join("tools/lib/rustlib") + .join(artifact.target.triple()) + .join("lib"); + fs::create_dir_all(&standard).expect("should create target standard library"); + fs::write( + standard.join("libstd-0123456789abcdef.rlib"), + b"fictional std", + ) + .expect("should write fictional libstd"); + } + Self { + _directory: directory, + base, + completed: JsBuildCompleted::fixture_for_tests(js_commit), + } + } + + pub(super) fn retained(&self) -> PathBuf { + fs::canonicalize( + self.completed + .rehash_for_handoff() + .expect("should rehash the fixture handoff"), + ) + .expect("should canonicalize the retained root") + } + + pub(super) fn request(&self, asset: &str, role: &str, commit: &str) -> RecipeRequest { + self.request_at(asset, role, commit, &self.base.join("target")) + } + + pub(super) fn request_at( + &self, + asset: &str, + role: &str, + commit: &str, + target_dir: &Path, + ) -> RecipeRequest { + let retained = self.retained(); + let js_dir = retained.join("bundles"); + let js_inventory = retained.join("tsjs-inventory.json"); + RecipeRequest::parse(&RecipeInput { + asset, + variant: "default", + role, + commit, + js_dir: &js_dir, + js_inventory: &js_inventory, + materials: &self.base.join("materials"), + cargo_state: &self.base.join("cargo-state"), + output: &self.base.join("output"), + target_dir, + }) + .expect("should parse the fixture request") + } + + pub(super) fn ambient(&self, role: Role) -> CheckedAmbient { + match role { + Role::A => CheckedAmbient::check( + Role::A, + &AmbientProfile::A, + ObservedAmbient { + checkout_root: self.base.join("checkout-a"), + ..observed_a() + }, + ), + Role::B => CheckedAmbient::check( + Role::B, + &AmbientProfile::alternate_for_tests(), + ObservedAmbient { + checkout_root: self.base.join("checkout-b"), + ..observed_b() + }, + ), + } + .expect("should check the fixture ambient") + } + + pub(super) fn tools_for(&self, target: ReleaseTarget) -> SelectedRustTools { + SelectedRustTools::for_tests(&self.base.join("tools"), target) + .expect("should select fixture tools") + } + + pub(super) fn materials(&self, commit: &str) -> PreparedCargoMaterials { + PreparedCargoMaterials::for_tests(&self.base.join("materials"), commit, &[GIT_SELECTOR]) + .expect("should describe fixture materials") + } + + /// Binds with explicit tools and materials. + pub(super) fn bind_parts( + &self, + request: RecipeRequest, + role: Role, + commit: &str, + tools: SelectedRustTools, + materials: PreparedCargoMaterials, + ) -> Result> { + let commit = CommitId::parse(commit).expect("should parse fixture commit"); + let child = + build_child_for_tests(&audited(), &commit, 1_700_000_000, &child_request(&request)) + .expect("should build the fixture child environment"); + PreparedRecipe::bind(BindInputs { + commit: &commit, + child, + completed: &self.completed, + tools, + materials, + ambient: self.ambient(role), + request, + }) + } + + pub(super) fn bind_with( + &self, + request: RecipeRequest, + role: Role, + commit: &str, + ) -> Result> { + let tools = self.tools_for(request.asset.artifact().target); + let materials = self.materials(commit); + self.bind_parts(request, role, commit, tools, materials) + } + + pub(super) fn bind( + &self, + asset: &str, + role: Role, + ) -> Result> { + let text = if role == Role::A { "A" } else { "B" }; + self.bind_with(self.request(asset, text, COMMIT), role, COMMIT) + } + + /// The fake namespace mirroring the boundary's mount layout for `role`. + pub(super) fn paths(&self, role: Role) -> PrefixPaths { + let checkout = if role == Role::A { + "checkout-a" + } else { + "checkout-b" + }; + PrefixPaths::new() + .with(&self.base.join(checkout), "/boundary/immutable/0", false) + .with(&self.retained(), "/boundary/immutable/1", false) + .with(&self.base.join("materials"), "/boundary/immutable/2", false) + .with(&self.base.join("tools"), "/boundary/tool/0", false) + .with(&self.base.join("cargo-state"), "/boundary/writable/0", true) + .with(&self.base.join("target"), "/boundary/writable/1", true) + } +} + +pub(super) fn plan_error(prepared: &PreparedRecipe, paths: &PrefixPaths) -> String { + match prepared.plan(paths) { + Ok(_) => panic!("should reject the plan"), + Err(error) => format!("{error:?}"), + } +} + +pub(super) fn bind_error(result: Result>) -> String { + match result { + Ok(_) => panic!("should reject the binding"), + Err(error) => format!("{error:?}"), + } +} + +pub(super) fn confinement_limits() -> ProcessLimits { + ProcessLimits { + wall: std::time::Duration::from_secs(60), + stdout_max: 1024, + stderr_max: 1024, + } +} + +pub(super) fn umask(mask: u32) -> Umask { + Umask::new(mask).expect("should accept mask") +} diff --git a/crates/trusted-server-release/src/recipe/tests/plan.rs b/crates/trusted-server-release/src/recipe/tests/plan.rs new file mode 100644 index 0000000000..6b33c80ec2 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/plan.rs @@ -0,0 +1,499 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +use super::super::paths::PrefixPaths; +use super::fixture::*; +use super::*; +use crate::policy::FIXED_ARTIFACTS; +use crate::preflight::audit_environment; + +/// The exact source-replacement pairs for the fixture's fictional Git source. +fn expected_replacement() -> Vec { + [ + "--config", + "source.crates-io.replace-with=\"ts-vendor\"", + "--config", + "source.\"git+https://example.com/org/workspace?tag=v1\".git=\"https://example.com/org/workspace\"", + "--config", + "source.\"git+https://example.com/org/workspace?tag=v1\".tag=\"v1\"", + "--config", + "source.\"git+https://example.com/org/workspace?tag=v1\".replace-with=\"ts-vendor\"", + "--config", + "source.ts-vendor.directory=\"/boundary/immutable/2/vendor\"", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +#[test] +fn cargo_plan_has_fixed_release_frozen_json_flags_and_exact_source_replacement() { + assert!( + !FIXED_ARTIFACTS.is_empty(), + "should select a nonzero number of assets" + ); + for (index, artifact) in FIXED_ARTIFACTS.iter().enumerate() { + let role = if index % 2 == 0 { Role::A } else { Role::B }; + let jobs = if role == Role::A { "4" } else { "2" }; + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind(artifact.name, role) + .expect("should bind every fixed asset"); + let plan = prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan"); + let mut expected_arguments: Vec = [ + "build", + "--package", + artifact.package, + "--bin", + artifact.bin, + "--target", + artifact.target.triple(), + "--release", + "--frozen", + ] + .into_iter() + .map(str::to_owned) + .collect(); + expected_arguments.extend(expected_replacement()); + expected_arguments.extend( + ["--message-format=json-render-diagnostics", "--jobs", jobs] + .into_iter() + .map(str::to_owned), + ); + assert_eq!( + plan.arguments, expected_arguments, + "should use the exact fixed arguments for {}", + artifact.name + ); + assert_eq!( + plan.program, + fixture.base.join("tools/bin/cargo"), + "should run the selected Cargo by its host path" + ); + let checkout = if role == Role::A { + "checkout-a" + } else { + "checkout-b" + }; + assert_eq!( + plan.working_directory, + fixture.base.join(checkout), + "should run in the role checkout by its host path" + ); + assert_environment(&plan.environment, &fixture, artifact.target); + prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan again"); + } +} + +fn assert_environment(actual: &BTreeMap, fixture: &Fixture, target: ReleaseTarget) { + let mut expected: BTreeMap = [ + ("AWS_LC_SYS_USE_SYSTEM", "0"), + ("CARGO_HOME", "/boundary/writable/0/cargo-home"), + ("CARGO_TARGET_DIR", "/boundary/writable/1"), + ("HOME", "/boundary/writable/0/home"), + ("LC_ALL", "C"), + ("LIBSQLITE3_SYS_USE_PKG_CONFIG", "0"), + ("PATH", "/boundary/tool/0/bin:/usr/bin:/bin"), + ("RUSTC", "/boundary/tool/0/bin/rustc"), + ("SOURCE_DATE_EPOCH", "1700000000"), + ("TMPDIR", "/boundary/writable/0/tmp"), + ("TRUSTED_SERVER__GIT_VERSION", COMMIT), + ("TRUSTED_SERVER__RELEASE_BUILD", "1"), + ( + "TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", + "/boundary/immutable/1/tsjs-inventory.json", + ), + ("TSJS_PREBUILT_DIR", "/boundary/immutable/1/bundles"), + ("TZ", "UTC"), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value.to_owned())) + .collect(); + expected.insert( + format!( + "AWS_LC_SYS_USE_SYSTEM_{}", + target.triple().replace('-', "_") + ), + "0".to_owned(), + ); + if target.is_darwin() { + expected.insert("MACOSX_DEPLOYMENT_TARGET".to_owned(), "13.0".to_owned()); + } + assert_eq!( + actual, &expected, + "should hold exactly the child environment with every path in the execution namespace" + ); + for (name, value) in actual { + assert!( + !value.contains(fixture.base.to_str().expect("should be UTF-8")), + "should not leak a host path into {name}" + ); + } +} + +/// One mapped location per path-carrying variable, so each can be omitted alone. +fn granular(fixture: &Fixture, omit: &[&str]) -> PrefixPaths { + let retained = fixture.retained(); + let all: [(&str, PathBuf, &str, bool); 9] = [ + ("target", fixture.base.join("target"), "/c/target", true), + ( + "cargo-home", + fixture.base.join("cargo-state/cargo-home"), + "/c/cargo-home", + true, + ), + ( + "home", + fixture.base.join("cargo-state/home"), + "/c/home", + true, + ), + ("tmp", fixture.base.join("cargo-state/tmp"), "/c/tmp", true), + ("bundles", retained.join("bundles"), "/c/bundles", false), + ( + "inventory", + retained.join("tsjs-inventory.json"), + "/c/inventory.json", + false, + ), + ( + "rustc", + fixture.base.join("tools/bin/rustc"), + "/c/rustc", + false, + ), + ("bin", fixture.base.join("tools/bin"), "/c/bin", false), + ( + "vendor", + fixture.base.join("materials/vendor"), + "/c/vendor", + false, + ), + ]; + all.iter().filter(|(name, ..)| !omit.contains(name)).fold( + PrefixPaths::new(), + |paths, (_, host, execution, writable)| paths.with(host, execution, *writable), + ) +} + +#[test] +fn path_variables_outside_every_root_are_rejected() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + prepared + .plan(&granular(&fixture, &[])) + .expect("should derive the plan when every location is mapped"); + let cases: [(&str, &[&str]); 8] = [ + ("CARGO_TARGET_DIR", &["target"]), + ("CARGO_HOME", &["cargo-home"]), + ("HOME", &["home"]), + ("TMPDIR", &["tmp"]), + ("TSJS_PREBUILT_DIR", &["bundles"]), + ("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", &["inventory"]), + ("RUSTC", &["rustc", "bin"]), + ("vendor directory", &["vendor"]), + ]; + for (label, omit) in cases { + let message = plan_error(&prepared, &granular(&fixture, omit)); + assert!( + message.contains(label) && message.contains("outside the boundary roots"), + "should reject {label} outside the roots: {message}" + ); + } + let message = plan_error(&prepared, &granular(&fixture, &["bin"])); + assert!( + message.contains("PATH") && message.contains("outside the boundary roots"), + "should reject the tool directory in PATH outside the roots: {message}" + ); +} + +#[test] +fn child_home_and_cargo_home_must_be_the_request_state() { + let fixture = Fixture::new(COMMIT); + let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let commit = CommitId::parse(COMMIT).expect("should parse commit"); + let mut forged = child_request(&request); + forged.home = fixture.base.join("other-work/home"); + let child = + crate::preflight::child::build_child_for_tests(&audited(), &commit, 1_700_000_000, &forged) + .expect("should build a child with another HOME"); + let error = PreparedRecipe::bind(BindInputs { + commit: &commit, + child, + completed: &fixture.completed, + tools: fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin), + materials: fixture.materials(COMMIT), + ambient: fixture.ambient(Role::A), + request, + }); + assert!( + bind_error(error).contains("HOME is not the request's private cargo state"), + "should reject a child HOME outside the request state" + ); +} + +#[test] +fn cargo_plan_rejects_inherited_rustflags_and_selectors() { + for name in [ + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_TARGET", + "RUSTUP_TOOLCHAIN", + "CC", + "TSJS_SKIP_BUILD", + ] { + let snapshot = [(OsString::from(name), OsString::from("fictional"))]; + let rejection = match audit_environment(&snapshot) { + Ok(_) => panic!("should reject inherited {name}"), + Err(error) => format!("{error:?}"), + }; + assert!( + rejection.contains(name), + "should reject inherited {name} before any plan exists" + ); + } + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-x86_64-unknown-linux-gnu", Role::A) + .expect("should bind from a benign environment"); + let plan = prepared + .plan(&fixture.paths(Role::A)) + .expect("should derive the plan"); + for name in plan.environment.keys() { + assert!( + name == "RUSTC" || (!name.starts_with("RUST") && !name.starts_with("CARGO_BUILD")), + "should carry no selector besides the explicit RUSTC: {name}" + ); + } + let configs: Vec<&String> = plan + .arguments + .iter() + .enumerate() + .filter(|(_, argument)| *argument == "--config") + .filter_map(|(index, _)| plan.arguments.get(index + 1)) + .collect(); + assert_eq!( + configs.len(), + expected_replacement().len() / 2, + "should carry only the exact source-replacement configs" + ); + for argument in &plan.arguments { + assert!( + ![ + "--features", + "--all-features", + "--no-default-features", + "--manifest-path", + "--config=", + ] + .iter() + .any(|selector| argument.starts_with(selector)) + && !argument.starts_with('+'), + "should not carry selector argument {argument}" + ); + } + assert!( + plan.arguments + .iter() + .any(|argument| argument == "--release") + && plan.arguments.iter().any(|argument| argument == "--frozen"), + "should keep the release and frozen flags" + ); +} + +#[test] +fn plan_digest_binds_every_part_of_the_plan() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + let paths = fixture.paths(Role::A); + let plan = prepared.plan(&paths).expect("should derive the plan"); + let digest = plan.digest().expect("should digest the plan"); + assert_eq!(digest.len(), 64, "should be a SHA-256 hex digest"); + assert_eq!( + digest, + prepared + .plan(&paths) + .expect("should derive the plan again") + .digest() + .expect("should digest again"), + "should be a pure function of the bound inputs" + ); + type Edit = fn(&mut CargoPlan); + let edits: [(&str, Edit); 6] = [ + ("argument", |p| p.arguments.push("--offline".to_owned())), + ("dropped argument", |p| { + p.arguments.retain(|argument| argument != "--release"); + }), + ("environment value", |p| { + p.environment + .insert("RUSTC".to_owned(), "/other/rustc".to_owned()); + }), + ("injected variable", |p| { + p.environment + .insert("RUSTFLAGS".to_owned(), "--cfg x".to_owned()); + }), + ("program", |p| p.program = PathBuf::from("/other/cargo")), + ("umask", |p| p.umask = umask(0o000)), + ]; + for (label, edit) in edits { + let mut changed = prepared.plan(&paths).expect("should derive the plan"); + edit(&mut changed); + assert_ne!( + changed.digest().expect("should digest the edited plan"), + digest, + "should change the digest for an edited {label}" + ); + } + let mut moved = prepared.plan(&paths).expect("should derive the plan"); + moved.working_directory = fixture.base.join("checkout-b"); + assert_ne!( + moved.digest().expect("should digest the moved plan"), + digest, + "should change the digest for another working directory" + ); +} + +#[test] +fn plan_carries_the_role_umask() { + for (role, expected) in [(Role::A, 0o022), (Role::B, 0o027)] { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", role) + .expect("should bind the recipe"); + assert_eq!( + prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan") + .umask, + umask(expected), + "should derive the umask of role {role:?} from its ambient profile" + ); + } +} + +#[test] +fn confined_command_applies_the_role_umask_and_keeps_host_program_paths() { + for (role, expected) in [(Role::A, "022"), (Role::B, "027")] { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", role) + .expect("should bind the recipe"); + let plan = prepared + .plan(&fixture.paths(role)) + .expect("should derive the plan"); + let command = plan + .confined_command(confinement_limits()) + .expect("should describe the confined command"); + assert_eq!( + command + .requested_umask() + .map(|umask| umask.to_string()) + .as_deref(), + Some(expected), + "should set the umask of role {role:?} on the confined command" + ); + let described = format!("{command:?}"); + for part in [ + format!("{:?}", plan.program), + format!("{:?}", plan.working_directory), + format!( + "{:?}", + plan.arguments + .iter() + .map(OsString::from) + .collect::>() + ), + ] { + assert!( + described.contains(&part), + "should describe {part} in {described}" + ); + } + } +} + +#[cfg(target_os = "macos")] +#[test] +fn real_boundary_translates_every_plan_path() { + use crate::boundary::Boundary; + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + let roots = prepared.boundary_roots().expect("should derive roots"); + let boundary = Boundary::mac_sandbox_for_tests(roots).expect("should configure, not run"); + let plan = prepared + .plan(&boundary) + .expect("should derive the plan through the boundary"); + assert_eq!( + plan.environment.get("CARGO_TARGET_DIR").map(String::as_str), + fixture.base.join("target").to_str(), + "should map writable roots to themselves on macOS" + ); + assert!( + plan.environment["PATH"].starts_with( + fixture + .base + .join("tools/bin") + .to_str() + .expect("should be UTF-8") + ), + "should put the selected tool directory first in PATH" + ); +} + +#[test] +fn execution_paths_with_control_characters_or_separators_are_rejected() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + for bad in [ + "/c/bin\nx", + "/c/bin\tx", + "/c/bin\u{7f}x", + "/c/bi:n", + "/c/bi\"n", + "/c/bi\\n", + ] { + let paths = PrefixPaths::new() + .with(&fixture.base.join("tools"), bad, false) + .with(&fixture.retained(), "/c/retained", false) + .with(&fixture.base, "/c/base", true); + let message = plan_error(&prepared, &paths); + assert!( + message.contains("not plain absolute UTF-8"), + "should reject execution path {bad:?}: {message}" + ); + } +} + +#[test] +fn fake_host_path_rejects_non_normal_components_like_the_real_inverse() { + let paths = PrefixPaths::new().with(std::path::Path::new("/host/target"), "/c/target", true); + assert!( + paths + .host_path(std::path::Path::new("/c/target/release/ts")) + .is_ok() + ); + for bad in [ + "/c/target/../target/ts", + "/c/target/./ts", + "/c/target//ts", + "/c/target/ts/", + ] { + assert!( + paths.host_path(std::path::Path::new(bad)).is_err(), + "should reject {bad}" + ); + } +} diff --git a/crates/trusted-server-release/src/recipe/tests/sources.rs b/crates/trusted-server-release/src/recipe/tests/sources.rs new file mode 100644 index 0000000000..97b0ebe04d --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/sources.rs @@ -0,0 +1,126 @@ +use super::super::sources::source_replacement_arguments; +use crate::materials::GitSourceConfig; + +fn parse(selector: &str) -> GitSourceConfig { + GitSourceConfig::parse(selector).expect("should parse the fictional selector") +} + +#[test] +fn replacement_argv_is_exact_for_registry_only() { + assert_eq!( + source_replacement_arguments("/boundary/immutable/2/vendor", &[]), + [ + "--config", + "source.crates-io.replace-with=\"ts-vendor\"", + "--config", + "source.ts-vendor.directory=\"/boundary/immutable/2/vendor\"", + ], + "should replace only crates.io when no Git source is locked" + ); +} + +#[test] +fn replacement_argv_is_exact_for_every_git_reference_kind() { + let sources = [ + parse("git+https://example.com/org/tagged?tag=v1.2.3"), + parse("git+https://example.com/org/branched?branch=release-1"), + parse("git+https://example.com/org/pinned?rev=abc123"), + parse("git+https://example.com/org/default"), + ]; + let expected = [ + "--config", + "source.crates-io.replace-with=\"ts-vendor\"", + "--config", + "source.\"git+https://example.com/org/tagged?tag=v1.2.3\".git=\"https://example.com/org/tagged\"", + "--config", + "source.\"git+https://example.com/org/tagged?tag=v1.2.3\".tag=\"v1.2.3\"", + "--config", + "source.\"git+https://example.com/org/tagged?tag=v1.2.3\".replace-with=\"ts-vendor\"", + "--config", + "source.\"git+https://example.com/org/branched?branch=release-1\".git=\"https://example.com/org/branched\"", + "--config", + "source.\"git+https://example.com/org/branched?branch=release-1\".branch=\"release-1\"", + "--config", + "source.\"git+https://example.com/org/branched?branch=release-1\".replace-with=\"ts-vendor\"", + "--config", + "source.\"git+https://example.com/org/pinned?rev=abc123\".git=\"https://example.com/org/pinned\"", + "--config", + "source.\"git+https://example.com/org/pinned?rev=abc123\".rev=\"abc123\"", + "--config", + "source.\"git+https://example.com/org/pinned?rev=abc123\".replace-with=\"ts-vendor\"", + "--config", + "source.\"git+https://example.com/org/default\".git=\"https://example.com/org/default\"", + "--config", + "source.\"git+https://example.com/org/default\".replace-with=\"ts-vendor\"", + "--config", + "source.ts-vendor.directory=\"/boundary/immutable/2/vendor\"", + ]; + assert_eq!( + source_replacement_arguments("/boundary/immutable/2/vendor", &sources), + expected, + "should spell a complete source table for every Git reference kind" + ); +} + +#[test] +fn every_config_value_parses_as_a_toml_assignment() { + let sources = [parse("git+https://example.com/org/tagged?tag=v1.2.3")]; + let arguments = source_replacement_arguments("/boundary/immutable/2/vendor", &sources); + let entries: Vec<&String> = arguments + .iter() + .enumerate() + .filter(|(index, _)| index % 2 == 1) + .map(|(_, entry)| entry) + .collect(); + assert_eq!( + entries.len() * 2, + arguments.len(), + "should pair every value with --config" + ); + for (index, entry) in entries.iter().enumerate() { + assert_eq!( + arguments[index * 2], + "--config", + "should lead every value with --config" + ); + let table: toml::Table = toml::from_str(entry).expect("should parse as a TOML assignment"); + assert_eq!(table.len(), 1, "should assign one top-level table"); + assert!( + table.contains_key("source"), + "should configure only the source table: {entry}" + ); + } +} + +#[test] +fn tracked_lockfile_sources_yield_complete_tables() { + let lock = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.lock")) + .expect("should read the tracked Cargo.lock"); + let text = std::str::from_utf8(&lock).expect("should be UTF-8"); + let selectors: std::collections::BTreeSet = text + .lines() + .filter_map(|line| line.strip_prefix("source = \"git+")) + .filter_map(|rest| rest.strip_suffix('"')) + .filter_map(|rest| { + rest.rsplit_once('#') + .map(|(selector, _)| format!("git+{selector}")) + }) + .collect(); + assert!( + !selectors.is_empty(), + "should find a nonzero number of locked Git sources" + ); + let sources: Vec = selectors.iter().map(|selector| parse(selector)).collect(); + let arguments = source_replacement_arguments("/boundary/immutable/2/vendor", &sources); + for source in &sources { + for needle in [ + format!("source.\"{}\".git=\"{}\"", source.key(), source.url()), + format!("source.\"{}\".replace-with=\"ts-vendor\"", source.key()), + ] { + assert!( + arguments.contains(&needle), + "should spell {needle} for the tracked lock" + ); + } + } +} diff --git a/crates/trusted-server-release/src/recipe/tests/tools.rs b/crates/trusted-server-release/src/recipe/tests/tools.rs new file mode 100644 index 0000000000..8338716887 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/tools.rs @@ -0,0 +1,203 @@ +use std::fs; +use std::os::unix::fs::{PermissionsExt as _, symlink}; + +use super::fixture::*; +use super::*; +use crate::policy::FIXED_ARTIFACTS; + +fn rejection(fixture: &Fixture, target: ReleaseTarget) -> String { + match SelectedRustTools::for_tests(&fixture.base.join("tools"), target) { + Ok(_) => panic!("should reject the tools"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn every_asset_target_needs_its_own_standard_library_directory() { + assert!( + !FIXED_ARTIFACTS.is_empty(), + "should select a nonzero number of targets" + ); + for artifact in &FIXED_ARTIFACTS { + let fixture = Fixture::new(COMMIT); + let tools = fixture.tools_for(artifact.target); + assert_eq!( + tools.target(), + artifact.target, + "should record the checked target" + ); + let standard = fixture + .base + .join("tools/lib/rustlib") + .join(artifact.target.triple()) + .join("lib"); + fs::remove_dir_all(&standard).expect("should remove the target standard library"); + assert!( + rejection(&fixture, artifact.target).contains("standard library"), + "should reject {} without its standard library directory", + artifact.target.triple() + ); + } +} + +#[test] +fn other_targets_standard_library_does_not_satisfy_the_asset_target() { + let fixture = Fixture::new(COMMIT); + fs::remove_dir_all( + fixture + .base + .join("tools/lib/rustlib/aarch64-apple-darwin/lib"), + ) + .expect("should remove one target"); + SelectedRustTools::for_tests( + &fixture.base.join("tools"), + ReleaseTarget::X86_64UnknownLinuxGnu, + ) + .expect("should accept a target whose library is present"); + assert!( + rejection(&fixture, ReleaseTarget::Aarch64AppleDarwin).contains("standard library"), + "should reject the target whose library is absent although others are present" + ); +} + +#[test] +fn standard_library_directory_must_hold_a_libstd_file() { + let fixture = Fixture::new(COMMIT); + let standard = fixture.base.join("tools/lib/rustlib/wasm32-wasip1/lib"); + fs::remove_file(standard.join("libstd-0123456789abcdef.rlib")).expect("should remove libstd"); + assert!( + rejection(&fixture, ReleaseTarget::Wasm32Wasip1).contains("libstd"), + "should reject an empty standard library directory" + ); + fs::create_dir(standard.join("libstd-subdirectory")).expect("should add a directory"); + assert!( + rejection(&fixture, ReleaseTarget::Wasm32Wasip1).contains("libstd"), + "should reject a libstd-named directory" + ); + fs::write(standard.join("libcore-0123.rlib"), b"fictional").expect("should add libcore"); + assert!( + rejection(&fixture, ReleaseTarget::Wasm32Wasip1).contains("libstd"), + "should reject a directory with only other libraries" + ); +} + +#[test] +fn symlinked_or_missing_tool_layout_is_rejected() { + let fixture = Fixture::new(COMMIT); + let standard = fixture.base.join("tools/lib/rustlib/wasm32-wasip1/lib"); + fs::rename(&standard, fixture.base.join("real-std")).expect("should move the directory"); + symlink(fixture.base.join("real-std"), &standard).expect("should alias the directory"); + assert!( + rejection(&fixture, ReleaseTarget::Wasm32Wasip1).contains("symlink"), + "should reject a symlinked standard library directory" + ); + let fixture = Fixture::new(COMMIT); + fs::remove_file(fixture.base.join("tools/bin/rustc")).expect("should remove rustc"); + assert!( + rejection(&fixture, ReleaseTarget::Wasm32Wasip1).contains("Rust tool"), + "should reject missing rustc" + ); +} + +#[test] +fn tool_digest_detects_edits_hard_links_and_escaping_aliases() { + let fixture = Fixture::new(COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); + let baseline = tools.digest().expect("should digest the tools"); + assert_eq!( + baseline, + tools.digest().expect("should digest again"), + "should be stable" + ); + + fs::write( + fixture.base.join("tools/bin/cargo"), + b"fictional tool, edited", + ) + .expect("should edit cargo"); + let edited = tools.digest().expect("should digest the edited tools"); + assert_ne!(baseline, edited, "should detect an edited tool"); + fs::write(fixture.base.join("tools/bin/cargo"), b"fictional tool") + .expect("should restore cargo"); + assert_eq!( + baseline, + tools.digest().expect("should digest restored tools"), + "should return to the baseline" + ); + + fs::set_permissions( + fixture.base.join("tools/bin/rustc"), + fs::Permissions::from_mode(0o700), + ) + .expect("should chmod rustc"); + assert_ne!( + baseline, + tools.digest().expect("should digest chmod tools"), + "should detect a mode change" + ); + fs::set_permissions( + fixture.base.join("tools/bin/rustc"), + fs::Permissions::from_mode(0o644), + ) + .expect("should restore mode"); + + fs::hard_link( + fixture.base.join("tools/bin/cargo"), + fixture.base.join("tools/bin/cargo-link"), + ) + .expect("should hard-link cargo"); + assert!(tools.digest().is_err(), "should reject a hard-linked tool"); + fs::remove_file(fixture.base.join("tools/bin/cargo-link")).expect("should remove the link"); + + symlink("../../other-work", fixture.base.join("tools/escape")) + .expect("should add escaping alias"); + assert!( + tools.digest().is_err(), + "should reject an alias escaping the tool root" + ); +} + +#[test] +fn tools_edited_after_binding_are_rejected_at_the_execute_gate() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + prepared + .check_tools() + .expect("should accept the untouched tools"); + prepared + .check_materials() + .expect("should accept the untouched materials"); + fs::write(fixture.base.join("tools/bin/rustc"), b"swapped compiler") + .expect("should swap rustc"); + let error = prepared + .check_tools() + .expect_err("should reject the swapped tools"); + assert!( + format!("{error:?}").contains("Rust tools changed"), + "should name the changed tools" + ); + fs::write(fixture.base.join("tools/bin/rustc"), b"fictional tool") + .expect("should restore rustc"); + prepared + .check_tools() + .expect("should accept the restored tools"); + fs::write( + fixture + .base + .join("materials/vendor/example-1.0.0/Cargo.toml"), + b"swapped dependency", + ) + .expect("should swap a vendored file"); + assert!( + format!( + "{:?}", + prepared + .check_materials() + .expect_err("should reject swapped materials") + ) + .contains("changed after binding"), + "should name the changed materials" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tools.rs b/crates/trusted-server-release/src/recipe/tools.rs new file mode 100644 index 0000000000..51fe6a08eb --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tools.rs @@ -0,0 +1,161 @@ +//! The Rust toolchain a recipe builds with, and how its selection is gated. + +use std::fs; +use std::path::{Path, PathBuf}; + +use error_stack::Report; + +use super::fresh::{real_directory, real_file}; +use super::recipe_error; +use crate::error::ReleaseError; +use crate::policy::ReleaseTarget; +use crate::tool_tree::tool_tree_digest; + +/// A Rust toolchain that passed Task 8 qualification. +/// +/// None has, so this type is uninhabited and production selection stays closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum QualifiedRustTools {} + +/// Whether a selection carries release authority. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Authority { + /// A qualified toolchain; cannot be constructed while none is qualified. + Qualified, + /// An ordinary CI toolchain that proves nothing about release provenance. + Unqualified, +} + +/// How a toolchain was selected. +/// +/// Production builds can only name [`QualifiedRustTools`], which is +/// uninhabited, so no production value exists until a toolchain is qualified. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolSelection { + /// A qualified production selection. + Qualified(QualifiedRustTools), + /// An ordinary CI toolchain; test builds only. + #[cfg(test)] + OrdinaryCi, +} + +impl ToolSelection { + /// Returns the authority this selection carries. + pub(crate) fn authority(&self) -> Authority { + match self { + Self::Qualified(qualification) => match *qualification {}, + #[cfg(test)] + Self::OrdinaryCi => Authority::Unqualified, + } + } +} + +/// Fixed `bin/cargo`, `bin/rustc` and target standard-library layout, retained +/// as a measured candidate. +pub(crate) struct SelectedRustTools { + root: PathBuf, + target: ReleaseTarget, + selection: ToolSelection, +} + +/// Requires the fixed layout for `target` below the canonical `root`. +/// +/// The target's `lib/rustlib//lib` directory must hold a `libstd-*` +/// file, so a toolchain installed without the target's standard library is +/// rejected before any build starts. +fn check_layout(root: &Path, target: ReleaseTarget) -> Result<(), Report> { + for relative in ["bin/cargo", "bin/rustc"] { + real_file("Rust tool", &root.join(relative))?; + } + let standard = root.join("lib/rustlib").join(target.triple()).join("lib"); + real_directory("Rust target standard library directory", &standard)?; + let list_error = |error: std::io::Error| { + recipe_error(format!("cannot list the target standard library: {error}")) + }; + let mut has_std = false; + for entry in fs::read_dir(&standard).map_err(list_error)? { + let entry = entry.map_err(list_error)?; + let kind = entry.file_type().map_err(list_error)?; + has_std |= kind.is_file() && entry.file_name().to_string_lossy().starts_with("libstd-"); + } + if !has_std { + return Err(recipe_error(format!( + "Rust tools hold no libstd file for target {}", + target.triple() + ))); + } + Ok(()) +} + +impl SelectedRustTools { + /// Accepts only a qualified selection; production construction stays closed. + pub(crate) fn qualified(_root: &Path, qualification: QualifiedRustTools) -> Self { + match qualification {} + } + + /// Selects ordinary CI tools without asserting release qualification. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] unless `bin/cargo` and `bin/rustc` + /// are regular files and `lib/rustlib//lib` holds a `libstd-*` + /// file, all below the canonical `root`. + #[cfg(test)] + pub(crate) fn for_tests( + root: &Path, + target: ReleaseTarget, + ) -> Result> { + let root = fs::canonicalize(root) + .map_err(|error| recipe_error(format!("cannot resolve Rust tools: {error}")))?; + check_layout(&root, target)?; + Ok(Self { + root, + target, + selection: ToolSelection::OrdinaryCi, + }) + } + + /// The canonical tool root. + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// The target whose standard library the layout was checked for. + pub(crate) fn target(&self) -> ReleaseTarget { + self.target + } + + /// The authority of this selection. + pub(crate) fn authority(&self) -> Authority { + self.selection.authority() + } + + /// Host path of the selected Cargo. + pub(crate) fn cargo(&self) -> PathBuf { + self.root.join("bin/cargo") + } + + /// Host path of the selected compiler. + pub(crate) fn rustc(&self) -> PathBuf { + self.root.join("bin/rustc") + } + + /// Host path of the directory holding both tools. + pub(crate) fn bin_dir(&self) -> PathBuf { + self.root.join("bin") + } + + /// Digest of the whole tool tree: contents, modes, names and aliases. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for a hard-linked or special file, an + /// alias that escapes the root, or an exceeded limit. + pub(crate) fn digest(&self) -> Result> { + tool_tree_digest(&self.root, &|| Ok(())).map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("Rust tool tree cannot be digested") + }) + } +} diff --git a/crates/trusted-server-release/src/tool_tree.rs b/crates/trusted-server-release/src/tool_tree.rs new file mode 100644 index 0000000000..0b9c1d5918 --- /dev/null +++ b/crates/trusted-server-release/src/tool_tree.rs @@ -0,0 +1,109 @@ +//! Content digest of a selected tool tree, shared by the JS and Rust recipes. +//! +//! The digest covers every entry's relative path, kind, permission bits and, +//! for regular files, size and SHA-256. It rejects anything a later swap could +//! hide behind: hard-linked files (a second path can rewrite the content), +//! absolute or root-escaping symlinks, dangling symlinks, and special files. + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; +use std::path::Path; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; + +/// Per-file byte limit while hashing a tool tree. +const FILE_BYTE_LIMIT: u64 = 512 * 1024 * 1024; +/// Total byte limit across one tool tree. +const TREE_BYTE_LIMIT: u64 = 2 * 1024 * 1024 * 1024; +/// Entry limit across one tool tree. +const ENTRY_LIMIT: usize = 100_000; + +fn tree_error(detail: impl std::fmt::Display) -> Report { + Report::new(ReleaseError::Material).attach(detail.to_string()) +} + +/// Hashes the whole tree below `root`, calling `check` between entries and +/// file chunks so the owning attempt can cancel or expire it. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Material`] for a hard-linked or special file, a +/// non-UTF-8 name, an absolute, dangling or root-escaping symlink, an +/// exceeded byte or entry limit, or when `check` fails. +pub(crate) fn tool_tree_digest( + root: &Path, + check: &dyn Fn() -> Result<(), Report>, +) -> Result> { + let mut digest = Sha256::new(); + let mut total_bytes = 0_u64; + let mut pending = vec![root.to_owned()]; + let mut facts = BTreeMap::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory).map_err(tree_error)? { + check()?; + let entry = entry.map_err(tree_error)?; + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(tree_error)? + .to_str() + .ok_or_else(|| tree_error("tool path is not UTF-8"))? + .to_owned(); + let metadata = fs::symlink_metadata(&path).map_err(tree_error)?; + let fact = if metadata.is_dir() { + pending.push(path.clone()); + format!("directory:{}", metadata.permissions().mode() & 0o7777) + } else if metadata.is_file() && metadata.nlink() == 1 { + let raw = hash_bounded_with_check(&path, FILE_BYTE_LIMIT, check)?; + total_bytes += raw.size; + if total_bytes > TREE_BYTE_LIMIT { + return Err(tree_error("selected tool tree exceeds total byte limit")); + } + format!( + "file:{}:{}:{}", + metadata.permissions().mode() & 0o7777, + raw.size, + raw.sha256 + ) + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).map_err(tree_error)?; + if target.is_absolute() + || !fs::canonicalize(&path) + .map_err(tree_error)? + .starts_with(root) + { + return Err(tree_error("selected tool alias escapes its owned root")); + } + format!( + "alias:{}", + target + .to_str() + .ok_or_else(|| tree_error("tool alias is not UTF-8"))? + ) + } else { + return Err(tree_error( + "selected tool contains an unsafe file type or hardlink", + )); + }; + facts.insert(relative, fact); + if facts.len() > ENTRY_LIMIT { + return Err(tree_error("selected tool tree exceeds file limit")); + } + } + } + for (relative, fact) in facts { + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(fact.as_bytes()); + digest.update([0]); + } + Ok(hex::encode(digest.finalize())) +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-release/src/tool_tree/tests.rs b/crates/trusted-server-release/src/tool_tree/tests.rs new file mode 100644 index 0000000000..1466a15f81 --- /dev/null +++ b/crates/trusted-server-release/src/tool_tree/tests.rs @@ -0,0 +1,165 @@ +use std::fs; +use std::os::unix::fs::{PermissionsExt as _, symlink}; +use std::path::PathBuf; + +use super::*; + +/// A fictional tool tree below a canonical temporary root. +struct Tree { + _directory: tempfile::TempDir, + root: PathBuf, +} + +impl Tree { + fn new() -> Self { + let directory = tempfile::tempdir().expect("should create tree directory"); + let outer = fs::canonicalize(directory.path()).expect("should canonicalize"); + let root = outer.join("tools"); + fs::create_dir_all(root.join("bin")).expect("should create bin"); + fs::write(root.join("bin/tool"), b"fictional tool").expect("should write tool"); + fs::create_dir(outer.join("outside")).expect("should create outside directory"); + fs::write(outer.join("outside/secret"), b"outside").expect("should write outside file"); + Self { + _directory: directory, + root, + } + } + + fn digest(&self) -> Result> { + tool_tree_digest(&self.root, &|| Ok(())) + } + + fn rejection(&self) -> String { + match self.digest() { + Ok(_) => panic!("should reject the tree"), + Err(error) => format!("{error:?}"), + } + } +} + +#[test] +fn identical_trees_have_identical_digests_and_edits_change_them() { + let tree = Tree::new(); + let baseline = tree.digest().expect("should digest the tree"); + assert_eq!( + baseline, + tree.digest().expect("should digest again"), + "should be deterministic over an unchanged tree" + ); + assert_eq!(baseline.len(), 64, "should be a SHA-256 hex digest"); + + fs::write(tree.root.join("bin/tool"), b"fictional tool!").expect("should edit content"); + let edited = tree.digest().expect("should digest the edited tree"); + assert_ne!(baseline, edited, "should detect a content edit"); + + fs::write(tree.root.join("bin/tool"), b"fictional tool").expect("should restore content"); + assert_eq!( + baseline, + tree.digest().expect("should digest the restored tree"), + "should return to the baseline when content is restored" + ); + + fs::set_permissions( + tree.root.join("bin/tool"), + fs::Permissions::from_mode(0o755), + ) + .expect("should change mode"); + assert_ne!( + baseline, + tree.digest().expect("should digest the chmod tree"), + "should detect a permission change" + ); +} + +#[test] +fn added_and_renamed_entries_change_the_digest() { + let tree = Tree::new(); + let baseline = tree.digest().expect("should digest the tree"); + fs::rename(tree.root.join("bin/tool"), tree.root.join("bin/renamed")) + .expect("should rename the tool"); + assert_ne!( + baseline, + tree.digest().expect("should digest the renamed tree"), + "should detect a rename" + ); + fs::rename(tree.root.join("bin/renamed"), tree.root.join("bin/tool")) + .expect("should restore the name"); + fs::write(tree.root.join("extra"), b"").expect("should add an entry"); + assert_ne!( + baseline, + tree.digest().expect("should digest the extended tree"), + "should detect an added empty file" + ); +} + +#[test] +fn hard_linked_files_are_rejected() { + let tree = Tree::new(); + fs::hard_link(tree.root.join("bin/tool"), tree.root.join("bin/alias")) + .expect("should link inside the tree"); + assert!( + tree.rejection().contains("hardlink"), + "should reject a second link inside the tree" + ); + let tree = Tree::new(); + let outside = tree + .root + .parent() + .expect("should have parent") + .join("outside/secret"); + fs::hard_link(&outside, tree.root.join("bin/leak")).expect("should link from outside"); + assert!( + tree.rejection().contains("hardlink"), + "should reject a link shared with a path outside the tree" + ); +} + +#[test] +fn escaping_dangling_or_absolute_aliases_are_rejected() { + let cases: [(&str, &str); 3] = [ + ("relative escape", "../../outside/secret"), + ("dangling", "missing"), + ("absolute", "/usr/bin/true"), + ]; + for (label, target) in cases { + let tree = Tree::new(); + symlink(target, tree.root.join("bin/alias")).expect("should create alias"); + let rejection = tree.rejection(); + assert!( + rejection.contains("alias") || rejection.contains("No such file"), + "should reject the {label} alias: {rejection}" + ); + } +} + +#[test] +fn inside_relative_alias_is_accepted_and_recorded() { + let tree = Tree::new(); + let baseline = tree.digest().expect("should digest the tree"); + symlink("tool", tree.root.join("bin/alias")).expect("should create inside alias"); + let with_alias = tree + .digest() + .expect("should accept an inside relative alias"); + assert_ne!( + baseline, with_alias, + "should record the alias in the digest" + ); + fs::remove_file(tree.root.join("bin/alias")).expect("should remove alias"); + symlink("./tool", tree.root.join("bin/alias")).expect("should create alias with other text"); + assert_ne!( + with_alias, + tree.digest().expect("should digest the other alias"), + "should bind the alias target text" + ); +} + +#[test] +fn failing_check_stops_the_digest() { + let tree = Tree::new(); + let error = tool_tree_digest(&tree.root, &|| Err(tree_error("cancelled"))) + .expect_err("should stop when the owning attempt fails"); + assert!( + format!("{error:?}").contains("cancelled"), + "should surface the check failure" + ); +} From 2d933b0e9debed3960f97f2cc3ebb5948da17471 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 09:48:16 +0530 Subject: [PATCH 069/153] Run a confined candidate Cargo build of the Fastly Wasm in Linux CI Open PreparedRecipe::execute for the test-only ordinary-CI tool selection only; production selections still return Unqualified. A run rechecks the JS handoff, tools and materials before and after, refuses Cargo registry or Git caches in CARGO_HOME, runs the plan through run_confined with bounded streams and deadline, and completes into a RecipeCompleted. Registry crates may use the GNU ././@LongLink long-name record (regular mode, exact 100-byte prefix join, bounded, never consecutive, dangling or mixed with PAX) and tar modes carrying regular or directory type bits; npm originals keep the stricter policy. Git packages are vendored under their bare name so intra-workspace relative paths resolve. Add an unauthenticated ordinary-CI fixture (copied sysroot with wasm32-wasip1, fetched crate originals, Git objects, cargo-vendor normalized manifests paired with their original blobs, gcc image), a denial fixture workspace whose build scripts, a grandchild and a proc macro attempt a connection, and two ignored Linux tests: denial inheritance and a real JS handoff into the Fastly Cargo build. A new CI job runs both and prints measurements with release_qualification false. --- .github/workflows/test.yml | 86 +++ .../trusted-server-release/src/materials.rs | 2 + .../src/materials/cargo_vendor.rs | 42 +- .../src/materials/cargo_vendor/tests/git.rs | 92 +++- .../src/materials/extract.rs | 36 +- .../src/materials/extract/compatibility.rs | 68 ++- .../src/materials/extract/tests.rs | 189 +------ .../materials/extract/tests/cargo_package.rs | 154 ++++++ .../extract/tests/npm_compatibility.rs | 177 ++++++ crates/trusted-server-release/src/policy.rs | 31 ++ .../trusted-server-release/src/preparation.rs | 2 +- .../src/preparation/tests.rs | 2 +- .../src/preparation/tests/full.rs | 75 ++- crates/trusted-server-release/src/recipe.rs | 240 ++------ .../src/recipe/completed.rs | 12 + .../src/recipe/execute.rs | 297 ++++++++++ .../src/recipe/tests.rs | 3 + .../src/recipe/tests/candidate.rs | 520 ++++++++++++++++++ .../src/recipe/tests/complete.rs | 58 ++ .../src/recipe/tests/full.rs | 406 ++++++++++++++ .../src/recipe/tests/tools.rs | 63 +++ .../src/recipe/tools.rs | 18 + .../tests/fixtures/cargo-denial/Cargo.lock | 19 + .../tests/fixtures/cargo-denial/Cargo.toml | 18 + .../crates/build-direct/Cargo.toml | 6 + .../cargo-denial/crates/build-direct/build.rs | 13 + .../crates/build-direct/src/main.rs | 3 + .../crates/build-grandchild/Cargo.toml | 6 + .../crates/build-grandchild/build.rs | 29 + .../crates/build-grandchild/src/main.rs | 3 + .../cargo-denial/crates/control/Cargo.toml | 6 + .../cargo-denial/crates/control/build.rs | 5 + .../cargo-denial/crates/control/src/main.rs | 5 + .../crates/macro-attempt/Cargo.toml | 12 + .../crates/macro-attempt/src/lib.rs | 17 + .../crates/macro-attempt/src/main.rs | 5 + .../release/tests/cargo-fixture.Dockerfile | 6 + .../release/tests/prepare-cargo-fixture.mjs | 353 ++++++++++++ 38 files changed, 2683 insertions(+), 396 deletions(-) create mode 100644 crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs create mode 100644 crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs create mode 100644 crates/trusted-server-release/src/recipe/execute.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/candidate.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/full.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs create mode 100644 crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs create mode 100644 scripts/release/tests/cargo-fixture.Dockerfile create mode 100644 scripts/release/tests/prepare-cargo-fixture.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1d58ac83f..3a912e13c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -390,6 +390,92 @@ jobs: cargo test -p trusted-server-js --target "$HOST_TARGET" --test release_prebuilt cargo clippy -p trusted-server-js --target "$HOST_TARGET" --all-targets --all-features -- -D warnings + # Ordinary CI candidate evidence of the confined Cargo recipe; this job is + # not release qualification (every measurement reports it as such). + test-release-cargo-candidate: + name: cargo test (confined release Cargo candidate, Linux) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + # Optimize repeated whole-input hashes with test safety checks retained. + CARGO_PROFILE_TEST_OPT_LEVEL: '2' + CARGO_PROFILE_TEST_DEBUG_ASSERTIONS: 'true' + CARGO_PROFILE_TEST_OVERFLOW_CHECKS: 'true' + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 (Node 24) + with: + persist-credentials: false + # Full history: the fixtures clone this checkout, and source + # authentication rejects a shallow repository (`.git/shallow`). + fetch-depth: 0 + + - name: Read pinned tool versions + id: tools + shell: bash + run: | + set -euo pipefail + echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + - name: Use pinned Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24) + with: + node-version: ${{ steps.tools.outputs.node }} + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + + # The runner's installed rustup bootstraps ordinary CI, not the release producer. + - name: Install pinned native Rust toolchain with the Wasm target + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + shell: bash + run: | + set -euo pipefail + rustup toolchain install "$RUST_VERSION" --profile minimal + rustup target add --toolchain "$RUST_VERSION" wasm32-wasip1 + rustup override set "$RUST_VERSION" + HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" + test -n "$HOST_TARGET" + echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV" + + - name: Prepare ordinary CI JS and Cargo fixtures + shell: bash + run: | + set -euo pipefail + cargo build -p trusted-server-release --target "$HOST_TARGET" --locked + PRODUCER="target/$HOST_TARGET/debug/trusted-server-release" + JS_FIXTURE_ROOT="$RUNNER_TEMP/release-js-original-fixture" + CARGO_FIXTURE_ROOT="$RUNNER_TEMP/release-cargo-fixture" + node scripts/release/tests/prepare-js-fixture.mjs "$JS_FIXTURE_ROOT" "$PRODUCER" + node scripts/release/tests/prepare-cargo-fixture.mjs "$CARGO_FIXTURE_ROOT" "$PRODUCER" + echo "TS_RELEASE_JS_FIXTURE_ROOT=$JS_FIXTURE_ROOT" >> "$GITHUB_ENV" + echo "TS_RELEASE_CARGO_FIXTURE_ROOT=$CARGO_FIXTURE_ROOT" >> "$GITHUB_ENV" + df -h "$RUNNER_TEMP" + + - name: Prove Cargo build scripts and proc macros inherit denial (Linux) + shell: bash + run: | + set -euo pipefail + TEST_NAME=recipe::tests::full::cargo_children_inherit_denial + TEST_LIST="$(mktemp)" + trap 'rm -f "$TEST_LIST"' EXIT + cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Cargo denial fixture"; exit 1 } }' "$TEST_LIST" + cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture + cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-denial-measurement.json" + + - name: Build the Fastly Wasm from a real JS handoff through the confined recipe (Linux) + shell: bash + run: | + set -euo pipefail + TEST_NAME=recipe::tests::full::real_js_handoff_to_fastly_cargo_build + TEST_LIST="$(mktemp)" + trap 'rm -f "$TEST_LIST"' EXIT + cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one confined Cargo build fixture"; exit 1 } }' "$TEST_LIST" + cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture + cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-execution-measurement.json" + test-typescript: name: vitest runs-on: ubuntu-latest diff --git a/crates/trusted-server-release/src/materials.rs b/crates/trusted-server-release/src/materials.rs index f47c731746..9acf8c345d 100644 --- a/crates/trusted-server-release/src/materials.rs +++ b/crates/trusted-server-release/src/materials.rs @@ -146,6 +146,7 @@ impl LockedMaterials { index_alias, node_tar_timestamps, local_pax: locked.name == "gulp-wrap" && locked.version == "0.15.0", + cargo_package: false, }, ) } @@ -176,6 +177,7 @@ impl LockedMaterials { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: true, }, ) } diff --git a/crates/trusted-server-release/src/materials/cargo_vendor.rs b/crates/trusted-server-release/src/materials/cargo_vendor.rs index 33a60ac20d..26258162ec 100644 --- a/crates/trusted-server-release/src/materials/cargo_vendor.rs +++ b/crates/trusted-server-release/src/materials/cargo_vendor.rs @@ -2,10 +2,11 @@ //! //! Registry crates come from archives already authenticated against the //! approved `Cargo.lock` checksum and extracted under the archive policy. -//! Git packages come from a verified object closure. Each package becomes -//! `vendor/-/` with a producer-written `.cargo-checksum.json` -//! (see [`writer`]). The result is a [`PreparedCargoMaterials`] token whose -//! digest covers the whole vendor tree. +//! Git packages come from a verified object closure. Each registry crate +//! becomes `vendor/-/` and each Git package `vendor//` +//! (so sibling paths inside its own workspace still resolve), with a +//! producer-written `.cargo-checksum.json` (see [`writer`]). The result is a +//! [`PreparedCargoMaterials`] token whose digest covers the whole vendor tree. //! //! # Normalized Git manifests //! @@ -208,18 +209,35 @@ fn attempt_check( } /// Requires a vendor directory name that cannot escape or hide. -fn directory_name(name: &str, version: &str) -> Result> { - let joined = format!("{name}-{version}"); - let plain = !joined.starts_with('.') - && joined +fn plain_directory(directory: String) -> Result> { + let plain = !directory.is_empty() + && !directory.starts_with('.') + && directory .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'+')); if !plain { return Err(material_error(format!( - "package identity {joined:?} is not a safe vendor directory name" + "package identity {directory:?} is not a safe vendor directory name" ))); } - Ok(joined) + Ok(directory) +} + +/// Names a registry crate's vendor directory `-`. +fn directory_name(name: &str, version: &str) -> Result> { + plain_directory(format!("{name}-{version}")) +} + +/// Names a Git package's vendor directory after the package alone. +/// +/// This is the layout `cargo vendor` gives a unique name, and it keeps paths +/// between sibling members of the package's own workspace resolving: a +/// package that reads `..//...` from its manifest directory, as a +/// checkout allows, finds that sibling in the vendor directory too. A +/// registry crate or second Git package with the same directory name still +/// fails closed, because every vendor directory is created exclusively. +fn git_directory_name(name: &str) -> Result> { + plain_directory(name.to_owned()) } /// Requires a real, empty directory that no symlink aliases. @@ -417,7 +435,7 @@ impl LockedMaterials { attempt_check(deadline, cancellation)?; let input = self.matching_git_input(package, inputs)?; if let Some(digest) = vendor_git(package, input, &vendor)? { - derived.insert(directory_name(&package.name, &package.version)?, digest); + derived.insert(git_directory_name(&package.name)?, digest); } } attempt_check(deadline, cancellation)?; @@ -467,7 +485,7 @@ fn vendor_git( input: &GitVendorInput<'_>, vendor: &Path, ) -> Result, Report> { - let directory = directory_name(&package.name, &package.version)?; + let directory = git_directory_name(&package.name)?; let store = Store::new(input.material.object_store()); let root = find_package(&store, input.material.root_tree(), &package.name)?; let committed = store.read("blob", &root.manifest, MANIFEST_LIMIT)?; diff --git a/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs b/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs index 944926a7b4..cd6f17ecb5 100644 --- a/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs +++ b/crates/trusted-server-release/src/materials/cargo_vendor/tests/git.rs @@ -116,7 +116,7 @@ fn plain_manifest_package_is_vendored_without_derivation() { let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); let result = run(&locked_core(), &material, None); let materials = result.outcome.expect("should vendor the plain package"); - let package = result.root.join("vendor/example-core-0.4.0"); + let package = result.root.join("vendor/example-core"); assert_eq!( fs::read(package.join("Cargo.toml")).expect("should read manifest"), PLAIN.as_bytes(), @@ -193,7 +193,7 @@ fn workspace_inheritance_requires_a_matching_normalized_manifest() { normalized(NORMALIZED, INHERITING), ); let materials = accepted.outcome.expect("should accept the normalized pair"); - let package = accepted.root.join("vendor/example-core-0.4.0"); + let package = accepted.root.join("vendor/example-core"); assert_eq!( fs::read(package.join("Cargo.toml")).expect("should read manifest"), NORMALIZED.as_bytes(), @@ -206,9 +206,7 @@ fn workspace_inheritance_requires_a_matching_normalized_manifest() { ); let digest = hex::encode(Sha256::digest(NORMALIZED.as_bytes())); assert_eq!( - materials - .derived_manifest_digests() - .get("example-core-0.4.0"), + materials.derived_manifest_digests().get("example-core"), Some(&digest), "should record the normalized manifest digest as derived" ); @@ -462,3 +460,87 @@ fn store_objects_are_rehashed_before_use() { "should reject a root tree absent from the store" ); } + +#[test] +fn git_package_directory_is_its_bare_name_and_never_shared() { + let built = workspace(PLAIN, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let accepted = run(&locked_core(), &material, None); + accepted.outcome.expect("should vendor the Git package"); + assert!( + accepted + .root + .join("vendor/example-core/Cargo.toml") + .is_file() + && !accepted.root.join("vendor/example-core-0.4.0").exists(), + "should name the Git package directory after the package alone" + ); + + let registry_crate = plain_crate("example", "core"); + let locked = locked( + &[("example", "core", ®istry_crate)], + vec![git_package("example-core", "0.4.0")], + ); + let archive_directory = temporary(); + let registry = archives( + archive_directory.path(), + &[("example", "core", ®istry_crate)], + ); + let output = temporary(); + let inputs = [GitVendorInput { + material: &material, + name: "example-core", + version: "0.4.0", + normalized: None, + }]; + let error = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: ®istry, + git: &inputs, + }, + output.path(), + deadline(), + &cancellation(), + ) + .expect_err("should refuse a Git directory that a registry crate already holds"); + assert!( + format!("{error:?}").contains("cannot be created exclusively"), + "should fail closed on the shared directory: {error:?}" + ); +} + +#[test] +fn git_packages_sharing_a_name_fail_closed() { + let built = workspace(INHERITING, &[]); + let material = built.objects.material(SELECTOR, REVISION, &built.root_tree); + let locked = locked( + &[], + vec![ + git_package("example-core", "0.4.0"), + git_package("example-core", "0.5.0"), + ], + ); + let inputs = ["0.4.0", "0.5.0"].map(|version| GitVendorInput { + material: &material, + name: "example-core", + version, + normalized: normalized(&NORMALIZED.replace("0.4.0", version), INHERITING), + }); + let output = temporary(); + let error = locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &Default::default(), + git: &inputs, + }, + output.path(), + deadline(), + &cancellation(), + ) + .expect_err("should refuse a second Git package in the same bare-name directory"); + assert!( + format!("{error:?}").contains("cannot be created exclusively"), + "should fail closed on the shared directory: {error:?}" + ); +} diff --git a/crates/trusted-server-release/src/materials/extract.rs b/crates/trusted-server-release/src/materials/extract.rs index a5101b67c3..debbfc9573 100644 --- a/crates/trusted-server-release/src/materials/extract.rs +++ b/crates/trusted-server-release/src/materials/extract.rs @@ -21,6 +21,11 @@ pub(super) struct Selection<'a> { pub(super) index_alias: bool, pub(super) node_tar_timestamps: bool, pub(super) local_pax: bool, + /// Accepts the two encodings `cargo package` itself writes into `.crate` + /// archives: a GNU long-name record before a member whose path exceeds + /// the 100-byte header field, and (in older Cargo releases) the regular + /// file or directory type bits inside the mode field. + pub(super) cargo_package: bool, } mod compatibility; @@ -339,6 +344,7 @@ impl State { &mut self, mut entry: tar::Entry<'_, R>, selection: &Selection<'_>, + long_name: Option, ) -> Result<(), Report> { let header = entry.header(); let directory = header.entry_type().is_dir(); @@ -347,13 +353,21 @@ impl State { "TAR member is not a regular file or directory", )); } - let raw = raw_name(header, selection)?; + let raw = match long_name { + Some(long) => compatibility::join_long_name(long, &raw_name(header, selection)?)?, + None => raw_name(header, selection)?, + }; let name = member_name(&raw, directory, selection)?; if !self.spellings.insert(raw.clone()) { return Err(material_error("TAR repeats a member spelling")); } self.portable_name(&name)?; let mode = header.mode().map_err(material_error)?; + let mode = if selection.cargo_package { + compatibility::strip_member_type(mode, directory)? + } else { + mode + }; let size = entry.size(); if mode & !0o777 != 0 || (directory && size != 0) { return Err(material_error(format!( @@ -478,6 +492,7 @@ fn extract_bounded( directories: BTreeSet::new(), }; let mut pending_pax: Option = None; + let mut pending_long: Option = None; for (index, entry) in archive .entries() .map_err(material_error)? @@ -488,7 +503,21 @@ fn extract_bounded( return Err(material_error("TAR exceeds entry count limit")); } let mut entry = entry.map_err(material_error)?; + if entry.header().entry_type().is_gnu_longname() { + if !selection.cargo_package || pending_long.is_some() || pending_pax.is_some() { + return Err(material_error( + "unreviewed, consecutive or mixed GNU long-name record", + )); + } + pending_long = Some(compatibility::read_long_name(&mut entry, PATH_LIMIT)?); + continue; + } if entry.header().entry_type().as_byte() == b'x' { + if pending_long.is_some() { + return Err(material_error( + "GNU long-name record must immediately precede its member", + )); + } if !selection.local_pax || pending_pax.is_some() || entry.size() > 64 * 1024 { return Err(material_error( "unreviewed, consecutive or oversized local PAX header", @@ -517,11 +546,14 @@ fn extract_bounded( entry.header().size().map_err(material_error)?, )?; } - state.entry(entry, &selection)?; + state.entry(entry, &selection, pending_long.take())?; } if pending_pax.is_some() { return Err(material_error("dangling local PAX header")); } + if pending_long.is_some() { + return Err(material_error("dangling GNU long-name record")); + } if state.spellings.contains("package/./dist/index.js") && !state.spellings.contains("package/dist/index.js") { diff --git a/crates/trusted-server-release/src/materials/extract/compatibility.rs b/crates/trusted-server-release/src/materials/extract/compatibility.rs index 42a1a1ec4c..f4dc546d09 100644 --- a/crates/trusted-server-release/src/materials/extract/compatibility.rs +++ b/crates/trusted-server-release/src/materials/extract/compatibility.rs @@ -1,4 +1,5 @@ -//! Closed compatibility for authenticated, exact-version npm originals. +//! Closed compatibility for authenticated, exact-version npm originals and +//! for the encodings `cargo package` writes into registry crates. use super::{field, material_error}; use crate::error::ReleaseError; use error_stack::Report; @@ -151,3 +152,68 @@ pub(super) fn parse_pax(mut bytes: &[u8]) -> Result( + entry: &mut tar::Entry<'_, R>, + limit: usize, +) -> Result> { + let header = entry.header(); + let mode = strip_member_type(header.mode().map_err(material_error)?, false)?; + if field(&header.as_old().name)? != LONG_NAME_RECORD + || !field(&header.as_old().linkname)?.is_empty() + || mode & !0o777 != 0 + { + return Err(material_error("unreviewed GNU long-name record header")); + } + let size = entry.size(); + if size < 2 || size > limit as u64 + 1 { + return Err(material_error("GNU long-name record has an invalid size")); + } + let mut bytes = Vec::new(); + std::io::Read::read_to_end(entry, &mut bytes).map_err(material_error)?; + let name = bytes + .strip_suffix(b"\0") + .filter(|name| bytes.len() as u64 == size && !name.contains(&0)) + .ok_or_else(|| material_error("GNU long name is not one NUL-terminated path"))?; + String::from_utf8(name.to_vec()).map_err(material_error) +} + +/// Joins a long name with the member header it describes. +/// +/// The header must hold exactly the first 100 bytes of the long name, so a +/// record can only lengthen the member it precedes, never rename it. +pub(super) fn join_long_name( + long: String, + header_name: &str, +) -> Result> { + let consistent = long.len() > HEADER_NAME_WIDTH + && header_name.len() == HEADER_NAME_WIDTH + && long.as_bytes().starts_with(header_name.as_bytes()); + if !consistent { + return Err(material_error( + "GNU long name does not extend its member's header name", + )); + } + Ok(long) +} + +/// Removes the regular-file or directory type bits older `cargo package` +/// releases left in the mode field; any other type is rejected. +pub(super) fn strip_member_type(mode: u32, directory: bool) -> Result> { + let expected = if directory { 0o040_000 } else { 0o100_000 }; + match mode & TYPE_BITS { + 0 => Ok(mode), + kind if kind == expected => Ok(mode & !TYPE_BITS), + _ => Err(material_error("TAR member mode names another file type")), + } +} diff --git a/crates/trusted-server-release/src/materials/extract/tests.rs b/crates/trusted-server-release/src/materials/extract/tests.rs index f7d259c78a..3088d28f65 100644 --- a/crates/trusted-server-release/src/materials/extract/tests.rs +++ b/crates/trusted-server-release/src/materials/extract/tests.rs @@ -78,6 +78,7 @@ fn extracts_verified_regular_files_and_preserves_original_bytes() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, }, ) .expect("should extract verified regular file"); @@ -118,6 +119,7 @@ fn accepts_bounded_native_members_above_64_mib() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, }, ) .expect("should support authenticated native members larger than 64 MiB"); @@ -172,6 +174,7 @@ fn rejects_unsafe_paths_types_modes_duplicates_and_collisions() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -192,6 +195,7 @@ fn rejects_excessive_depth_member_size_and_cancelled_extraction() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -210,6 +214,7 @@ fn rejects_excessive_depth_member_size_and_cancelled_extraction() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, }, ) .err() @@ -245,6 +250,7 @@ fn rejects_excessive_depth_member_size_and_cancelled_extraction() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -267,6 +273,7 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { index_alias: true, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -280,6 +287,7 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { index_alias: true, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_ok(), @@ -293,6 +301,7 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -308,6 +317,7 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { index_alias: true, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -323,6 +333,7 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { index_alias: true, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -340,6 +351,7 @@ fn index_alias_exception_requires_identical_pair_and_no_third_entry() { index_alias: true, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -376,6 +388,7 @@ fn rejects_hidden_data_bad_trailers_concatenation_and_truncation() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, } ) .is_err(), @@ -514,6 +527,7 @@ fn enforces_decoded_byte_and_entry_budgets() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, }, 1 ) @@ -547,6 +561,7 @@ fn extracted_tree_review_rejects_changed_or_added_members() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, }, ) .expect("should extract original"); @@ -626,6 +641,7 @@ fn extraction_recheck_rejects_root_alias_and_missing_empty_directory() { index_alias: false, node_tar_timestamps: false, local_pax: false, + cargo_package: false, }, ) .expect("should extract original"); @@ -648,174 +664,5 @@ fn extraction_recheck_rejects_root_alias_and_missing_empty_directory() { ); } -#[test] -fn accepts_only_reviewed_node_tar_timestamp_layout() { - let mut header = tar::Header::new_ustar(); - header.as_mut_bytes()[476..488].copy_from_slice(b"15075543223\0"); - header.as_mut_bytes()[488..500].copy_from_slice(b"15075543223\0"); - header.as_mut_bytes()[..12].copy_from_slice(b"chai/file.js"); - let selection = Selection { - root: "chai", - index_alias: false, - node_tar_timestamps: true, - local_pax: false, - }; - assert_eq!( - raw_name(&header, &selection).expect("should parse reviewed layout"), - "chai/file.js" - ); - assert!( - raw_name( - &header, - &Selection { - node_tar_timestamps: false, - ..selection - } - ) - .is_err(), - "should reject layout outside approved tuple" - ); - header.as_mut_bytes()[475] = b'x'; - assert!( - raw_name(&header, &selection).is_err(), - "should reject hidden prefix bytes" - ); - header.as_mut_bytes()[475] = 0; - header.as_mut_bytes()[476] = b'9'; - assert!( - raw_name(&header, &selection).is_err(), - "should reject nonoctal timestamps" - ); -} - -fn pax_record(key: &str, value: &str) -> Vec { - let content = format!(" {key}={value}\n"); - let mut length = content.len() + 1; - loop { - let next = content.len() + length.to_string().len(); - if next == length { - return format!("{length}{content}").into_bytes(); - } - length = next; - } -} -#[test] -fn reviewed_local_pax_never_overrides_member_selection() { - let mut body = pax_record("path", "package/file.js"); - body.extend(pax_record("size", "3")); - body.extend(pax_record("NODETAR.type", "File")); - let parsed = compatibility::parse_pax(&body).expect("should parse reviewed metadata"); - assert!( - parsed.join("package/file.js", 3).is_ok(), - "should accept redundant selectors" - ); - assert!( - parsed.join("package/other.js", 3).is_err(), - "should reject path override" - ); - assert!( - parsed.join("package/file.js", 4).is_err(), - "should reject size override" - ); - for (key, value) in [ - ("linkpath", "package/other"), - ("GNU.sparse.size", "3"), - ("size", "03"), - ] { - let mut invalid = body.clone(); - invalid.extend(pax_record(key, value)); - assert!( - compatibility::parse_pax(&invalid).is_err(), - "should reject semantic or duplicate fields" - ); - } - assert!( - compatibility::parse_pax(b"99 path=package/file.js\n").is_err(), - "should reject malformed framing" - ); -} - -#[test] -fn reviewed_pax_is_local_bounded_and_never_redirects_extraction() { - let metadata = |path: &str, size: &str| { - let mut bytes = pax_record("path", path); - bytes.extend(pax_record("size", size)); - bytes.extend(pax_record("NODETAR.type", "File")); - bytes - }; - let valid = metadata("package/file.js", "3"); - let selection = Selection { - root: "package", - index_alias: false, - node_tar_timestamps: false, - local_pax: true, - }; - let archive = compressed(&tar_bytes(&[ - ("PaxHeader/package/file.js", b'x', 0o644, &valid), - ("package/file.js", b'0', 0o644, b"abc"), - ])); - let extracted = - extract(original(&archive), selection).expect("should extract redundant local metadata"); - extracted - .verify_unchanged() - .expect("should preserve regular member facts"); - let bad_path = metadata("package/other.js", "3"); - let bad_size = metadata("package/file.js", "4"); - let cases = [ - vec![("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice())], - vec![ - ("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice()), - ("PaxHeader/other.js", b'x', 0o644, valid.as_slice()), - ], - vec![ - ("PaxHeader/package/file.js", b'g', 0o644, valid.as_slice()), - ("package/file.js", b'0', 0o644, b"abc"), - ], - vec![ - ( - "PaxHeader/package/file.js", - b'x', - 0o644, - bad_path.as_slice(), - ), - ("package/file.js", b'0', 0o644, b"abc"), - ], - vec![ - ( - "PaxHeader/package/file.js", - b'x', - 0o644, - bad_size.as_slice(), - ), - ("package/file.js", b'0', 0o644, b"abc"), - ], - ]; - for entries in cases { - let bytes = compressed(&tar_bytes(&entries)); - assert!( - extract(original(&bytes), selection).is_err(), - "should reject nonlocal or overriding metadata" - ); - } - for (name, mode) in [ - ("PaxHeader/../file.js", 0o644), - ("PaxHeader/package/file.js", 0o4644), - ] { - let bytes = compressed(&tar_bytes(&[ - (name, b'x', mode, &valid), - ("package/file.js", b'0', 0o644, b"abc"), - ])); - assert!( - extract(original(&bytes), selection).is_err(), - "should reject unsafe PAX header itself" - ); - } - let unreviewed = Selection { - local_pax: false, - ..selection - }; - assert!( - extract(original(&archive), unreviewed).is_err(), - "should reject PAX outside exact approved tuple" - ); -} +mod cargo_package; +mod npm_compatibility; diff --git a/crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs b/crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs new file mode 100644 index 0000000000..992e211a27 --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/tests/cargo_package.rs @@ -0,0 +1,154 @@ +//! Encodings `cargo package` writes into registry crates: GNU long names and +//! file-type bits in the mode field. + +use super::*; + +fn crate_selection(cargo_package: bool) -> Selection<'static> { + Selection { + root: "example-1.0.0", + index_alias: false, + node_tar_timestamps: false, + local_pax: false, + cargo_package, + } +} + +/// A member path longer than the 100-byte header name field. +fn long_member() -> String { + format!("example-1.0.0/{}/file.rs", "nested".repeat(18)) +} + +/// A crate as `cargo package` writes it: a GNU long-name record and a +/// regular file whose mode carries the regular-file type bits. +fn cargo_package_archive() -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast())); + let mut long = tar::Header::new_gnu(); + long.set_mode(0o644); + long.set_size(5); + builder + .append_data(&mut long, long_member(), &b"long\n"[..]) + .expect("should append a GNU long-name member"); + let mut typed = tar::Header::new_ustar(); + typed.set_mode(0o100_755); + typed.set_size(4); + typed.set_cksum(); + builder + .append_data(&mut typed, "example-1.0.0/build.sh", &b"#!\n\n"[..]) + .expect("should append a typed-mode member"); + builder + .into_inner() + .expect("should finish crate TAR") + .finish() + .expect("should finish crate gzip") +} + +#[test] +fn cargo_package_long_names_and_typed_modes_extract_only_for_crates() { + let bytes = cargo_package_archive(); + let extracted = extract(original(&bytes), crate_selection(true)) + .expect("should extract the encodings cargo package writes"); + assert_eq!( + fs::read(extracted.directory.path().join(long_member())).expect("should read long member"), + b"long\n", + "should place the member at its full long name" + ); + let (_, _, mode) = extracted + .member_facts("build.sh") + .expect("should record the typed-mode member"); + assert_eq!(mode, 0o755, "should keep only the permission bits"); + let error = extract(original(&bytes), crate_selection(false)) + .err() + .expect("should keep npm extraction closed to both encodings"); + assert!( + format!("{error:?}").contains("GNU long-name"), + "should name the unreviewed record: {error:?}" + ); +} + +/// One raw TAR member: name, entry type, mode and contents. +type Member<'a> = (&'a str, u8, u32, &'a [u8]); + +#[test] +fn cargo_package_encodings_never_rename_or_privilege_a_member() { + let long = long_member(); + let record = format!("{long}\0"); + let header_name = &long[..100]; + let other_name = format!("example-1.0.0/{}", "x".repeat(86)); + let control = compressed(&tar_bytes(&[ + ("././@LongLink", b'L', 0o644, record.as_bytes()), + (header_name, b'0', 0o100_644, b"data"), + ])); + let extracted = extract(original(&control), crate_selection(true)) + .expect("should accept the hand-built record the negatives below alter"); + assert!( + extracted.directory.path().join(&long).is_file(), + "should extract the control member at its long name" + ); + let cases: [(&str, Vec>); 9] = [ + ( + "renaming header", + vec![ + ("././@LongLink", b'L', 0o644, record.as_bytes()), + (other_name.as_str(), b'0', 0o644, b"data"), + ], + ), + ( + "consecutive records", + vec![ + ("././@LongLink", b'L', 0o644, record.as_bytes()), + ("././@LongLink", b'L', 0o644, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "dangling record", + vec![ + ("example-1.0.0/lib.rs", b'0', 0o644, b"data"), + ("././@LongLink", b'L', 0o644, record.as_bytes()), + ], + ), + ( + "unterminated record", + vec![ + ("././@LongLink", b'L', 0o644, long.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "foreign record name", + vec![ + ("example-1.0.0/LongLink", b'L', 0o644, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "directory-mode record", + vec![ + ("././@LongLink", b'L', 0o040_755, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "set-user-ID record", + vec![ + ("././@LongLink", b'L', 0o104_644, record.as_bytes()), + (header_name, b'0', 0o644, b"data"), + ], + ), + ( + "symlink type bits", + vec![("example-1.0.0/lib.rs", b'0', 0o120_777, b"data")], + ), + ( + "set-user-ID mode", + vec![("example-1.0.0/lib.rs", b'0', 0o104_755, b"data")], + ), + ]; + for (label, entries) in cases { + let bytes = compressed(&tar_bytes(&entries)); + assert!( + extract(original(&bytes), crate_selection(true)).is_err(), + "should reject a crate with a {label}" + ); + } +} diff --git a/crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs b/crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs new file mode 100644 index 0000000000..9e1c9cdb9b --- /dev/null +++ b/crates/trusted-server-release/src/materials/extract/tests/npm_compatibility.rs @@ -0,0 +1,177 @@ +//! Reviewed npm compatibility: node-tar timestamp prefixes and local PAX headers. + +use super::*; + +#[test] +fn accepts_only_reviewed_node_tar_timestamp_layout() { + let mut header = tar::Header::new_ustar(); + header.as_mut_bytes()[476..488].copy_from_slice(b"15075543223\0"); + header.as_mut_bytes()[488..500].copy_from_slice(b"15075543223\0"); + header.as_mut_bytes()[..12].copy_from_slice(b"chai/file.js"); + let selection = Selection { + root: "chai", + index_alias: false, + node_tar_timestamps: true, + local_pax: false, + cargo_package: false, + }; + assert_eq!( + raw_name(&header, &selection).expect("should parse reviewed layout"), + "chai/file.js" + ); + assert!( + raw_name( + &header, + &Selection { + node_tar_timestamps: false, + ..selection + } + ) + .is_err(), + "should reject layout outside approved tuple" + ); + header.as_mut_bytes()[475] = b'x'; + assert!( + raw_name(&header, &selection).is_err(), + "should reject hidden prefix bytes" + ); + header.as_mut_bytes()[475] = 0; + header.as_mut_bytes()[476] = b'9'; + assert!( + raw_name(&header, &selection).is_err(), + "should reject nonoctal timestamps" + ); +} + +fn pax_record(key: &str, value: &str) -> Vec { + let content = format!(" {key}={value}\n"); + let mut length = content.len() + 1; + loop { + let next = content.len() + length.to_string().len(); + if next == length { + return format!("{length}{content}").into_bytes(); + } + length = next; + } +} +#[test] +fn reviewed_local_pax_never_overrides_member_selection() { + let mut body = pax_record("path", "package/file.js"); + body.extend(pax_record("size", "3")); + body.extend(pax_record("NODETAR.type", "File")); + let parsed = compatibility::parse_pax(&body).expect("should parse reviewed metadata"); + assert!( + parsed.join("package/file.js", 3).is_ok(), + "should accept redundant selectors" + ); + assert!( + parsed.join("package/other.js", 3).is_err(), + "should reject path override" + ); + assert!( + parsed.join("package/file.js", 4).is_err(), + "should reject size override" + ); + for (key, value) in [ + ("linkpath", "package/other"), + ("GNU.sparse.size", "3"), + ("size", "03"), + ] { + let mut invalid = body.clone(); + invalid.extend(pax_record(key, value)); + assert!( + compatibility::parse_pax(&invalid).is_err(), + "should reject semantic or duplicate fields" + ); + } + assert!( + compatibility::parse_pax(b"99 path=package/file.js\n").is_err(), + "should reject malformed framing" + ); +} + +#[test] +fn reviewed_pax_is_local_bounded_and_never_redirects_extraction() { + let metadata = |path: &str, size: &str| { + let mut bytes = pax_record("path", path); + bytes.extend(pax_record("size", size)); + bytes.extend(pax_record("NODETAR.type", "File")); + bytes + }; + let valid = metadata("package/file.js", "3"); + let selection = Selection { + root: "package", + index_alias: false, + node_tar_timestamps: false, + local_pax: true, + cargo_package: false, + }; + let archive = compressed(&tar_bytes(&[ + ("PaxHeader/package/file.js", b'x', 0o644, &valid), + ("package/file.js", b'0', 0o644, b"abc"), + ])); + let extracted = + extract(original(&archive), selection).expect("should extract redundant local metadata"); + extracted + .verify_unchanged() + .expect("should preserve regular member facts"); + let bad_path = metadata("package/other.js", "3"); + let bad_size = metadata("package/file.js", "4"); + let cases = [ + vec![("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice())], + vec![ + ("PaxHeader/package/file.js", b'x', 0o644, valid.as_slice()), + ("PaxHeader/other.js", b'x', 0o644, valid.as_slice()), + ], + vec![ + ("PaxHeader/package/file.js", b'g', 0o644, valid.as_slice()), + ("package/file.js", b'0', 0o644, b"abc"), + ], + vec![ + ( + "PaxHeader/package/file.js", + b'x', + 0o644, + bad_path.as_slice(), + ), + ("package/file.js", b'0', 0o644, b"abc"), + ], + vec![ + ( + "PaxHeader/package/file.js", + b'x', + 0o644, + bad_size.as_slice(), + ), + ("package/file.js", b'0', 0o644, b"abc"), + ], + ]; + for entries in cases { + let bytes = compressed(&tar_bytes(&entries)); + assert!( + extract(original(&bytes), selection).is_err(), + "should reject nonlocal or overriding metadata" + ); + } + for (name, mode) in [ + ("PaxHeader/../file.js", 0o644), + ("PaxHeader/package/file.js", 0o4644), + ] { + let bytes = compressed(&tar_bytes(&[ + (name, b'x', mode, &valid), + ("package/file.js", b'0', 0o644, b"abc"), + ])); + assert!( + extract(original(&bytes), selection).is_err(), + "should reject unsafe PAX header itself" + ); + } + let unreviewed = Selection { + local_pax: false, + ..selection + }; + assert!( + extract(original(&archive), unreviewed).is_err(), + "should reject PAX outside exact approved tuple" + ); +} diff --git a/crates/trusted-server-release/src/policy.rs b/crates/trusted-server-release/src/policy.rs index 87dc5a6056..0991fdf41b 100644 --- a/crates/trusted-server-release/src/policy.rs +++ b/crates/trusted-server-release/src/policy.rs @@ -173,6 +173,37 @@ pub(crate) const FIXED_ARTIFACTS: [FixedArtifact; 4] = [ }, ]; +/// Test-only rows naming the members of the Cargo denial fixture workspace. +/// +/// They are never part of [`FIXED_ARTIFACTS`] or the cohort, so no request +/// text can select them; only test code reaches them by package name. +#[cfg(test)] +pub(crate) const DENIAL_FIXTURE_ARTIFACTS: [FixedArtifact; 4] = [ + denial_fixture_row("denial-build-direct.wasm", "build-direct"), + denial_fixture_row("denial-build-grandchild.wasm", "build-grandchild"), + denial_fixture_row("denial-macro-attempt.wasm", "macro-attempt"), + denial_fixture_row("denial-control.wasm", "control"), +]; + +/// One Wasm denial fixture row whose binary is named after its package. +#[cfg(test)] +const fn denial_fixture_row(name: &'static str, package: &'static str) -> FixedArtifact { + FixedArtifact { + name, + package, + bin: package, + target: ReleaseTarget::Wasm32Wasip1, + kind: "denial-fixture", + role: "test-fixture", + assurance_profile: "none", + capability: "none", + architecture: "wasm32", + minimum_version: "wasip1", + runtime: "none", + recipe_environment: "ordinary-ci", + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CohortDocument { diff --git a/crates/trusted-server-release/src/preparation.rs b/crates/trusted-server-release/src/preparation.rs index c70519c4ef..874ec990c9 100644 --- a/crates/trusted-server-release/src/preparation.rs +++ b/crates/trusted-server-release/src/preparation.rs @@ -686,4 +686,4 @@ fn authenticate_observation( } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/crates/trusted-server-release/src/preparation/tests.rs b/crates/trusted-server-release/src/preparation/tests.rs index 62ab23dbb9..038b013079 100644 --- a/crates/trusted-server-release/src/preparation/tests.rs +++ b/crates/trusted-server-release/src/preparation/tests.rs @@ -102,7 +102,7 @@ fn hook_start_does_not_substitute_for_successful_terminal_execution() { } #[cfg(target_os = "linux")] -mod full; +pub(crate) mod full; #[test] fn normalization_receipt_cannot_assert_another_native_tool_identity() { diff --git a/crates/trusted-server-release/src/preparation/tests/full.rs b/crates/trusted-server-release/src/preparation/tests/full.rs index 8705487649..354d46305c 100644 --- a/crates/trusted-server-release/src/preparation/tests/full.rs +++ b/crates/trusted-server-release/src/preparation/tests/full.rs @@ -3,7 +3,8 @@ use super::*; use crate::boundary::{DockerSettings, ImageSelection, ProbeProgram}; use crate::documents::read_bounded; -use crate::preflight::audit_environment; +use crate::javascript::JsBuildCompleted; +use crate::preflight::{AuditedEnvironment, audit_environment}; use crate::source::{CommitId, authenticate}; fn host_limits() -> ProcessLimits { @@ -14,9 +15,33 @@ fn host_limits() -> ProcessLimits { } } -#[test] -#[ignore = "requires explicit original-only fixture and Linux Docker; run separately in producer CI"] -fn real_two_fresh_js_builds() { +/// One real two-build JS fixture run and the authenticated source it built. +/// +/// Ordinary CI evidence only: the tools, image and fixture acquisition are +/// unqualified, so nothing here is release qualification. +pub(crate) struct JsFixtureRun { + /// The explicitly supplied fixture root. + pub(crate) fixture: PathBuf, + /// The audited minimal environment the source was authenticated under. + pub(crate) audited: AuditedEnvironment, + /// The locally authenticated fixture source. + pub(crate) source: ApprovedSource, + /// The locked materials derived from that source. + pub(crate) locked: LockedMaterials, + /// The completed JS handoff of build A. + pub(crate) completed: JsBuildCompleted, + /// The ordinary CI measurement of both JS builds. + pub(crate) measurement: Value, +} + +/// Runs two fresh offline JS builds from the fixture named by +/// `TS_RELEASE_JS_FIXTURE_ROOT` and returns the retained A handoff. +/// +/// # Panics +/// +/// Panics when the fixture is missing or any preparation, boundary or build +/// step fails; this is an explicit CI fixture, not a production path. +pub(crate) fn run_two_fresh_js_builds() -> JsFixtureRun { let fixture = std::env::var_os("TS_RELEASE_JS_FIXTURE_ROOT") .map(PathBuf::from) .expect("should explicitly supply original-only fixture root"); @@ -30,13 +55,30 @@ fn real_two_fresh_js_builds() { .expect("should retain source identity"), ) .expect("should parse independently selected source identity"); - let environment = - audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) - .expect("should audit minimal source environment"); - let source = authenticate(&fixture.join("source"), &commit, &environment) + let audited = audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) + .expect("should audit minimal source environment"); + let source = authenticate(&fixture.join("source"), &commit, &audited) .expect("should authenticate fixture tracked source"); let locked = LockedMaterials::from_source(&source).expect("should derive approved locked npm materials"); + let (completed, measurement) = two_fresh_js_builds(&fixture, &context, &source, &locked); + JsFixtureRun { + fixture, + audited, + source, + locked, + completed, + measurement, + } +} + +/// Prepares, proves and runs both JS builds, returning the handoff and measurement. +fn two_fresh_js_builds( + fixture: &Path, + context: &Value, + source: &ApprovedSource, + locked: &LockedMaterials, +) -> (JsBuildCompleted, Value) { let cancellation = Cancellation::default(); let deadline = Instant::now() + Duration::from_secs(3600); let originals = locked @@ -45,8 +87,8 @@ fn real_two_fresh_js_builds() { let tools = SelectedJsTools::for_tests(&fixture.join("tools")) .expect("should measure ordinary CI candidate tools"); let prepared = PreparedJsBuild::new( - &source, - &locked, + source, + locked, &originals, tools, JsAttempt::new(deadline, cancellation), @@ -149,7 +191,7 @@ fn real_two_fresh_js_builds() { ); assert_eq!( completed.source_commit(), - commit.as_str(), + source.commit().as_str(), "should bind actual approved source" ); assert!( @@ -186,9 +228,16 @@ fn real_two_fresh_js_builds() { "private_roots_removed": owned_roots.len(), "release_qualification": false, }); + (completed, measurement) +} + +#[test] +#[ignore = "requires explicit original-only fixture and Linux Docker; run separately in producer CI"] +fn real_two_fresh_js_builds() { + let run = run_two_fresh_js_builds(); fs::write( - fixture.join("js-execution-measurement.json"), - serde_json::to_vec_pretty(&measurement).expect("should encode actual CI measurement"), + run.fixture.join("js-execution-measurement.json"), + serde_json::to_vec_pretty(&run.measurement).expect("should encode actual CI measurement"), ) .expect("should retain actual CI measurement after successful handoff"); } diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index 2fecfd7986..b8695c7fa9 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -1,10 +1,11 @@ //! Closed recipe layer for one reproducible Cargo build. //! //! The recipe validates and binds every input a Cargo execution needs, derives -//! the exact plan in the build's own path namespace, and parses and copies the -//! result of a successful run. The execute gate stays closed: it checks the -//! boundary and plan, then reports the unqualified selection. The real -//! confined run is wired by a later slice. +//! the exact plan in the build's own path namespace, runs it confined, and +//! parses and copies the result of a successful run. Only the test-only +//! ordinary CI tool selection reaches the run, so every completed build is +//! unqualified candidate evidence; production execution stays closed until a +//! toolchain, boundary and image are qualified. //! //! # Roots //! @@ -15,21 +16,20 @@ //! copies the executable into it after the run. use std::path::{Path, PathBuf}; -use std::time::Duration; use error_stack::Report; use sha2::{Digest as _, Sha256}; -use crate::boundary::{Boundary, BoundaryRoots, DenialProof}; +use crate::boundary::BoundaryRoots; use crate::documents::Role as DocumentRole; use crate::error::ReleaseError; -use crate::inspect::inspect_wasm; use crate::javascript::JsBuildCompleted; use crate::materials::PreparedCargoMaterials; -use crate::policy::{FIXED_ARTIFACTS, FixedArtifact, ReleaseTarget}; +#[cfg(test)] +use crate::policy::ReleaseTarget; +use crate::policy::{FIXED_ARTIFACTS, FixedArtifact}; use crate::preflight::AuditedEnvironment; use crate::preflight::child::{ChildEnvironment, ChildRequest, child_environment}; -use crate::process::ProcessLimits; use crate::source::{ApprovedSource, CommitId, FreshSource}; use fresh::{ absent_in_real_parent, disjoint, empty_directory, normal_absolute, prepared_materials, @@ -40,6 +40,7 @@ mod ambient; mod artifact; mod cargo_output; mod completed; +mod execute; mod fresh; mod paths; mod plan; @@ -56,19 +57,14 @@ pub(crate) use plan::CargoPlan; )] pub(crate) use tools::{Authority, QualifiedRustTools, SelectedRustTools, ToolSelection}; -use artifact::{EXECUTABLE_LIMIT, copy_artifact}; -use cargo_output::{ExpectedBinary, locate_host_binary}; -use completed::SuccessFacts; +#[cfg(test)] +use execute::RunOutput; +use execute::retained_tree_digest; use plan::{PlanInputs, check_child_state, plan_for}; /// The only released feature variant of every asset. const DEFAULT_VARIANT: &str = "default"; -/// Wall limit of one Cargo build. -const BUILD_WALL: Duration = Duration::from_secs(3600); -/// Captured output limit of each Cargo stream. -const BUILD_STREAM_MAX: u64 = 64 * 1024 * 1024; - /// Builds a recipe error carrying `message`. pub(crate) fn recipe_error(message: impl std::fmt::Display) -> Report { Report::new(ReleaseError::Recipe).attach(message.to_string()) @@ -115,6 +111,20 @@ impl AssetId { pub(crate) fn artifact(&self) -> &'static FixedArtifact { self.artifact } + + /// Resolves a Cargo denial fixture member by package name, for tests only. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for any package not in the test rows. + #[cfg(test)] + pub(crate) fn denial_fixture(package: &str) -> Result> { + crate::policy::DENIAL_FIXTURE_ARTIFACTS + .iter() + .find(|artifact| artifact.package == package) + .map(|artifact| Self { artifact }) + .ok_or_else(|| recipe_error(format!("unknown denial fixture member {package:?}"))) + } } /// Which independent build of the pair a recipe belongs to. @@ -245,6 +255,26 @@ impl RecipeRequest { pub(crate) fn parse(input: &RecipeInput<'_>) -> Result> { check_variant(input.variant)?; let asset = AssetId::parse(input.asset)?; + Self::with_asset(asset, input) + } + + /// Parses a request for an already resolved test-only asset; the asset + /// text of `input` is ignored. + /// + /// # Errors + /// + /// As [`Self::parse`], except for the asset name. + #[cfg(test)] + pub(crate) fn parse_for_asset( + asset: AssetId, + input: &RecipeInput<'_>, + ) -> Result> { + check_variant(input.variant)?; + Self::with_asset(asset, input) + } + + /// Validates every field but the asset, which the caller resolved. + fn with_asset(asset: AssetId, input: &RecipeInput<'_>) -> Result> { let role = Role::parse(input.role)?; let commit = CommitId::parse(input.commit)?; check_roots(input)?; @@ -331,17 +361,10 @@ pub(crate) struct PreparedRecipe { ambient: CheckedAmbient, child: ChildEnvironment, retained_root: PathBuf, + retained_digest: String, js: JsFacts, } -/// What a successful confined run produced, as the run path reports it. -struct RunOutput { - stdout: Vec, - stderr_sha256: String, - started_at_seconds: u64, - elapsed: Duration, -} - impl PreparedRecipe { /// Binds the locally authenticated source and a fresh private checkout. /// @@ -420,6 +443,7 @@ impl PreparedRecipe { let retained_root = resolve_js_handoff(completed, &request)?; check_fresh_and_disjoint(&request, &ambient, &tools, &materials, &retained_root)?; let tools_digest = tools.digest()?; + let retained_digest = retained_tree_digest(&retained_root)?; create_private_roots(&request)?; Ok(Self { js: JsFacts { @@ -434,6 +458,7 @@ impl PreparedRecipe { ambient, child, retained_root, + retained_digest, }) } @@ -480,171 +505,6 @@ impl PreparedRecipe { ], ) } - - /// Requires the tools to be the tree measured at binding. - /// - /// # Errors - /// - /// Returns [`ReleaseError::Recipe`] when anything under the tool root was - /// edited, linked, added or removed. - fn check_tools(&self) -> Result<(), Report> { - if self.tools.digest()? != self.tools_digest { - return Err(recipe_error("Rust tools changed after binding")); - } - Ok(()) - } - - /// Requires the prepared materials to be unchanged. - fn check_materials(&self) -> Result<(), Report> { - self.materials.rehash().map_err(|error| { - error - .change_context(ReleaseError::Recipe) - .attach("prepared materials changed after binding") - }) - } - - /// Requires unchanged inputs, a derivable plan, the exact planned roots - /// and owned descendant cleanup. - /// - /// # Errors - /// - /// Returns [`ReleaseError::Recipe`] when the tools or materials changed, - /// the plan cannot be derived in the boundary's namespace, the boundary - /// roots differ from [`Self::boundary_roots`], or the boundary does not - /// own descendant cleanup. - fn check_boundary(&self, boundary: &Boundary) -> Result<(), Report> { - self.check_tools()?; - self.check_materials()?; - self.plan(boundary)?; - if !boundary.matches_roots(&self.boundary_roots()?) { - return Err(recipe_error( - "boundary roots differ from the exact recipe roots", - )); - } - if !boundary.has_owned_descendant_cleanup() { - return Err(recipe_error( - "boundary does not own descendant cleanup for Cargo build scripts", - )); - } - Ok(()) - } - - /// Checks every gate, then reports the closed selection. - /// - /// The confined command is described but never started: the real run is - /// wired by a later slice, and no tool selection is qualified. - /// - /// # Errors - /// - /// Returns [`ReleaseError::Recipe`] when an input, plan, roots or - /// descendant cleanup gate fails, then always [`ReleaseError::Unqualified`]. - pub(crate) fn execute( - self, - boundary: &Boundary, - _proof: &DenialProof, - ) -> Result<(), Report> { - self.check_boundary(boundary)?; - self.plan(boundary)?.confined_command(ProcessLimits { - wall: BUILD_WALL, - stdout_max: BUILD_STREAM_MAX, - stderr_max: BUILD_STREAM_MAX, - })?; - let reason = match self.tools.authority() { - Authority::Qualified => "the confined Cargo run is not wired yet", - Authority::Unqualified => { - "Cargo execution is closed until Rust tools and boundary are qualified" - } - }; - Err(Report::new(ReleaseError::Unqualified).attach(reason)) - } - - /// Turns one successful run into a [`RecipeCompleted`]. - /// - /// Parses Cargo's stream, maps the executable back through a writable - /// root, copies it into the output directory and, for Wasm, requires the - /// debug sections a stripped module lacks. - /// - /// # Errors - /// - /// Returns [`ReleaseError::Recipe`] when the tools or materials changed, - /// the stream is not exactly one fresh debug-info binary, the executable - /// cannot be copied, or a Wasm artifact is stripped, or - /// [`ReleaseError::Inspect`] when the Wasm module is rejected. - fn complete( - self, - run: RunOutput, - boundary_fingerprint: [u8; 32], - paths: &dyn ExecutionPaths, - ) -> Result> { - self.check_tools()?; - self.check_materials()?; - let plan_digest = self.plan(paths)?.digest()?; - let artifact = self.request.asset.artifact(); - let manifest = paths.execution_path( - &self - .ambient - .checkout_root() - .join("crates") - .join(artifact.package) - .join("Cargo.toml"), - )?; - let release_dir = paths - .execution_path(&self.request.target_dir)? - .join(artifact.target.triple()) - .join("release"); - let located = locate_host_binary( - &run.stdout, - &ExpectedBinary { - bin: artifact.bin, - manifest: &manifest, - release_dir: &release_dir, - }, - paths, - )?; - let copied = copy_artifact( - &located.host, - &self.request.target_dir, - &self.request.output, - artifact.name, - EXECUTABLE_LIMIT, - )?; - if artifact.target == ReleaseTarget::Wasm32Wasip1 { - let bytes = std::fs::read(copied.path()).map_err(|error| { - recipe_error(format!("cannot read the copied artifact: {error}")) - })?; - if hex::encode(Sha256::digest(&bytes)) != copied.sha256() { - return Err(recipe_error("copied artifact changed before inspection")); - } - inspect_wasm(&bytes)?.require_debug_sections()?; - } - if self.tools.authority() == Authority::Qualified - && !self.materials.derived_manifest_digests().is_empty() - { - return Err(Report::new(ReleaseError::Unqualified) - .attach("a build from derived manifests cannot carry qualified authority")); - } - RecipeCompleted::record_success(SuccessFacts { - asset: self.request.asset, - role: self.request.role, - commit: self.request.commit.clone(), - cargo_lock_sha256: self.materials.cargo_lock_sha256().to_owned(), - materials_digest: self.materials.digest().to_owned(), - js_inventory_sha256: self.js.inventory_sha256.clone(), - js_settings_sha256: self.js.settings_sha256.clone(), - js_tools_sha256: self.js.tools_sha256.clone(), - rust_tools_digest: self.tools_digest.clone(), - boundary_fingerprint, - plan_digest, - ambient: self.ambient.clone(), - started_at_seconds: run.started_at_seconds, - elapsed: run.elapsed, - stdout: run.stdout, - stderr_sha256: run.stderr_sha256, - artifact: copied, - output_dir: self.request.output.clone(), - derived_manifest_digests: self.materials.derived_manifest_digests().clone(), - }) - } } /// Child variable carrying the authenticated source commit. diff --git a/crates/trusted-server-release/src/recipe/completed.rs b/crates/trusted-server-release/src/recipe/completed.rs index 7281c5fbea..970eb3ea5b 100644 --- a/crates/trusted-server-release/src/recipe/completed.rs +++ b/crates/trusted-server-release/src/recipe/completed.rs @@ -54,6 +54,18 @@ pub(crate) struct RecipeCompleted { retained: tempfile::TempDir, } +impl std::fmt::Debug for RecipeCompleted { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RecipeCompleted") + .field("asset", &self.facts.asset) + .field("role", &self.facts.role) + .field("artifact", &self.facts.artifact) + .field("plan_digest", &self.facts.plan_digest) + .finish_non_exhaustive() + } +} + impl RecipeCompleted { /// Records one successful run and retains its Cargo message stream. /// diff --git a/crates/trusted-server-release/src/recipe/execute.rs b/crates/trusted-server-release/src/recipe/execute.rs new file mode 100644 index 0000000000..4e27e05e81 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/execute.rs @@ -0,0 +1,297 @@ +//! The confined run of a bound recipe and the checks around it. +//! +//! Every input is rehashed immediately before and after the confined Cargo +//! process, Cargo home must gain no registry or Git cache, and only a +//! successful run is parsed, copied and bound into a [`RecipeCompleted`]. + +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::artifact::{EXECUTABLE_LIMIT, copy_artifact}; +use super::cargo_output::{ExpectedBinary, locate_host_binary}; +use super::completed::SuccessFacts; +use super::{Authority, ExecutionPaths, PreparedRecipe, RecipeCompleted, recipe_error}; +use crate::boundary::{Boundary, DenialProof}; +use crate::error::ReleaseError; +use crate::inspect::inspect_wasm; +use crate::policy::ReleaseTarget; +use crate::process::ProcessLimits; +use crate::tool_tree::tool_tree_digest; + +/// Wall limit of one Cargo build. +const BUILD_WALL: Duration = Duration::from_secs(25 * 60); +/// Captured output limit of each Cargo stream. +const BUILD_STREAM_MAX: u64 = 16 * 1024 * 1024; + +/// Limits of the confined Cargo process. +fn build_limits() -> ProcessLimits { + ProcessLimits { + wall: BUILD_WALL, + stdout_max: BUILD_STREAM_MAX, + stderr_max: BUILD_STREAM_MAX, + } +} + +/// What a successful confined run produced, as the run path reports it. +pub(super) struct RunOutput { + pub(super) stdout: Vec, + pub(super) stderr_sha256: String, + pub(super) started_at_seconds: u64, + pub(super) elapsed: Duration, +} + +impl PreparedRecipe { + /// Requires the tools to be the tree measured at binding. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when anything under the tool root was + /// edited, linked, added or removed. + pub(super) fn check_tools(&self) -> Result<(), Report> { + if self.tools.digest()? != self.tools_digest { + return Err(recipe_error("Rust tools changed after binding")); + } + Ok(()) + } + + /// Requires the prepared materials to be unchanged. + pub(super) fn check_materials(&self) -> Result<(), Report> { + self.materials.rehash().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("prepared materials changed after binding") + }) + } + + /// Requires the retained JS handoff to be the tree measured at binding. + fn check_js_handoff(&self) -> Result<(), Report> { + if retained_tree_digest(&self.retained_root)? != self.retained_digest { + return Err(recipe_error("retained JS handoff changed after binding")); + } + Ok(()) + } + + /// Rehashes the JS handoff, the tools and the materials. + pub(super) fn check_inputs(&self) -> Result<(), Report> { + self.check_js_handoff()?; + self.check_tools()?; + self.check_materials() + } + + /// Requires Cargo's private home to hold no registry or Git cache. + /// + /// Every dependency comes from the vendored directory source, so a cache + /// appearing in Cargo home means Cargo resolved something elsewhere. + pub(super) fn check_no_fetched_sources(&self) -> Result<(), Report> { + let cargo_home = self.request.cargo_home(); + for name in ["registry", "git"] { + match std::fs::symlink_metadata(cargo_home.join(name)) { + Ok(_) => { + return Err(recipe_error(format!( + "Cargo home holds a {name} cache; the build bypassed the vendored sources" + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(recipe_error(format!( + "Cargo home {name} cache cannot be inspected: {error}" + ))); + } + } + } + Ok(()) + } + + /// Requires unchanged inputs, a derivable plan, the exact planned roots + /// and owned descendant cleanup. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the tools or materials changed, + /// the plan cannot be derived in the boundary's namespace, the boundary + /// roots differ from [`Self::boundary_roots`], or the boundary does not + /// own descendant cleanup. + fn check_boundary(&self, boundary: &Boundary) -> Result<(), Report> { + self.check_tools()?; + self.check_materials()?; + self.plan(boundary)?; + if !boundary.matches_roots(&self.boundary_roots()?) { + return Err(recipe_error( + "boundary roots differ from the exact recipe roots", + )); + } + if !boundary.has_owned_descendant_cleanup() { + return Err(recipe_error( + "boundary does not own descendant cleanup for Cargo build scripts", + )); + } + Ok(()) + } + + /// Checks every gate, runs Cargo confined and completes the run. + /// + /// Only a selection that [permits a candidate run](super::SelectedRustTools::permits_candidate_run) + /// reaches the run: the test-only ordinary CI selection. That run is + /// unqualified CI evidence; production builds can name no selection, so + /// their gate stays closed. The JS handoff, tools and materials are + /// rehashed immediately before and after the run, and Cargo home must + /// gain no registry or Git cache. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when an input, plan, roots or + /// descendant cleanup gate fails, an input changed around the run, Cargo + /// fetched into its home, the build did not succeed or its completion is + /// rejected; [`ReleaseError::Unqualified`] for a selection that permits no + /// candidate run; or [`ReleaseError::Boundary`] / [`ReleaseError::Process`] + /// when the confined run itself fails. + pub(crate) fn execute( + self, + boundary: &Boundary, + proof: &DenialProof, + ) -> Result> { + self.check_boundary(boundary)?; + let command = self.plan(boundary)?.confined_command(build_limits())?; + if !self.tools.permits_candidate_run() { + return Err(Report::new(ReleaseError::Unqualified) + .attach("Cargo execution is closed until Rust tools and boundary are qualified")); + } + self.check_inputs()?; + self.check_no_fetched_sources()?; + let started_at_seconds = unix_seconds()?; + let run = boundary.run_confined(&command, proof); + let after = self + .check_inputs() + .and_then(|()| self.check_no_fetched_sources()); + let result = match (run, after) { + (Ok(result), Ok(())) => result, + (Ok(_), Err(changed)) => return Err(changed), + (Err(run), Ok(())) => return Err(run), + (Err(run), Err(changed)) => { + return Err(run.attach(format!("inputs also failed their checks: {changed:?}"))); + } + }; + let stderr_sha256 = hex::encode(Sha256::digest(result.stderr())); + let elapsed = result.elapsed(); + let stdout = result.require_success().map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("confined Cargo build did not succeed") + })?; + let output = RunOutput { + stdout, + stderr_sha256, + started_at_seconds, + elapsed, + }; + self.complete(output, boundary.execution_fingerprint(), boundary) + } + + /// Turns one successful run into a [`RecipeCompleted`]. + /// + /// Parses Cargo's stream, maps the executable back through a writable + /// root, copies it into the output directory and, for Wasm, requires the + /// debug sections a stripped module lacks. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] when the tools or materials changed, + /// the stream is not exactly one fresh debug-info binary, the executable + /// cannot be copied, or a Wasm artifact is stripped, or + /// [`ReleaseError::Inspect`] when the Wasm module is rejected. + pub(super) fn complete( + self, + run: RunOutput, + boundary_fingerprint: [u8; 32], + paths: &dyn ExecutionPaths, + ) -> Result> { + self.check_tools()?; + self.check_materials()?; + let plan_digest = self.plan(paths)?.digest()?; + let artifact = self.request.asset.artifact(); + let manifest = paths.execution_path( + &self + .ambient + .checkout_root() + .join("crates") + .join(artifact.package) + .join("Cargo.toml"), + )?; + let release_dir = paths + .execution_path(&self.request.target_dir)? + .join(artifact.target.triple()) + .join("release"); + let located = locate_host_binary( + &run.stdout, + &ExpectedBinary { + bin: artifact.bin, + manifest: &manifest, + release_dir: &release_dir, + }, + paths, + )?; + let copied = copy_artifact( + &located.host, + &self.request.target_dir, + &self.request.output, + artifact.name, + EXECUTABLE_LIMIT, + )?; + if artifact.target == ReleaseTarget::Wasm32Wasip1 { + let bytes = std::fs::read(copied.path()).map_err(|error| { + recipe_error(format!("cannot read the copied artifact: {error}")) + })?; + if hex::encode(Sha256::digest(&bytes)) != copied.sha256() { + return Err(recipe_error("copied artifact changed before inspection")); + } + inspect_wasm(&bytes)?.require_debug_sections()?; + } + if self.tools.authority() == Authority::Qualified + && !self.materials.derived_manifest_digests().is_empty() + { + return Err(Report::new(ReleaseError::Unqualified) + .attach("a build from derived manifests cannot carry qualified authority")); + } + RecipeCompleted::record_success(SuccessFacts { + asset: self.request.asset, + role: self.request.role, + commit: self.request.commit.clone(), + cargo_lock_sha256: self.materials.cargo_lock_sha256().to_owned(), + materials_digest: self.materials.digest().to_owned(), + js_inventory_sha256: self.js.inventory_sha256.clone(), + js_settings_sha256: self.js.settings_sha256.clone(), + js_tools_sha256: self.js.tools_sha256.clone(), + rust_tools_digest: self.tools_digest.clone(), + boundary_fingerprint, + plan_digest, + ambient: self.ambient.clone(), + started_at_seconds: run.started_at_seconds, + elapsed: run.elapsed, + stdout: run.stdout, + stderr_sha256: run.stderr_sha256, + artifact: copied, + output_dir: self.request.output.clone(), + derived_manifest_digests: self.materials.derived_manifest_digests().clone(), + }) + } +} + +/// Digest of the whole retained JS root: contents, modes, names and aliases. +pub(super) fn retained_tree_digest(root: &Path) -> Result> { + tool_tree_digest(root, &|| Ok(())).map_err(|error| { + error + .change_context(ReleaseError::Recipe) + .attach("retained JS handoff cannot be digested") + }) +} + +/// Current Unix time in whole seconds. +fn unix_seconds() -> Result> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .map_err(|error| recipe_error(format!("system clock is before the Unix epoch: {error}"))) +} diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs index aa83a082c8..8633920c8d 100644 --- a/crates/trusted-server-release/src/recipe/tests.rs +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -325,8 +325,11 @@ fn checked_b() -> CheckedAmbient { mod ambient; mod bind; +mod candidate; mod complete; mod fixture; +#[cfg(target_os = "linux")] +mod full; mod plan; mod sources; mod tools; diff --git a/crates/trusted-server-release/src/recipe/tests/candidate.rs b/crates/trusted-server-release/src/recipe/tests/candidate.rs new file mode 100644 index 0000000000..b8d654e362 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/candidate.rs @@ -0,0 +1,520 @@ +//! Ordinary CI candidate support: the Cargo fixture description, the explicit +//! container settings and the measurements the real Linux fixtures print. +//! +//! Nothing here is release qualification. The fixture is acquired without +//! authentication, the toolchain and image are unqualified, and every +//! measurement says so. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use serde_json::{Map, Value, json}; + +use super::*; +use crate::boundary::DockerSettings; +use crate::process::ProcessLimits; + +/// Schema version of `fixture.json` and of every measurement. +const SCHEMA_VERSION: u64 = 1; +/// Kind of the Cargo fixture description. +const FIXTURE_KIND: &str = "ordinary-ci-cargo-fixture"; +/// How the fixture inputs were acquired. +const ACQUISITION: &str = "unauthenticated-ordinary-ci"; +/// Every field `fixture.json` must hold, and nothing else. +const FIXTURE_FIELDS: [&str; 10] = [ + "acquisition", + "cargo_lock_sha256", + "git", + "image_id", + "kind", + "normalized", + "registry", + "rustc_version", + "schema_version", + "source_commit", +]; + +/// Container process limit of an ordinary CI Cargo build. +const CARGO_PIDS_LIMIT: u32 = 4096; +/// Container memory of an ordinary CI Cargo build. +const CARGO_MEMORY_BYTES: u64 = 8 * 1024 * 1024 * 1024; +/// Most CPUs an ordinary CI Cargo container receives (a 4-vCPU runner). +const CARGO_MAX_CPUS: u32 = 4; +/// Private `/tmp` of the Cargo container; Cargo's own `TMPDIR` is a root. +const CARGO_TMPFS_BYTES: u64 = 512 * 1024 * 1024; + +/// One retained Git object directory for a locked Git source. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct GitObjects { + pub(super) selector: String, + pub(super) revision: String, + pub(super) objects: PathBuf, +} + +/// One normalized Git manifest with the committed original it came from. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct NormalizedPair { + pub(super) name: String, + pub(super) version: String, + pub(super) selector: String, + pub(super) cargo_toml: PathBuf, + pub(super) cargo_toml_orig: PathBuf, +} + +/// The parsed, unauthenticated ordinary CI Cargo fixture. +#[derive(Debug)] +pub(super) struct CargoFixture { + pub(super) root: PathBuf, + pub(super) source_commit: String, + pub(super) cargo_lock_sha256: String, + pub(super) image_id: String, + pub(super) registry: BTreeMap<(String, String), PathBuf>, + pub(super) git: Vec, + pub(super) normalized: Vec, +} + +fn fixture_error(detail: impl std::fmt::Display) -> Report { + recipe_error(format!("cargo fixture: {detail}")) +} + +fn lower_hex(text: &str, length: usize) -> bool { + text.len() == length + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Returns the string field `name` of `value`. +fn text<'a>(value: &'a Value, name: &str) -> Result<&'a str, Report> { + value[name] + .as_str() + .filter(|text| !text.is_empty()) + .ok_or_else(|| fixture_error(format!("{name} is not a non-empty string"))) +} + +/// Joins a relative fixture path made only of normal components. +fn below(root: &Path, value: &Value, name: &str) -> Result> { + let relative = Path::new(text(value, name)?); + let normal = relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + && relative.components().next().is_some(); + if !normal { + return Err(fixture_error(format!( + "{name} must be a relative path of normal components" + ))); + } + Ok(root.join(relative)) +} + +/// Returns the array field `name` of `value`. +fn entries<'a>(value: &'a Value, name: &str) -> Result<&'a [Value], Report> { + value[name] + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| fixture_error(format!("{name} is not an array"))) +} + +impl CargoFixture { + /// Parses `fixture.json` of the fixture at `root`. + /// + /// # Errors + /// + /// Returns [`ReleaseError::Recipe`] for another schema, kind or + /// acquisition, a missing or extra field, a malformed commit, digest or + /// image ID, a path that is absolute or dotted, a repeated registry + /// identity, no Git source, or a normalized manifest whose selector names + /// no retained Git source. + pub(super) fn parse(root: &Path, document: &Value) -> Result> { + let fields: BTreeSet<&str> = document + .as_object() + .ok_or_else(|| fixture_error("description is not an object"))? + .keys() + .map(String::as_str) + .collect(); + if fields != BTreeSet::from(FIXTURE_FIELDS) { + return Err(fixture_error("description fields differ from the schema")); + } + if document["schema_version"].as_u64() != Some(SCHEMA_VERSION) + || text(document, "kind")? != FIXTURE_KIND + || text(document, "acquisition")? != ACQUISITION + { + return Err(fixture_error("unsupported schema, kind or acquisition")); + } + let source_commit = text(document, "source_commit")?.to_owned(); + let cargo_lock_sha256 = text(document, "cargo_lock_sha256")?.to_owned(); + let image_id = text(document, "image_id")?.to_owned(); + let image_ok = image_id + .strip_prefix("sha256:") + .is_some_and(|digest| lower_hex(digest, 64)); + if !lower_hex(&source_commit, 40) || !lower_hex(&cargo_lock_sha256, 64) || !image_ok { + return Err(fixture_error("malformed commit, lock digest or image ID")); + } + let git = entries(document, "git")? + .iter() + .map(|entry| { + Ok(GitObjects { + selector: text(entry, "selector")?.to_owned(), + revision: text(entry, "revision")?.to_owned(), + objects: below(root, entry, "objects")?, + }) + }) + .collect::, Report>>()?; + if git.is_empty() || git.iter().any(|entry| !lower_hex(&entry.revision, 40)) { + return Err(fixture_error("needs Git sources with full revisions")); + } + Ok(Self { + registry: registry(root, document)?, + normalized: normalized(root, document, &git)?, + root: root.to_owned(), + source_commit, + cargo_lock_sha256, + image_id, + git, + }) + } + + /// The private single-link Rust tool root. + pub(super) fn tools(&self) -> PathBuf { + self.root.join("tools") + } + + /// The staged producer probe inside the tool root. + pub(super) fn probe(&self) -> PathBuf { + self.root.join("tools/probe") + } +} + +/// Parses the registry originals, keyed by exact locked identity. +fn registry( + root: &Path, + document: &Value, +) -> Result, Report> { + let mut registry = BTreeMap::new(); + for entry in entries(document, "registry")? { + let key = ( + text(entry, "name")?.to_owned(), + text(entry, "version")?.to_owned(), + ); + if registry + .insert(key.clone(), below(root, entry, "path")?) + .is_some() + { + return Err(fixture_error(format!( + "registry repeats {} {}", + key.0, key.1 + ))); + } + } + Ok(registry) +} + +/// Parses the normalized manifest pairs; each must name a retained Git source. +fn normalized( + root: &Path, + document: &Value, + git: &[GitObjects], +) -> Result, Report> { + entries(document, "normalized")? + .iter() + .map(|entry| { + let pair = NormalizedPair { + name: text(entry, "name")?.to_owned(), + version: text(entry, "version")?.to_owned(), + selector: text(entry, "selector")?.to_owned(), + cargo_toml: below(root, entry, "cargo_toml")?, + cargo_toml_orig: below(root, entry, "cargo_toml_orig")?, + }; + if !git.iter().any(|source| source.selector == pair.selector) { + return Err(fixture_error(format!( + "normalized {} names no retained Git source", + pair.name + ))); + } + Ok(pair) + }) + .collect() +} + +/// Explicit container settings of an ordinary CI Cargo build. +/// +/// These are fixture settings for the ordinary CI runner, not production +/// pins: a qualified builder environment selects its own. +pub(super) fn cargo_docker_settings(user: (u32, u32), parallelism: u32) -> DockerSettings { + DockerSettings { + client: PathBuf::from("/usr/bin/docker"), + cpus: parallelism.clamp(1, CARGO_MAX_CPUS), + memory_bytes: CARGO_MEMORY_BYTES, + pids_limit: CARGO_PIDS_LIMIT, + tmpfs_bytes: CARGO_TMPFS_BYTES, + user, + stop_timeout_seconds: 10, + host_limits: ProcessLimits { + wall: Duration::from_secs(300), + stdout_max: 1024 * 1024, + stderr_max: 1024 * 1024, + }, + } +} + +fn role_text(role: Role) -> &'static str { + match role { + Role::A => "A", + Role::B => "B", + } +} + +/// The ordinary CI measurement of one confined Cargo build. +pub(super) fn cargo_measurement(completed: &RecipeCompleted) -> Value { + let artifact = completed.artifact(); + let [js_inventory, js_settings, js_tools] = completed.js_digests(); + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-confined-cargo-build", + "asset": completed.asset().artifact().name, + "role": role_text(completed.role()), + "source_commit": completed.commit().as_str(), + "cargo_lock_sha256": completed.cargo_lock_sha256(), + "artifact": { + "name": artifact.name(), + "sha256": artifact.sha256(), + "size": artifact.size(), + }, + "rust_tools_digest": completed.rust_tools_digest(), + "materials_digest": completed.materials_digest(), + "plan_digest": completed.plan_digest(), + "js_inventory_sha256": js_inventory, + "js_settings_sha256": js_settings, + "js_tools_sha256": js_tools, + "boundary_fingerprint": hex::encode(completed.boundary_fingerprint()), + "stdout_sha256": completed.stdout_sha256(), + "stderr_sha256": completed.stderr_sha256(), + "elapsed_seconds": completed.elapsed().as_secs(), + "derived_manifest_digests": completed.derived_manifest_digests().len(), + "release_qualification": false, + }) +} + +/// The ordinary CI measurement of the Cargo denial fixture. +pub(super) fn denial_measurement( + proof_errno: i32, + observed: &BTreeMap, + control: &RecipeCompleted, +) -> Value { + let cases: Map = observed + .iter() + .map(|(case, errno)| (case.clone(), json!({ "errno": errno }))) + .collect(); + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-cargo-denial", + "proof_errno": proof_errno, + "attempts": cases, + "control": { + "asset": control.asset().artifact().name, + "sha256": control.artifact().sha256(), + "size": control.artifact().size(), + }, + "release_qualification": false, + }) +} + +/// Returns the errno one denial fixture case reported in `text`. +/// +/// The fixture prints `TS_DENIAL case= errno=[]`, or +/// `... child errno=[]` from the re-executed grandchild. A connected +/// attempt, a child that never reported or any other text yields `None`. +pub(super) fn reported_errno(case: &str, text: &str) -> Option { + let marker = format!("TS_DENIAL case={case} "); + let start = text.find(&marker)? + marker.len(); + let rest = &text[start..]; + let rest = rest.strip_prefix("child ").unwrap_or(rest); + let digits = rest.strip_prefix("errno=[")?; + let end = digits.find(']')?; + digits[..end].parse().ok() +} + +#[test] +fn fixture_description_parses_relative_inputs() { + let root = Path::new("/example/fixture"); + let parsed = CargoFixture::parse(root, &description()).expect("should parse the description"); + assert_eq!( + parsed + .registry + .get(&("example".to_owned(), "1.0.0".to_owned())), + Some(&root.join("crates/example-1.0.0.crate")), + "should key registry originals by locked identity" + ); + assert_eq!( + parsed.git[0].objects, + root.join("git/0/objects"), + "should resolve Git objects below the root" + ); + assert_eq!( + parsed.normalized[0].cargo_toml_orig, + root.join("normalized/example-git-0.1.0/Cargo.toml.orig"), + "should resolve the committed original below the root" + ); + assert_eq!( + parsed.probe(), + root.join("tools/probe"), + "should fix the probe" + ); +} + +/// One in-place change to a fixture description. +type Edit = fn(&mut Value); + +#[test] +fn fixture_description_rejects_unsafe_or_foreign_inputs() { + let root = Path::new("/example/fixture"); + let cases: [(&str, Edit); 8] = [ + ("absolute path", |value| { + value["registry"][0]["path"] = json!("/example/elsewhere.crate"); + }), + ("dotted path", |value| { + value["git"][0]["objects"] = json!("git/../../objects"); + }), + ("extra field", |value| value["extra"] = json!(true)), + ("kind", |value| { + value["kind"] = json!("release-qualification") + }), + ("acquisition", |value| { + value["acquisition"] = json!("authenticated"); + }), + ("image", |value| value["image_id"] = json!("example:latest")), + ("duplicate", |value| { + let first = value["registry"][0].clone(); + value["registry"] + .as_array_mut() + .expect("should hold registry entries") + .push(first); + }), + ("orphan normalized", |value| { + value["normalized"][0]["selector"] = json!("git+https://example.com/other?tag=v2"); + }), + ]; + for (label, edit) in cases { + let mut value = description(); + edit(&mut value); + assert!( + CargoFixture::parse(root, &value).is_err(), + "should reject a description with a bad {label}" + ); + } +} + +#[test] +fn cargo_container_settings_are_explicit_and_fit_the_runner() { + let settings = cargo_docker_settings((1001, 1001), 16); + assert_eq!(settings.pids_limit, 4096, "should allow Cargo's job tree"); + assert_eq!( + settings.memory_bytes, + 8 * 1024 * 1024 * 1024, + "should give the Wasm link 8 GiB" + ); + assert_eq!(settings.cpus, 4, "should cap CPUs at a 4-vCPU runner"); + assert_eq!( + cargo_docker_settings((1001, 1001), 2).cpus, + 2, + "should never exceed host parallelism" + ); +} + +#[test] +fn reported_errno_reads_only_the_named_case() { + let text = "--- stderr\n TS_DENIAL case=build-direct errno=[1]\n"; + assert_eq!( + reported_errno("build-direct", text), + Some(1), + "should read errno" + ); + assert_eq!( + reported_errno( + "build-grandchild", + "TS_DENIAL case=build-grandchild child errno=[13]" + ), + Some(13), + "should read the grandchild report" + ); + assert_eq!( + reported_errno("macro-attempt", text), + None, + "should not read another case" + ); + for other in [ + "TS_DENIAL case=build-direct connected", + "TS_DENIAL case=build-direct child failed with exit status: 1", + "TS_DENIAL case=build-direct errno=[1", + ] { + assert_eq!( + reported_errno("build-direct", other), + None, + "should not invent an errno from {other:?}" + ); + } +} + +#[test] +fn denial_fixture_rows_stay_outside_the_fixed_table() { + for package in [ + "build-direct", + "build-grandchild", + "macro-attempt", + "control", + ] { + let asset = AssetId::denial_fixture(package).expect("should resolve the fixture member"); + let artifact = asset.artifact(); + assert_eq!( + artifact.bin, package, + "should name the binary after the package" + ); + assert_eq!( + artifact.target, + ReleaseTarget::Wasm32Wasip1, + "should build the fixture for Wasm" + ); + assert!( + AssetId::parse(artifact.name).is_err(), + "should keep {} unreachable from request text", + artifact.name + ); + assert!( + FIXED_ARTIFACTS + .iter() + .all(|fixed| fixed.name != artifact.name && fixed.package != package), + "should not touch the fixed release table" + ); + } + assert!( + AssetId::denial_fixture("trusted-server-adapter-fastly").is_err(), + "should resolve only fixture members" + ); +} + +fn description() -> Value { + let selector = "git+https://example.com/org/workspace?tag=v1"; + json!({ + "schema_version": 1, + "kind": "ordinary-ci-cargo-fixture", + "acquisition": "unauthenticated-ordinary-ci", + "source_commit": "0123456789abcdef0123456789abcdef01234567", + "cargo_lock_sha256": "a".repeat(64), + "image_id": format!("sha256:{}", "b".repeat(64)), + "rustc_version": "rustc 1.95.0 (example)", + "registry": [ + {"name": "example", "version": "1.0.0", "path": "crates/example-1.0.0.crate"} + ], + "git": [ + {"selector": selector, "revision": "c".repeat(40), "objects": "git/0/objects"} + ], + "normalized": [{ + "name": "example-git", + "version": "0.1.0", + "selector": selector, + "cargo_toml": "normalized/example-git-0.1.0/Cargo.toml", + "cargo_toml_orig": "normalized/example-git-0.1.0/Cargo.toml.orig", + }], + }) +} diff --git a/crates/trusted-server-release/src/recipe/tests/complete.rs b/crates/trusted-server-release/src/recipe/tests/complete.rs index 464cf117ee..8b611567e1 100644 --- a/crates/trusted-server-release/src/recipe/tests/complete.rs +++ b/crates/trusted-server-release/src/recipe/tests/complete.rs @@ -476,3 +476,61 @@ fn derived_manifest_digests_are_carried_into_the_completion() { "should carry the derived manifest digest so the build stays marked unqualified" ); } + +#[test] +fn candidate_measurements_report_no_release_qualification() { + let asset = "trusted-server-fastly.wasm"; + let kept = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); + let mut built = built_with(asset, Role::A, &kept, true); + let stdout = built.stdout(|_| {}); + let completed = built.finish(stdout).expect("should complete the run"); + + let measurement = super::candidate::cargo_measurement(&completed); + assert_eq!( + measurement["kind"], "ordinary-ci-confined-cargo-build", + "should name the candidate kind" + ); + assert_eq!( + measurement["release_qualification"], false, + "should never claim release qualification" + ); + assert_eq!(measurement["asset"], asset, "should name the asset"); + assert_eq!(measurement["role"], "A", "should name the role"); + assert_eq!( + measurement["source_commit"], COMMIT, + "should bind the commit" + ); + assert_eq!( + measurement["artifact"]["sha256"], + hex::encode(Sha256::digest(&kept)), + "should report the copied artifact digest" + ); + assert_eq!( + measurement["artifact"]["size"], + kept.len(), + "should report the copied artifact size" + ); + assert_eq!( + measurement["derived_manifest_digests"], 1, + "should count the derived manifests that keep the build unqualified" + ); + for field in ["rust_tools_digest", "materials_digest", "plan_digest"] { + assert_eq!( + measurement[field].as_str().map(str::len), + Some(64), + "should report the {field}" + ); + } + + let observed = std::collections::BTreeMap::from([("build-direct".to_owned(), libc::EPERM)]); + let denial = super::candidate::denial_measurement(libc::EPERM, &observed, &completed); + assert_eq!( + denial["attempts"]["build-direct"]["errno"], + libc::EPERM, + "should report each observed errno" + ); + assert_eq!( + denial["release_qualification"], false, + "should never claim release qualification" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/full.rs b/crates/trusted-server-release/src/recipe/tests/full.rs new file mode 100644 index 0000000000..78e5385606 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/full.rs @@ -0,0 +1,406 @@ +//! Explicit real Linux Cargo fixtures; never selected by a normal focused unit run. +//! +//! Both runs are ordinary CI candidate evidence (`release_qualification: +//! false`): the toolchain, container image and fixture acquisition are +//! unqualified and unauthenticated, so nothing here qualifies a release. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::super::ambient::ObservedAmbient; +use super::candidate::{ + CargoFixture, cargo_docker_settings, cargo_measurement, denial_measurement, reported_errno, +}; +use super::*; +use crate::boundary::{Boundary, DenialProof, ImageSelection, ProbeProgram}; +use crate::documents::{parse_json, read_bounded}; +use crate::materials::{GitVendorInput, LockedMaterials, NormalizedManifest, VendorInputs}; +use crate::preflight::child::build_child_for_tests; +use crate::preflight::{AuditedEnvironment, audit_environment}; +use crate::preparation::tests::full::run_two_fresh_js_builds; +use crate::process::{Cancellation, ProcessLimits}; + +/// The committed denial fixture workspace. +const DENIAL_FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cargo-denial"); +/// Fictional committer epoch of the denial fixture child environment. +const DENIAL_EPOCH: u64 = 1_700_000_000; +/// Members that attempt a connection while Cargo builds them. +const ATTEMPTS: [&str; 3] = ["build-direct", "build-grandchild", "macro-attempt"]; + +/// Reads the ordinary CI Cargo fixture named by `TS_RELEASE_CARGO_FIXTURE_ROOT`. +fn cargo_fixture() -> CargoFixture { + let root = std::env::var_os("TS_RELEASE_CARGO_FIXTURE_ROOT") + .map(PathBuf::from) + .expect("should explicitly supply the ordinary CI Cargo fixture root"); + let root = fs::canonicalize(root).expect("should resolve the Cargo fixture root"); + let raw = read_bounded(&root.join("fixture.json"), DocumentRole::Metadata) + .expect("should read the Cargo fixture description"); + let document = + parse_json(&raw.bytes, DocumentRole::Metadata).expect("should parse the description"); + CargoFixture::parse(&root, &document).expect("should accept the Cargo fixture description") +} + +fn minimal_environment() -> AuditedEnvironment { + audit_environment(&[(OsString::from("PATH"), OsString::from("/usr/bin:/bin"))]) + .expect("should audit the minimal environment") +} + +/// Role A conditions of an ordinary CI build. +/// +/// The umask, jobs, locale and timezone are the profile values the plan +/// enforces on the child; this is not an independent qualified observation. +fn ordinary_ci_ambient(checkout: &Path) -> CheckedAmbient { + // Reading the real user ID has no preconditions. + let execution_uid = unsafe { libc::getuid() }; + let started_at_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("should read a clock after the Unix epoch") + .as_secs(); + CheckedAmbient::check( + Role::A, + &AmbientProfile::A, + ObservedAmbient { + execution_uid, + checkout_root: checkout.to_owned(), + started_at_seconds, + ..observed_a() + }, + ) + .expect("should match the role A profile") +} + +/// A configured container boundary with its proof for this instance. +struct Proven { + _state: tempfile::TempDir, + boundary: Boundary, + proof: DenialProof, +} + +/// Configures the exact recipe boundary and proves its denial. +fn proven_boundary(prepared: &PreparedRecipe, fixture: &CargoFixture) -> Proven { + let state = tempfile::tempdir().expect("should create disjoint boundary host state"); + // These IDs only select the existing unprivileged CI worker account. + let user = unsafe { (libc::getuid(), libc::getgid()) }; + let parallelism = std::thread::available_parallelism() + .map_or(1, |count| u32::try_from(count.get()).unwrap_or(u32::MAX)); + let boundary = Boundary::linux_container( + prepared + .boundary_roots() + .expect("should derive the exact recipe roots"), + cargo_docker_settings(user, parallelism), + ImageSelection::Test { + id: fixture.image_id.clone(), + }, + state.path(), + ) + .expect("should configure the owned PID/network container boundary"); + let probe = ProbeProgram::from_staged_release_binary(&fixture.probe()) + .expect("should select the staged producer probe"); + let limits = ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }; + let proof = boundary + .establish_denial(&probe, limits) + .expect("should establish real positive and denied socket controls"); + assert!( + matches!(proof.denied_errno(), libc::EPERM | libc::EACCES), + "should prove denial by the seccomp profile, not by a missing route" + ); + Proven { + _state: state, + boundary, + proof, + } +} + +/// Copies a tree of directories and regular files into a new directory. +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir(destination).expect("should create the copied directory"); + for entry in fs::read_dir(source).expect("should list the fixture directory") { + let entry = entry.expect("should read the fixture entry"); + let kind = entry.file_type().expect("should inspect the fixture entry"); + let target = destination.join(entry.file_name()); + if kind.is_dir() { + copy_tree(&entry.path(), &target); + } else if kind.is_file() { + fs::copy(entry.path(), &target).expect("should copy the fixture file"); + } else { + panic!("should hold only directories and regular files"); + } + } +} + +/// One bound denial fixture member with the roots it owns. +struct DenialCase { + _work: tempfile::TempDir, + _completed: JsBuildCompleted, + prepared: PreparedRecipe, +} + +/// Binds one denial fixture member over fresh private roots. +fn bind_denial_case(fixture: &CargoFixture, member: &str) -> DenialCase { + let work = tempfile::tempdir().expect("should create the case roots"); + let base = fs::canonicalize(work.path()).expect("should resolve the case roots"); + let checkout = base.join("checkout"); + copy_tree(Path::new(DENIAL_FIXTURE), &checkout); + for directory in ["materials/vendor", "cargo-state", "output"] { + fs::create_dir_all(base.join(directory)).expect("should create a case root"); + } + let completed = JsBuildCompleted::fixture_for_tests(&fixture.source_commit); + let retained = fs::canonicalize( + completed + .rehash_for_handoff() + .expect("should rehash the fixture handoff"), + ) + .expect("should resolve the retained root"); + let request = RecipeRequest::parse_for_asset( + AssetId::denial_fixture(member).expect("should resolve the fixture member"), + &RecipeInput { + asset: member, + variant: "default", + role: "A", + commit: &fixture.source_commit, + js_dir: &retained.join("bundles"), + js_inventory: &retained.join("tsjs-inventory.json"), + materials: &base.join("materials"), + cargo_state: &base.join("cargo-state"), + output: &base.join("output"), + target_dir: &base.join("target"), + }, + ) + .expect("should accept the case request"); + let commit = CommitId::parse(&fixture.source_commit).expect("should parse the commit"); + let child = build_child_for_tests( + &minimal_environment(), + &commit, + DENIAL_EPOCH, + &child_request(&request), + ) + .expect("should build the child environment"); + let prepared = PreparedRecipe::bind(BindInputs { + commit: &commit, + child, + completed: &completed, + tools: SelectedRustTools::for_tests(&fixture.tools(), ReleaseTarget::Wasm32Wasip1) + .expect("should select the ordinary CI Rust tools"), + materials: PreparedCargoMaterials::for_tests(&base.join("materials"), commit.as_str(), &[]) + .expect("should describe the empty vendor directory"), + ambient: ordinary_ci_ambient(&checkout), + request, + }) + .expect("should bind the denial case"); + DenialCase { + _work: work, + _completed: completed, + prepared, + } +} + +#[test] +#[ignore = "requires the explicit ordinary CI Cargo fixture and Linux Docker; run separately in producer CI"] +fn cargo_children_inherit_denial() { + let fixture = cargo_fixture(); + let mut observed = BTreeMap::new(); + let mut proof_errno = None; + for case in ATTEMPTS { + let bound = bind_denial_case(&fixture, case); + let proven = proven_boundary(&bound.prepared, &fixture); + let errno = proven.proof.denied_errno(); + let error = bound + .prepared + .execute(&proven.boundary, &proven.proof) + .expect_err("should fail a build that attempts a connection"); + let text = format!("{error:?}"); + let reported = reported_errno(case, &text) + .unwrap_or_else(|| panic!("should report the {case} attempt errno: {text}")); + assert_eq!( + reported, errno, + "should deny the {case} attempt with the proven seccomp errno: {text}" + ); + assert_ne!( + reported, + libc::ECONNREFUSED, + "should deny {case} before the empty network namespace refuses it" + ); + observed.insert(case.to_owned(), reported); + proof_errno = Some(errno); + } + let bound = bind_denial_case(&fixture, "control"); + let proven = proven_boundary(&bound.prepared, &fixture); + let control = bound + .prepared + .execute(&proven.boundary, &proven.proof) + .expect("should build the control through the confined recipe"); + control + .rehash_for_handoff() + .expect("should rehash the control artifact before reporting it"); + assert_eq!( + control.asset().artifact().name, + "denial-control.wasm", + "should copy the control under its asset name" + ); + let measurement = denial_measurement( + proof_errno.expect("should observe every attempt"), + &observed, + &control, + ); + let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write(fixture.root.join("cargo-denial-measurement.json"), text) + .expect("should retain the denial measurement"); +} + +/// Prepares the vendor tree from the fixture's retained originals. +fn prepare_vendor( + fixture: &CargoFixture, + locked: &LockedMaterials, + root: &Path, + deadline: Instant, + cancellation: &Cancellation, +) -> PreparedCargoMaterials { + let collection = locked + .git_collection(Path::new("/usr/bin/git"), deadline, cancellation) + .expect("should select the runner Git for object verification only"); + let verified: Vec<_> = fixture + .git + .iter() + .map(|source| { + collection + .verify(&source.selector, &source.revision, &source.objects) + .expect("should verify the retained locked Git closure") + }) + .collect(); + let manifests: Vec<_> = fixture + .normalized + .iter() + .map(|pair| NormalizedManifest { + cargo_toml: fs::read(&pair.cargo_toml).expect("should read the normalized manifest"), + cargo_toml_orig: fs::read(&pair.cargo_toml_orig) + .expect("should read the committed manifest"), + }) + .collect(); + let inputs: Vec> = fixture + .normalized + .iter() + .zip(&manifests) + .map(|(pair, manifest)| { + let index = fixture + .git + .iter() + .position(|source| source.selector == pair.selector) + .expect("should name a retained Git source"); + GitVendorInput { + material: &verified[index], + name: &pair.name, + version: &pair.version, + normalized: Some(manifest.clone()), + } + }) + .collect(); + locked + .prepare_cargo_vendor( + &VendorInputs { + registry_archives: &fixture.registry, + git: &inputs, + }, + root, + deadline, + cancellation, + ) + .expect("should vendor every locked original") +} + +#[test] +#[ignore = "requires explicit JS and Cargo ordinary CI fixtures and Linux Docker; run separately in producer CI"] +fn real_js_handoff_to_fastly_cargo_build() { + let fixture = cargo_fixture(); + let js = run_two_fresh_js_builds(); + assert_eq!( + js.source.commit().as_str(), + fixture.source_commit, + "should build the Cargo fixture's source commit" + ); + println!( + "{}", + serde_json::to_string_pretty(&js.measurement).expect("should encode the JS measurement") + ); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(40 * 60); + let fresh = js + .source + .materialize(deadline, &cancellation) + .expect("should materialize a third fresh tracked source"); + let materials_directory = tempfile::tempdir().expect("should create the materials root"); + let materials_root = + fs::canonicalize(materials_directory.path()).expect("should resolve the materials root"); + let materials = prepare_vendor( + &fixture, + &js.locked, + &materials_root, + deadline, + &cancellation, + ); + assert_eq!( + materials.cargo_lock_sha256(), + fixture.cargo_lock_sha256, + "should vendor from the fixture's Cargo.lock" + ); + let work = tempfile::tempdir().expect("should create the build roots"); + let base = fs::canonicalize(work.path()).expect("should resolve the build roots"); + for directory in ["cargo-state", "output"] { + fs::create_dir(base.join(directory)).expect("should create a build root"); + } + let retained = fs::canonicalize( + js.completed + .rehash_for_handoff() + .expect("should rehash the JS handoff"), + ) + .expect("should resolve the retained root"); + let request = RecipeRequest::parse(&RecipeInput { + asset: "trusted-server-fastly.wasm", + variant: "default", + role: "A", + commit: &fixture.source_commit, + js_dir: &retained.join("bundles"), + js_inventory: &retained.join("tsjs-inventory.json"), + materials: &materials_root, + cargo_state: &base.join("cargo-state"), + output: &base.join("output"), + target_dir: &base.join("target"), + }) + .expect("should accept the Fastly request"); + let prepared = PreparedRecipe::new( + &js.source, + &fresh, + &js.audited, + &js.completed, + SelectedRustTools::for_tests(&fixture.tools(), ReleaseTarget::Wasm32Wasip1) + .expect("should select the ordinary CI Rust tools"), + materials, + ordinary_ci_ambient(fresh.root()), + request, + ) + .expect("should bind the real source, handoff, tools and materials"); + let proven = proven_boundary(&prepared, &fixture); + let completed = prepared + .execute(&proven.boundary, &proven.proof) + .expect("should build the Fastly Wasm through the confined recipe"); + completed + .rehash_for_handoff() + .expect("should rehash the artifact before reporting it"); + assert!( + !completed.derived_manifest_digests().is_empty(), + "should mark the build as consuming derived Git manifests" + ); + let measurement = cargo_measurement(&completed); + let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write(fixture.root.join("cargo-execution-measurement.json"), text) + .expect("should retain the Cargo measurement"); + fresh.close().expect("should remove the fresh source copy"); +} diff --git a/crates/trusted-server-release/src/recipe/tests/tools.rs b/crates/trusted-server-release/src/recipe/tests/tools.rs index 8338716887..0a9c1d8f41 100644 --- a/crates/trusted-server-release/src/recipe/tests/tools.rs +++ b/crates/trusted-server-release/src/recipe/tests/tools.rs @@ -201,3 +201,66 @@ fn tools_edited_after_binding_are_rejected_at_the_execute_gate() { "should name the changed materials" ); } + +#[test] +fn handoff_edits_and_cargo_home_caches_are_rejected_around_the_run() { + let fixture = Fixture::new(COMMIT); + let retained = fixture.retained(); + let prepared = fixture + .bind("ts-aarch64-apple-darwin", Role::A) + .expect("should bind the recipe"); + prepared + .check_inputs() + .expect("should accept the untouched handoff, tools and materials"); + fs::write(retained.join("bundles/tsjs-core.js"), b"tampered") + .expect("should edit a retained module"); + assert!( + format!( + "{:?}", + prepared + .check_inputs() + .expect_err("should reject the edited handoff") + ) + .contains("retained JS handoff changed"), + "should name the changed handoff" + ); + fs::write(retained.join("bundles/tsjs-core.js"), b"fictional module") + .expect("should restore the retained module"); + prepared + .check_inputs() + .expect("should accept the restored handoff"); + + prepared + .check_no_fetched_sources() + .expect("should accept a Cargo home without caches"); + for cache in ["registry", "git"] { + let path = fixture.base.join("cargo-state/cargo-home").join(cache); + fs::create_dir(&path).expect("should create a Cargo cache"); + assert!( + format!( + "{:?}", + prepared + .check_no_fetched_sources() + .expect_err("should reject a Cargo cache") + ) + .contains(&format!("holds a {cache} cache")), + "should name the {cache} cache" + ); + fs::remove_dir(&path).expect("should remove the Cargo cache"); + } +} + +#[test] +fn only_the_ordinary_ci_selection_permits_a_candidate_run() { + let fixture = Fixture::new(COMMIT); + let tools = fixture.tools_for(ReleaseTarget::Wasm32Wasip1); + assert!( + tools.permits_candidate_run(), + "should let the ordinary CI selection start a candidate run" + ); + assert_eq!( + tools.authority(), + Authority::Unqualified, + "should still carry no release authority" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tools.rs b/crates/trusted-server-release/src/recipe/tools.rs index 51fe6a08eb..c7c3734a1d 100644 --- a/crates/trusted-server-release/src/recipe/tools.rs +++ b/crates/trusted-server-release/src/recipe/tools.rs @@ -48,6 +48,19 @@ impl ToolSelection { Self::OrdinaryCi => Authority::Unqualified, } } + + /// Reports whether this selection may start an unqualified candidate run. + /// + /// Only the test-only ordinary CI selection may, so a candidate run is + /// evidence for CI and never release qualification. A production build + /// can name no selection at all, so its execute gate stays closed. + pub(crate) fn permits_candidate_run(&self) -> bool { + match self { + Self::Qualified(qualification) => match *qualification {}, + #[cfg(test)] + Self::OrdinaryCi => true, + } + } } /// Fixed `bin/cargo`, `bin/rustc` and target standard-library layout, retained @@ -130,6 +143,11 @@ impl SelectedRustTools { self.selection.authority() } + /// Whether this selection may start an unqualified candidate run. + pub(crate) fn permits_candidate_run(&self) -> bool { + self.selection.permits_candidate_run() + } + /// Host path of the selected Cargo. pub(crate) fn cargo(&self) -> PathBuf { self.root.join("bin/cargo") diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock b/crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock new file mode 100644 index 0000000000..ca5880e993 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.lock @@ -0,0 +1,19 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "build-direct" +version = "0.1.0" + +[[package]] +name = "build-grandchild" +version = "0.1.0" + +[[package]] +name = "control" +version = "0.1.0" + +[[package]] +name = "macro-attempt" +version = "0.1.0" diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml b/crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml new file mode 100644 index 0000000000..c1d688f798 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/Cargo.toml @@ -0,0 +1,18 @@ +# Ordinary CI denial fixture: every member but `control` tries one loopback +# connection while Cargo builds it and fails the build reporting the errno. +[workspace] +resolver = "3" +members = [ + "crates/build-direct", + "crates/build-grandchild", + "crates/control", + "crates/macro-attempt", +] + +[workspace.package] +edition = "2024" +publish = false +version = "0.1.0" + +[profile.release] +debug = 1 diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml new file mode 100644 index 0000000000..ee55bd2fd2 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "build-direct" +edition.workspace = true +publish.workspace = true +version.workspace = true +build = "build.rs" diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs new file mode 100644 index 0000000000..8a29343946 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/build.rs @@ -0,0 +1,13 @@ +//! Attempts one loopback connection while Cargo runs this build script. + +use std::net::TcpStream; + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + let report = match TcpStream::connect(("127.0.0.1", 9)) { + Ok(_) => "connected".to_owned(), + Err(error) => format!("errno=[{}]", error.raw_os_error().unwrap_or(-1)), + }; + eprintln!("TS_DENIAL case=build-direct {report}"); + std::process::exit(1); +} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs new file mode 100644 index 0000000000..3b4d2fdf57 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-direct/src/main.rs @@ -0,0 +1,3 @@ +//! Never built: the build script fails first. + +fn main() {} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml new file mode 100644 index 0000000000..3db47f1365 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "build-grandchild" +edition.workspace = true +publish.workspace = true +version.workspace = true +build = "build.rs" diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs new file mode 100644 index 0000000000..e544f0b3c9 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/build.rs @@ -0,0 +1,29 @@ +//! Re-executes this build script; the child attempts one loopback connection. + +use std::net::TcpStream; +use std::process::Command; + +/// Marks the re-executed child. +const CHILD: &str = "TS_DENIAL_GRANDCHILD"; + +fn main() { + if std::env::var_os(CHILD).is_some() { + let report = match TcpStream::connect(("127.0.0.1", 9)) { + Ok(_) => "connected".to_owned(), + Err(error) => format!("errno=[{}]", error.raw_os_error().unwrap_or(-1)), + }; + println!("{report}"); + return; + } + println!("cargo::rerun-if-changed=build.rs"); + let program = std::env::current_exe().expect("should locate the build script"); + let report = match Command::new(program).env(CHILD, "1").output() { + Ok(output) if output.status.success() => { + format!("child {}", String::from_utf8_lossy(&output.stdout).trim()) + } + Ok(output) => format!("child failed with {}", output.status), + Err(error) => format!("child could not start: {error}"), + }; + eprintln!("TS_DENIAL case=build-grandchild {report}"); + std::process::exit(1); +} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs new file mode 100644 index 0000000000..3b4d2fdf57 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/build-grandchild/src/main.rs @@ -0,0 +1,3 @@ +//! Never built: the build script fails first. + +fn main() {} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml new file mode 100644 index 0000000000..c4da64d26b --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "control" +edition.workspace = true +publish.workspace = true +version.workspace = true +build = "build.rs" diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs new file mode 100644 index 0000000000..364cd30c61 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/build.rs @@ -0,0 +1,5 @@ +//! A build script that attempts nothing, so the control still runs one. + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); +} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs new file mode 100644 index 0000000000..3a8cdb3704 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/control/src/main.rs @@ -0,0 +1,5 @@ +//! Builds without any connection attempt. + +fn main() { + println!("control"); +} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml new file mode 100644 index 0000000000..70ec74ca80 --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "macro-attempt" +edition.workspace = true +publish.workspace = true +version.workspace = true + +[lib] +proc-macro = true + +[[bin]] +name = "macro-attempt" +path = "src/main.rs" diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs new file mode 100644 index 0000000000..a3761ecd3e --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/lib.rs @@ -0,0 +1,17 @@ +//! A procedural macro that attempts one loopback connection while it expands. + +use std::net::TcpStream; + +use proc_macro::TokenStream; + +/// Expands to a compile error reporting the connection attempt. +#[proc_macro] +pub fn attempt(_input: TokenStream) -> TokenStream { + let report = match TcpStream::connect(("127.0.0.1", 9)) { + Ok(_) => "connected".to_owned(), + Err(error) => format!("errno=[{}]", error.raw_os_error().unwrap_or(-1)), + }; + format!("compile_error!(\"TS_DENIAL case=macro-attempt {report}\");") + .parse() + .expect("should parse the generated report") +} diff --git a/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs new file mode 100644 index 0000000000..0c2156a90b --- /dev/null +++ b/crates/trusted-server-release/tests/fixtures/cargo-denial/crates/macro-attempt/src/main.rs @@ -0,0 +1,5 @@ +//! Uses the connecting macro, so building this binary expands it. + +macro_attempt::attempt!(); + +fn main() {} diff --git a/scripts/release/tests/cargo-fixture.Dockerfile b/scripts/release/tests/cargo-fixture.Dockerfile new file mode 100644 index 0000000000..04e0a3d63b --- /dev/null +++ b/scripts/release/tests/cargo-fixture.Dockerfile @@ -0,0 +1,6 @@ +# Ordinary PR fixture only. This image does not qualify any release recipe. +# gcc and libc6-dev give Cargo build scripts and proc macros a host linker. +FROM ubuntu:24.04 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates gcc git libc6-dev libgcc-s1 libstdc++6 \ + && rm -rf /var/lib/apt/lists/* diff --git a/scripts/release/tests/prepare-cargo-fixture.mjs b/scripts/release/tests/prepare-cargo-fixture.mjs new file mode 100644 index 0000000000..d7d63e0f53 --- /dev/null +++ b/scripts/release/tests/prepare-cargo-fixture.mjs @@ -0,0 +1,353 @@ +/** + * Acquire ordinary CI Cargo fixture inputs. + * + * This is unauthenticated ordinary CI acquisition: it copies the runner's + * rustup toolchain and downloads crates and Git objects over the network. It + * does not authenticate or qualify any release tool, image or input. + */ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const WASM_TARGET = 'wasm32-wasip1' +const CRATES_IO = 'registry+https://github.com/rust-lang/crates.io-index' +const MAXIMUM_CRATE = 64 * 1024 * 1024 + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../..' +) + +/** The pinned Rust version: `.tool-versions` and `rust-toolchain.toml` must agree. */ +function pinnedRustVersion() { + const pins = fs + .readFileSync(path.join(root, '.tool-versions'), 'utf8') + .split('\n') + .map((line) => line.trim().split(/\s+/)) + .filter(([tool]) => tool === 'rust') + const channel = /^channel = "([^"]+)"$/m.exec( + fs.readFileSync(path.join(root, 'rust-toolchain.toml'), 'utf8') + ) + if ( + pins.length !== 1 || + !/^\d+\.\d+\.\d+$/.test(pins[0][1] ?? '') || + channel?.[1] !== pins[0][1] + ) + throw new Error('Expected one exact Rust pin shared by both tool files') + return pins[0][1] +} +const RUST_VERSION = pinnedRustVersion() +const [destinationArgument, producerArgument] = process.argv.slice(2) +if (process.platform !== 'linux') + throw new Error('Cargo fixture requires Linux') +if (!destinationArgument || !producerArgument || process.argv.length !== 4) + throw new Error( + 'Expected fresh fixture destination and source-built producer' + ) +const destination = path.resolve(destinationArgument) +const producer = fs.realpathSync(producerArgument) +if (fs.existsSync(destination)) + throw new Error('Fixture destination must be fresh') +fs.mkdirSync(destination, { recursive: true, mode: 0o700 }) +const tools = path.join(destination, 'tools') +const work = path.join(destination, 'work') +const source = path.join(work, 'source') +const cargoHome = path.join(work, 'cargo-home') +const vendor = path.join(work, 'vendor') +fs.mkdirSync(work, { mode: 0o700 }) + +const gitEnvironment = { + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', +} +const hostEnvironment = { + PATH: process.env.PATH, + HOME: os.homedir(), + RUSTUP_TOOLCHAIN: RUST_VERSION, + ...(process.env.RUSTUP_HOME ? { RUSTUP_HOME: process.env.RUSTUP_HOME } : {}), + ...gitEnvironment, +} +// Cargo runs from the private tool copy with a fresh home and no rustup. +const cargoEnvironment = { + PATH: `${path.join(tools, 'bin')}:/usr/bin:/bin`, + HOME: os.homedir(), + CARGO_HOME: cargoHome, + RUSTC: path.join(tools, 'bin/rustc'), + ...gitEnvironment, +} + +const run = (program, args, { cwd = root, env = hostEnvironment } = {}) => + execFileSync(program, args, { + cwd, + env, + timeout: 20 * 60_000, + maxBuffer: 256 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'inherit'], + }) +const text = (program, args, options) => + run(program, args, options).toString('utf8').trim() +const sha256 = (bytes) => + crypto.createHash('sha256').update(bytes).digest('hex') + +// 1. The private single-link Rust tool root, with the staged probe inside. +const sysroot = text('rustc', ['--print', 'sysroot']) +const rustcVersion = text(path.join(sysroot, 'bin/rustc'), ['--version']) +if (!rustcVersion.startsWith(`rustc ${RUST_VERSION} `)) + throw new Error( + `Fixture requires Rust ${RUST_VERSION}, found ${rustcVersion}` + ) +const wasmStd = path.join(sysroot, 'lib/rustlib', WASM_TARGET, 'lib') +if ( + !fs.existsSync(wasmStd) || + !fs.readdirSync(wasmStd).some((name) => name.startsWith('libstd-')) +) + throw new Error( + `Selected toolchain lacks the ${WASM_TARGET} standard library` + ) +fs.cpSync(sysroot, tools, { + recursive: true, + dereference: false, + verbatimSymlinks: true, +}) +fs.copyFileSync(producer, path.join(tools, 'probe'), fs.constants.COPYFILE_EXCL) +fs.chmodSync(path.join(tools, 'probe'), 0o700) + +// 2. The exact tracked source at HEAD, never the developer working tree. +run('git', ['clone', '--no-hardlinks', '--no-checkout', '--', root, source]) +run('git', ['checkout', '--detach', '--force', 'HEAD'], { cwd: source }) +const commit = text('git', ['rev-parse', 'HEAD'], { cwd: source }) +const lockBytes = fs.readFileSync(path.join(source, 'Cargo.lock')) + +// 3. Unauthenticated network acquisition into a fresh Cargo home. +process.stderr.write( + 'Acquiring Cargo originals without authentication (ordinary CI only)\n' +) +run(path.join(tools, 'bin/cargo'), ['fetch', '--locked'], { + cwd: source, + env: cargoEnvironment, +}) + +/** Minimal `Cargo.lock` reader: one record per `[[package]]` table. */ +function lockedPackages(bytes) { + const packages = [] + let current = null + for (const line of bytes.toString('utf8').split('\n')) { + if (line === '[[package]]') { + current = {} + packages.push(current) + continue + } + const field = /^(name|version|source|checksum) = "([^"]*)"$/.exec(line) + if (current && field) current[field[1]] = field[2] + } + return packages +} +const locked = lockedPackages(lockBytes) + +// 4. Each original registry archive, checked against its locked checksum. +const caches = fs.readdirSync(path.join(cargoHome, 'registry/cache')) +if (caches.length !== 1) + throw new Error('Expected exactly one crates.io archive cache') +const cache = path.join(cargoHome, 'registry/cache', caches[0]) +fs.mkdirSync(path.join(destination, 'crates'), { mode: 0o700 }) +const registry = [] +for (const record of locked.filter((entry) => entry.source === CRATES_IO)) { + if (!/^[a-f0-9]{64}$/.test(record.checksum ?? '')) + throw new Error(`Registry crate ${record.name} has no locked checksum`) + const file = `${record.name}-${record.version}.crate` + const bytes = fs.readFileSync(path.join(cache, file)) + if (bytes.length > MAXIMUM_CRATE || sha256(bytes) !== record.checksum) + throw new Error(`Registry crate ${file} differs from its locked checksum`) + fs.writeFileSync(path.join(destination, 'crates', file), bytes, { + flag: 'wx', + mode: 0o600, + }) + registry.push({ + name: record.name, + version: record.version, + path: `crates/${file}`, + }) +} + +// 5. Git objects of every locked Git source; loose and pack objects only. +const gitPackages = locked + .filter((entry) => entry.source?.startsWith('git+')) + .map((entry) => { + const [selector, revision] = entry.source.split('#') + if (!/^[a-f0-9]{40}$/.test(revision ?? '')) + throw new Error(`Git package ${entry.name} has no full revision`) + return { ...entry, selector, revision } + }) +const databases = fs + .readdirSync(path.join(cargoHome, 'git/db')) + .map((name) => path.join(cargoHome, 'git/db', name)) +const containsCommit = (database, revision) => { + try { + execFileSync( + 'git', + [`--git-dir=${database}`, 'cat-file', '-e', `${revision}^{commit}`], + { env: hostEnvironment, timeout: 60_000, stdio: 'ignore' } + ) + return true + } catch { + return false + } +} +function copyObjects(input, output) { + fs.mkdirSync(output, { recursive: true, mode: 0o700 }) + for (const name of fs.readdirSync(input)) { + // Commit graphs and alternates live under info/; the verifier needs none. + if (name === 'info') continue + const loose = /^[a-f0-9]{2}$/.test(name) + if (!loose && name !== 'pack') + throw new Error(`Unexpected Git object entry ${name}`) + fs.mkdirSync(path.join(output, name), { mode: 0o700 }) + for (const child of fs.readdirSync(path.join(input, name))) { + if (name === 'pack' && child.endsWith('.keep')) continue + const accepted = loose + ? /^[a-f0-9]{38}$/.test(child) + : /^pack-[a-f0-9]{40}\.(pack|idx|rev)$/.test(child) + if (!accepted) throw new Error(`Unexpected Git object file ${child}`) + fs.copyFileSync( + path.join(input, name, child), + path.join(output, name, child), + fs.constants.COPYFILE_EXCL + ) + } + } +} +const git = [] +const databaseOf = new Map() +for (const { selector, revision } of gitPackages) { + if (git.some((entry) => entry.selector === selector)) continue + const matching = databases.filter((database) => + containsCommit(database, revision) + ) + if (matching.length !== 1) + throw new Error(`Expected one Git database holding ${revision}`) + const objects = `git/${git.length}/objects` + copyObjects( + path.join(matching[0], 'objects'), + path.join(destination, objects) + ) + databaseOf.set(selector, matching[0]) + git.push({ selector, revision, objects }) +} + +// 6. Normalized Git manifests from `cargo vendor`, paired with the committed +// original read from the Git object database at the locked revision. +run( + path.join(tools, 'bin/cargo'), + ['vendor', '--locked', '--offline', '--versioned-dirs', vendor], + { cwd: source, env: cargoEnvironment } +) +const metadata = JSON.parse( + run( + path.join(tools, 'bin/cargo'), + ['metadata', '--format-version', '1', '--locked', '--offline'], + { cwd: source, env: cargoEnvironment } + ).toString('utf8') +) +const checkouts = path.join(cargoHome, 'git/checkouts') + path.sep +const normalized = [] +let vendoredOriginals = 0 +for (const record of gitPackages) { + const described = metadata.packages.filter( + (entry) => + entry.name === record.name && + entry.version === record.version && + entry.source === record.source + ) + if (described.length !== 1) + throw new Error(`Expected one metadata record for ${record.name}`) + const manifestPath = described[0].manifest_path + if (!manifestPath.startsWith(checkouts)) + throw new Error(`Git manifest ${manifestPath} is outside the checkouts`) + // /// + const relative = manifestPath.slice(checkouts.length).split(path.sep) + const repositoryPath = relative.slice(2).join('/') + const original = run('git', [ + `--git-dir=${databaseOf.get(record.selector)}`, + 'show', + `${record.revision}:${repositoryPath}`, + ]) + if (!original.equals(fs.readFileSync(manifestPath))) + throw new Error(`Checkout manifest of ${record.name} differs from its blob`) + const directory = `${record.name}-${record.version}` + const vendored = path.join(vendor, directory) + if (fs.existsSync(path.join(vendored, 'Cargo.toml.orig'))) vendoredOriginals++ + const output = `normalized/${directory}` + fs.mkdirSync(path.join(destination, output), { + recursive: true, + mode: 0o700, + }) + fs.copyFileSync( + path.join(vendored, 'Cargo.toml'), + path.join(destination, output, 'Cargo.toml'), + fs.constants.COPYFILE_EXCL + ) + fs.writeFileSync( + path.join(destination, output, 'Cargo.toml.orig'), + original, + { + flag: 'wx', + mode: 0o600, + } + ) + normalized.push({ + name: record.name, + version: record.version, + selector: record.selector, + cargo_toml: `${output}/Cargo.toml`, + cargo_toml_orig: `${output}/Cargo.toml.orig`, + }) +} + +// 7. The ordinary CI image with a host linker, identified only by its ID. +const dockerConfig = path.join(work, 'docker-config') +fs.mkdirSync(dockerConfig, { mode: 0o700 }) +const imageFile = path.join(work, 'image-id') +run('/usr/bin/docker', [ + '--config', + dockerConfig, + '--host', + 'unix:///var/run/docker.sock', + 'build', + '--pull', + '--iidfile', + imageFile, + '--file', + path.join(source, 'scripts/release/tests/cargo-fixture.Dockerfile'), + path.join(source, 'scripts/release/tests'), +]) +const image = fs.readFileSync(imageFile, 'utf8').trim() +if (!/^sha256:[a-f0-9]{64}$/.test(image)) + throw new Error('Expected actual local fixture image identity') + +fs.rmSync(work, { recursive: true, force: true }) +fs.writeFileSync( + path.join(destination, 'fixture.json'), + JSON.stringify({ + schema_version: 1, + kind: 'ordinary-ci-cargo-fixture', + acquisition: 'unauthenticated-ordinary-ci', + source_commit: commit, + cargo_lock_sha256: sha256(lockBytes), + image_id: image, + rustc_version: rustcVersion, + registry, + git, + normalized, + }) + '\n', + { flag: 'wx', mode: 0o600 } +) +process.stdout.write( + `Prepared ${registry.length} registry originals, ${git.length} Git object ` + + `stores and ${normalized.length} normalized Git manifests ` + + `(cargo vendor wrote Cargo.toml.orig for ${vendoredOriginals}) by ` + + 'unauthenticated ordinary CI acquisition; this is not release qualification\n' +) From ce50e5df8e8e3beca1dc983267919f3a3116ff3c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 10:13:42 +0530 Subject: [PATCH 070/153] Install worker-build with its published lockfile A fresh resolution of worker-build 0.8.5 now fails because every zip 2.x release its binary-install dependency accepts has been yanked. Installing with --locked uses the lockfile worker-build publishes, which still builds. --- crates/trusted-server-adapter-cloudflare/build.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-cloudflare/build.sh b/crates/trusted-server-adapter-cloudflare/build.sh index dcabdee8eb..b3a6dd3a30 100644 --- a/crates/trusted-server-adapter-cloudflare/build.sh +++ b/crates/trusted-server-adapter-cloudflare/build.sh @@ -25,6 +25,9 @@ cd "$SCRIPT_DIR" # — a flag the CLI matching the pinned wasm-bindgen (0.2.123, via worker 0.8.4) # does not understand. Tracking the locked worker version keeps the toolchain in # lockstep, and the pin advances automatically when the lockfile bumps. +# `--locked` installs with worker-build's own published Cargo.lock: a fresh +# resolution fails once its dependencies yank every compatible release (for +# example every `zip` 2.x needed by its `binary-install` dependency). WORKER_VERSION="$( grep -A1 '^name = "worker"$' "$SCRIPT_DIR/../../Cargo.lock" | sed -nE 's/^version = "(.*)"/\1/p' | head -1 @@ -33,4 +36,4 @@ if [ -z "$WORKER_VERSION" ]; then echo "error: could not determine the worker crate version from Cargo.lock" >&2 exit 1 fi -cargo install -q --force --version "=$WORKER_VERSION" worker-build && worker-build --release +cargo install -q --locked --force --version "=$WORKER_VERSION" worker-build && worker-build --release From efa34f749b4baf5913e86238b066883d7112d258 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 10:33:27 +0530 Subject: [PATCH 071/153] Record the first confined candidate Cargo build evidence Ordinary PR CI built trusted-server-fastly.wasm role A through the confined recipe and proved build-script, grandchild and procedural-macro network denial. Record the measurements and note Task 9 progress; the build is marked unqualified and no Task 9 exit is claimed. --- ...6-10-13-attested-release-implementation.md | 2 + .../2026-10-16-confined-cargo-candidate.md | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 scripts/release/qualification/2026-10-16-confined-cargo-candidate.md diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 01e6e1e258..382a84f7dc 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -324,6 +324,8 @@ Implementation sequencing: review the local validation framework and candidate b - [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture MUST NOT invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. - [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. +Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. Role B and A/B equality, the `ts` executables, remapping, Viceroy final-byte acceptance and qualified pins remain open. macOS boundaries stay rejected by the execute gate until Task 8 evidence. + ### Task 10: Record selected Cargo units and native materials **Create:** release tool `src/composition.rs`, private `composition::tests`, `crates/trusted-server-release/tests/fixtures/composition/`. diff --git a/scripts/release/qualification/2026-10-16-confined-cargo-candidate.md b/scripts/release/qualification/2026-10-16-confined-cargo-candidate.md new file mode 100644 index 0000000000..59c73bb74f --- /dev/null +++ b/scripts/release/qualification/2026-10-16-confined-cargo-candidate.md @@ -0,0 +1,102 @@ +# Task 9: first confined candidate Cargo build + +Status: on 2026-10-16 ordinary PR CI built `trusted-server-fastly.wasm` (role A) +through `PreparedRecipe::execute` inside the Linux Docker denial boundary. It +also proved that Cargo build scripts, a build-script grandchild and a +procedural macro inherit network denial. This is candidate execution evidence +from an ordinary CI runner and records `release_qualification: false`. It does +not qualify the toolchain, the image, the acquired crates, the vendored Git +manifests, bootstrap or any hosted release environment. Production tool +selections remain uninhabited, so production `execute` still returns +`Unqualified`. + +## Run + +The job +[`cargo test (confined release Cargo candidate, Linux)`](https://github.com/IABTechLab/trusted-server/actions/runs/38849941580/job/116808709738) +checked out the pull request merge commit +`309b9553657bd74c3e7dea03927284b68756c7e6` (branch head +`2d933b0e9debed3960f97f2cc3ebb5948da17471` merged into `main` at +`fa9837f2b95f817f58eadb16c929c38fcb581c9a`). Each step ran exactly one +selected ignored test, after a guard that the selection matched exactly one +test. + +The fixture acquired 706 registry originals (each checked against its locked +SHA-256), one Git object store for the locked `edgezero` revision, and eight +normalized Git manifests from `cargo vendor --offline`. Cargo 1.95.0 wrote no +`Cargo.toml.orig` for Git packages, so each original manifest was read from +the Git object database and paired with its normalized form. The acquisition +was unauthenticated ordinary CI. The tool root was a private single-link copy +of the host Rust 1.95.0 sysroot with `wasm32-wasip1`; the image was Ubuntu +24.04 with `gcc` and `libc6-dev`, identified only by its image ID. + +## Denial inheritance + +`recipe::tests::full::cargo_children_inherit_denial` passed in 25.17 seconds. +A build script, a build-script grandchild and a procedural macro each +attempted a loopback connection inside the boundary. All three reported errno +1 (`EPERM`), matching the boundary's own denial proof, not `ECONNREFUSED`. +The control crate built a real `denial-control.wasm` (2,013,985 bytes, SHA-256 +`60bd5bc1f0388a50a75dd3a0e87400282fb5c97499fc414a1e8dadd2343d8534`) through +the same `execute` path. + +## JS handoff into the Fastly build + +`recipe::tests::full::real_js_handoff_to_fastly_cargo_build` passed in 871.02 +seconds. It first repeated the full two-fresh-installation JS acceptance: both +sides passed 1,275 tests including type checks, all 13 final modules were +byte-identical and six private roots were removed. It then bound that +in-memory JS completion to a third fresh source copy, prepared the vendor +directory, ran `cargo build --release --frozen +--message-format=json-render-diagnostics` with umask 022 and four jobs, and +completed only after the JS handoff, tools and materials rehashed unchanged. +No Cargo registry or Git cache appeared in the private `CARGO_HOME`. The +confined build took 130 seconds. + +```json +{ + "artifact": { + "name": "trusted-server-fastly.wasm", + "sha256": "6a19dece46c11b40f352a0ac23a124a4f8d6af5742df56845d37a99dc2b713c5", + "size": 66885152 + }, + "asset": "trusted-server-fastly.wasm", + "boundary_fingerprint": "99af57442bb03eeaaa50c320d83dbe5bf8938de97d5eca1c9f62668c17170582", + "cargo_lock_sha256": "9d1ce5338f2511e9ae289ceb4e1679a2330cda8c43f205d80b6d01672589e4dc", + "derived_manifest_digests": 8, + "elapsed_seconds": 130, + "js_inventory_sha256": "f3645c7699165920ccc64bfc0f91c43479b01b771cfacf9b9bd4721bcaedf33e", + "js_settings_sha256": "5d341e8a8b58a638ddc71b663daa0dbada5d1970175f21391b7750d36ffdef02", + "js_tools_sha256": "ba26c9198ef338cf0afc94762d91cfaa3cdbd51987e65dfe0f8905980963da2d", + "kind": "ordinary-ci-confined-cargo-build", + "materials_digest": "da91ba65a309b9290460a2e3fb5fdf2dd6fee59f39225777264444932bf5ab17", + "plan_digest": "a743a63a68cd1ace19f434bae98cf4fc1fafcfe5913e141c5196e7302255afa8", + "release_qualification": false, + "role": "A", + "rust_tools_digest": "0a741d4f4c3b4f9087da3891120b3557925573867341cdf826cb0ce56f883879", + "schema_version": 1, + "source_commit": "309b9553657bd74c3e7dea03927284b68756c7e6", + "stderr_sha256": "ba7154da1ed3e07bfa14e0e6ab0c894684fbe7b4eeccc903262f7e1db0cfca1c", + "stdout_sha256": "9fb08fe022a5fccef541d756acc85df5a6480c9987b29275a7cdc86166953efc" +} +``` + +Eight derived manifest digests mark this build unqualified: the normalized +`edgezero` manifests came from `cargo vendor`, not from a reviewed producer +normalization. + +## Archive policy change + +Real registry crates required two narrowly reviewed compatibility encodings, +for registry originals only: a GNU `././@LongLink` record (regular mode, +exact join with the next header's 100-byte name, bounded, never consecutive, +dangling or mixed with PAX) and tar modes that carry the regular or directory +type bits. npm originals keep the stricter policy. Git packages are vendored +under their bare name so that intra-workspace relative paths resolve. + +## Not covered + +Role B and the A/B comparison, the three `ts` executables, path remapping, +final-byte Viceroy acceptance, producer-owned manifest normalization, +authenticated acquisition, qualified tool and image pins, and hosted +qualification remain open under Tasks 8 and 9. From d472aaa50fb3d532ca997c6822a8c0b8907386de Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 10:58:14 +0530 Subject: [PATCH 072/153] Compare Fastly A and B builds byte for byte with remapped paths Remap the checkout, vendor tree, Cargo home, Rust tool root and target directory to fixed prefixes through a quoted target rustflags --config entry, which Cargo appends to tracked target rustflags. Reject tracked build.rustflags and an extensionless .cargo/config that would override the reviewed configuration, and require the remapped roots to be disjoint because rustc applies the last matching prefix. Add check_fastly_equality: it requires a role A then role B Fastly pair with identical source, lock, materials, derived manifests, JS and Rust tool digests and an honestly varied ambient pair, rehashes both artifacts, and requires complete byte equality, reporting a bounded first-difference diagnostic otherwise. The ordinary-CI candidate job now builds role A and the test-only role B from one JS handoff in separate fresh roots and boundaries, scans the final bytes for leaked host or container paths and for the remapped prefixes, compares them, and prints an equality measurement with release_qualification false. --- .github/workflows/test.yml | 15 +- crates/trusted-server-release/src/recipe.rs | 15 +- .../src/recipe/ambient.rs | 5 + .../src/recipe/compare.rs | 211 ++++++++++++++ .../src/recipe/completed.rs | 5 + .../trusted-server-release/src/recipe/plan.rs | 30 +- .../src/recipe/remap.rs | 241 ++++++++++++++++ .../src/recipe/tests.rs | 2 + .../src/recipe/tests/candidate.rs | 64 ++++ .../src/recipe/tests/compare.rs | 257 +++++++++++++++++ .../src/recipe/tests/complete.rs | 15 +- .../src/recipe/tests/full.rs | 198 ++++++++++--- .../src/recipe/tests/plan.rs | 44 ++- .../src/recipe/tests/remap.rs | 273 ++++++++++++++++++ ...6-10-13-attested-release-implementation.md | 2 +- 15 files changed, 1306 insertions(+), 71 deletions(-) create mode 100644 crates/trusted-server-release/src/recipe/compare.rs create mode 100644 crates/trusted-server-release/src/recipe/remap.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/compare.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/remap.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a912e13c8..ee082b2b03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -395,7 +395,10 @@ jobs: test-release-cargo-candidate: name: cargo test (confined release Cargo candidate, Linux) runs-on: ubuntu-24.04 - timeout-minutes: 45 + # Fixture preparation, two fresh JS builds (~12 min), then two confined + # Fastly builds each with its own vendoring (~3-4 min each) and the + # final-byte comparison: 45 minutes left too little headroom. + timeout-minutes: 60 env: # Optimize repeated whole-input hashes with test safety checks retained. CARGO_PROFILE_TEST_OPT_LEVEL: '2' @@ -464,17 +467,19 @@ jobs: cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-denial-measurement.json" - - name: Build the Fastly Wasm from a real JS handoff through the confined recipe (Linux) + # Role A and role B build from the same JS handoff in separate fresh + # roots and boundaries; the final Fastly files must be byte-identical. + - name: Build Fastly roles A and B from one JS handoff and compare final bytes (Linux) shell: bash run: | set -euo pipefail - TEST_NAME=recipe::tests::full::real_js_handoff_to_fastly_cargo_build + TEST_NAME=recipe::tests::full::fastly_role_a_and_b_builds_are_byte_identical TEST_LIST="$(mktemp)" trap 'rm -f "$TEST_LIST"' EXIT cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST" - awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one confined Cargo build fixture"; exit 1 } }' "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Fastly A/B equality fixture"; exit 1 } }' "$TEST_LIST" cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture - cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-execution-measurement.json" + cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-ab-equality-measurement.json" test-typescript: name: vitest diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index b8695c7fa9..b65a2e1d9f 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -39,11 +39,13 @@ use fresh::{ mod ambient; mod artifact; mod cargo_output; +mod compare; mod completed; mod execute; mod fresh; mod paths; mod plan; +mod remap; mod sources; mod tools; @@ -375,7 +377,10 @@ impl PreparedRecipe { /// /// Returns [`ReleaseError::Recipe`] when the fresh checkout is not the /// approved commit or not the ambient checkout root, the prepared - /// materials were derived from another `Cargo.lock`, or as [`Self::bind`]. + /// materials were derived from another `Cargo.lock`, the tracked Cargo + /// configuration sets `build.rustflags`, which the path remap would + /// supersede, a legacy extensionless `.cargo/config` is tracked or on + /// disk at the checkout root, or as [`Self::bind`]. #[allow( clippy::too_many_arguments, reason = "each argument is a distinct authority" @@ -406,6 +411,14 @@ impl PreparedRecipe { "prepared materials were derived from another Cargo.lock", )); } + let config = source + .read_tracked(remap::CARGO_CONFIG, DocumentRole::Metadata) + .map_err(|error| error.change_context(ReleaseError::Recipe))?; + remap::check_cargo_config(&config)?; + remap::check_no_extensionless_config( + fresh.root(), + source.tracked_sha256(remap::LEGACY_CARGO_CONFIG).is_some(), + )?; let child = child_environment(audited, source, &child_request(&request))?; Self::bind(BindInputs { commit: source.commit(), diff --git a/crates/trusted-server-release/src/recipe/ambient.rs b/crates/trusted-server-release/src/recipe/ambient.rs index 02ddee7d89..95a430adc5 100644 --- a/crates/trusted-server-release/src/recipe/ambient.rs +++ b/crates/trusted-server-release/src/recipe/ambient.rs @@ -260,6 +260,11 @@ impl CheckedAmbient { pub(crate) fn checkout_root(&self) -> &Path { &self.observed.checkout_root } + + /// Returns the observed conditions that matched the profile. + pub(crate) fn observed(&self) -> &ObservedAmbient { + &self.observed + } } /// Requires an observed value to equal the profile's value. diff --git a/crates/trusted-server-release/src/recipe/compare.rs b/crates/trusted-server-release/src/recipe/compare.rs new file mode 100644 index 0000000000..355b365658 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/compare.rs @@ -0,0 +1,211 @@ +//! Complete final-byte comparison of the role A and role B Fastly builds. +//! +//! The Fastly executable has no normalized or excluded region: the two final +//! files must be identical byte for byte. Before comparing bytes, the pair +//! must be one A and one B build of the same asset from the same source, +//! `Cargo.lock`, vendored materials, JS handoff and Rust tools, and their +//! ambient records must vary exactly what the standard claim states. + +use std::io::Read as _; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; +use wasmparser::{Parser, Payload}; + +use super::ambient::{AmbientClaim, AmbientRecord}; +use super::artifact::EXECUTABLE_LIMIT; +use super::{AssetId, RecipeCompleted, Role, recipe_error}; +use crate::error::ReleaseError; + +/// The only asset whose A/B outputs must be byte-identical. +pub(crate) const FASTLY_ASSET: &str = "trusted-server-fastly.wasm"; +/// Bytes shown from each side at the first difference. +const WINDOW: usize = 16; + +/// Proof that the A and B Fastly builds produced identical final bytes. +/// +/// It has private fields and only [`check_fastly_equality`] constructs it. +#[derive(Debug)] +pub(crate) struct FastlyEqualityChecked { + asset: AssetId, + sha256: String, + size: u64, + ambient: AmbientRecord, +} + +impl FastlyEqualityChecked { + /// The compared asset. + pub(crate) fn asset(&self) -> AssetId { + self.asset + } + + /// SHA-256 of the identical final bytes. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Size of the identical final bytes. + pub(crate) fn size(&self) -> u64 { + self.size + } + + /// The verified ambient pairing of the two builds. + pub(crate) fn ambient(&self) -> &AmbientRecord { + &self.ambient + } +} + +/// Requires `first` and `second` to be equal, naming `label` otherwise. +fn same_input( + label: &str, + first: &T, + second: &T, +) -> Result<(), Report> { + if first == second { + return Ok(()); + } + Err(recipe_error(format!( + "A and B Fastly builds used different {label}" + ))) +} + +/// Requires one A and one B build of the Fastly asset over identical inputs. +fn check_pair(a: &RecipeCompleted, b: &RecipeCompleted) -> Result<(), Report> { + if a.role() != Role::A || b.role() != Role::B { + return Err(recipe_error( + "Fastly equality needs a role A then a role B build", + )); + } + let fastly = AssetId::parse(FASTLY_ASSET)?; + if a.asset() != fastly || b.asset() != fastly { + return Err(recipe_error(format!( + "Fastly equality compares only {FASTLY_ASSET}" + ))); + } + same_input("source commits", a.commit(), b.commit())?; + same_input( + "Cargo.lock files", + a.cargo_lock_sha256(), + b.cargo_lock_sha256(), + )?; + same_input( + "vendored materials", + a.materials_digest(), + b.materials_digest(), + )?; + same_input( + "derived Git manifests", + a.derived_manifest_digests(), + b.derived_manifest_digests(), + )?; + same_input("JS handoffs", &a.js_digests(), &b.js_digests())?; + same_input("Rust tools", a.rust_tools_digest(), b.rust_tools_digest()) +} + +/// Reads the retained artifact and requires its recorded size and digest. +fn read_artifact(completed: &RecipeCompleted) -> Result, Report> { + completed.rehash_for_handoff()?; + let artifact = completed.artifact(); + if artifact.size() > EXECUTABLE_LIMIT { + return Err(recipe_error("artifact exceeds the executable limit")); + } + let mut bytes = Vec::new(); + std::fs::File::open(artifact.path()) + .and_then(|file| { + file.take(EXECUTABLE_LIMIT.saturating_add(1)) + .read_to_end(&mut bytes) + }) + .map_err(|error| recipe_error(format!("cannot read the final artifact: {error}")))?; + if bytes.len() as u64 != artifact.size() + || hex::encode(Sha256::digest(&bytes)) != artifact.sha256() + { + return Err(recipe_error("final artifact changed after its rehash")); + } + Ok(bytes) +} + +/// Compares the complete final Fastly files of a role A and a role B build. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the pair is not role A then role B +/// of the Fastly asset, the builds used different source, lock, materials, +/// derived manifests, JS handoff or tools, the ambient records do not vary +/// exactly what [`AmbientClaim::standard`] states, an artifact no longer +/// rehashes, or the final bytes differ; a difference reports both sizes and +/// digests, the first differing offset, the bytes there and their section. +pub(crate) fn check_fastly_equality( + a: &RecipeCompleted, + b: &RecipeCompleted, +) -> Result> { + check_pair(a, b)?; + let ambient = AmbientRecord::pair(a.ambient(), b.ambient(), &AmbientClaim::standard())?; + let first = read_artifact(a)?; + let second = read_artifact(b)?; + if first != second { + return Err(recipe_error(describe_mismatch(&first, &second))); + } + Ok(FastlyEqualityChecked { + asset: a.asset(), + sha256: a.artifact().sha256().to_owned(), + size: a.artifact().size(), + ambient, + }) +} + +/// Returns the first offset where `first` and `second` differ, if any. +/// +/// When one is a strict prefix of the other, the offset is the shorter length. +pub(super) fn first_difference(first: &[u8], second: &[u8]) -> Option { + first + .iter() + .zip(second) + .position(|(left, right)| left != right) + .or_else(|| (first.len() != second.len()).then(|| first.len().min(second.len()))) +} + +/// Names the Wasm section whose payload holds `offset`, if the module parses. +fn section_at(bytes: &[u8], offset: usize) -> String { + for payload in Parser::new(0).parse_all(bytes) { + let Ok(payload) = payload else { + return "unparsed module".to_owned(); + }; + let Some((id, range)) = payload.as_section() else { + continue; + }; + if range.contains(&offset) { + return match payload { + Payload::CustomSection(reader) => format!("custom section {:?}", reader.name()), + _ => format!("section id {id}"), + }; + } + } + "no section payload".to_owned() +} + +/// Describes a final-byte mismatch with bounded diagnostics. +pub(super) fn describe_mismatch(first: &[u8], second: &[u8]) -> String { + let offset = first_difference(first, second).unwrap_or(first.len()); + let window = |bytes: &[u8]| { + let end = bytes.len().min(offset.saturating_add(WINDOW)); + hex::encode(bytes.get(offset..end).unwrap_or_default()) + }; + let differing = first + .iter() + .zip(second) + .filter(|(left, right)| left != right) + .count() + + first.len().abs_diff(second.len()); + format!( + "final Fastly bytes differ: A size {} sha256 {}, B size {} sha256 {}; \ + first differing offset {offset} (A {}: {}, B {}: {}); {differing} differing bytes", + first.len(), + hex::encode(Sha256::digest(first)), + second.len(), + hex::encode(Sha256::digest(second)), + section_at(first, offset), + window(first), + section_at(second, offset), + window(second), + ) +} diff --git a/crates/trusted-server-release/src/recipe/completed.rs b/crates/trusted-server-release/src/recipe/completed.rs index 970eb3ea5b..4992097633 100644 --- a/crates/trusted-server-release/src/recipe/completed.rs +++ b/crates/trusted-server-release/src/recipe/completed.rs @@ -232,4 +232,9 @@ impl RecipeCompleted { pub(crate) fn retained_stdout_path(&self) -> PathBuf { self.retained.path().join(STDOUT_FILE) } + + /// Bound facts, for tests that forge one mismatched input. + pub(super) fn facts_mut_for_tests(&mut self) -> &mut SuccessFacts { + &mut self.facts + } } diff --git a/crates/trusted-server-release/src/recipe/plan.rs b/crates/trusted-server-release/src/recipe/plan.rs index 6c7db3c9df..100356c98f 100644 --- a/crates/trusted-server-release/src/recipe/plan.rs +++ b/crates/trusted-server-release/src/recipe/plan.rs @@ -16,6 +16,7 @@ use sha2::{Digest as _, Sha256}; use super::ambient::CheckedAmbient; use super::paths::{ExecutionPaths, execution_text}; +use super::remap::{RemapSources, remap_arguments}; use super::sources::source_replacement_arguments; use super::tools::SelectedRustTools; use super::{AssetId, RecipeRequest, recipe_error}; @@ -98,8 +99,9 @@ impl CargoPlan { /// Returns the fixed argument vector of the release Cargo build. /// -/// The only `--config` entries are the exact source replacements. -pub(super) fn cargo_arguments(asset: AssetId, jobs: u32, replacement: &[String]) -> Vec { +/// The only `--config` entries are `configs`: the exact source replacements +/// followed by the target path remap. +pub(super) fn cargo_arguments(asset: AssetId, jobs: u32, configs: &[String]) -> Vec { let artifact = asset.artifact(); [ "build", @@ -114,7 +116,7 @@ pub(super) fn cargo_arguments(asset: AssetId, jobs: u32, replacement: &[String]) ] .into_iter() .map(str::to_owned) - .chain(replacement.iter().cloned()) + .chain(configs.iter().cloned()) .chain( ["--message-format=json-render-diagnostics", "--jobs"] .into_iter() @@ -159,12 +161,16 @@ pub(super) struct PlanInputs<'a> { /// Derives the exact Cargo invocation from the bound inputs. /// +/// The arguments carry the source replacement and the deterministic target +/// path remap of [`super::remap`], so both are bound by [`CargoPlan::digest`]. +/// /// # Errors /// /// Returns [`ReleaseError::Recipe`] when the child environment's `HOME` or /// `CARGO_HOME` is not the request's private state, or any path variable, -/// the tool directory or the vendor directory lies outside the boundary roots -/// or is not plain text. +/// the tool directory, the vendor directory, the checkout root or the Rust +/// tool root lies outside the boundary roots or is not plain text, or two +/// remapped roots overlap in the execution namespace. pub(super) fn plan_for(inputs: &PlanInputs<'_>) -> Result> { let PlanInputs { request, @@ -197,11 +203,21 @@ pub(super) fn plan_for(inputs: &PlanInputs<'_>) -> Result.rustflags` and `target..rustflags` joined, and only +//! then `build.rustflags`. A `--config` value merges with the configuration +//! files and arrays concatenate with the command line last. The remap +//! therefore travels as one `--config target."".rustflags=[...]` +//! entry: reviewed target flags in `.cargo/config.toml` stay in effect before +//! it. Either environment variable would replace every configured flag, so +//! the audited environment rejects both and the plan never sets them. Any +//! target rustflags supersede `build.rustflags`, so the reviewed +//! configuration must not set it ([`check_cargo_config`]), and the legacy +//! extensionless `.cargo/config` layer is refused outright +//! ([`check_no_extensionless_config`]). +//! +//! # Ordering +//! +//! When several `--remap-path-prefix` flags match one path, rustc applies the +//! last matching one. The remapped roots are required to be pairwise +//! disjoint, so at most one ever matches and the emitted order cannot change +//! the result. +//! +//! # Limitation +//! +//! Remapping rewrites paths rustc knows as source locations. A string built +//! by `env!("CARGO_MANIFEST_DIR")`, `env!("OUT_DIR")` or `concat!` of them is +//! an ordinary literal and keeps the execution path. The final bytes are +//! therefore scanned ([`scan_remapped_bytes`]) so such a literal fails the +//! build evidence instead of passing silently. +//! +//! With an explicit `--target`, rustflags reach only target units, never +//! build scripts or procedural macros: exactly the code in the artifact. + +use std::io; +use std::path::Path; + +use error_stack::Report; + +use super::fresh::disjoint; +use super::recipe_error; +use crate::error::ReleaseError; + +/// Tracked Cargo configuration Cargo reads from the checkout root. +pub(super) const CARGO_CONFIG: &str = ".cargo/config.toml"; +/// Legacy extensionless configuration Cargo also reads; always refused. +pub(super) const LEGACY_CARGO_CONFIG: &str = ".cargo/config"; +/// Fixed prefix of the source checkout. +pub(super) const REMAPPED_CHECKOUT: &str = "/trusted-server"; +/// Fixed prefix of the vendored dependency directory. +pub(super) const REMAPPED_VENDOR: &str = "/cargo-vendor"; +/// Fixed prefix of the private Cargo home. +pub(super) const REMAPPED_CARGO_HOME: &str = "/cargo-home"; +/// Fixed prefix of the Rust tool root, which is the compiler sysroot. +pub(super) const REMAPPED_TOOLS: &str = "/rust-tools"; +/// Fixed prefix of the Cargo target directory, which holds every `OUT_DIR`. +pub(super) const REMAPPED_TARGET: &str = "/cargo-target"; +/// Every fixed prefix, in emitted order. +pub(super) const REMAPPED_PREFIXES: [&str; 5] = [ + REMAPPED_CHECKOUT, + REMAPPED_VENDOR, + REMAPPED_CARGO_HOME, + REMAPPED_TOOLS, + REMAPPED_TARGET, +]; +/// Execution-namespace prefix of every container mount; never in final bytes. +const EXECUTION_MOUNT_PREFIX: &str = "/boundary/"; +/// Most forbidden prefixes one scan accepts. +const MAX_FORBIDDEN: usize = 64; +/// Longest forbidden prefix one scan accepts. +const MAX_PREFIX_BYTES: usize = 4096; + +/// Execution paths of every root a compiled path can come from. +/// +/// Each must already be plain absolute text without quotes, backslashes, +/// `:` or control characters, as [`super::paths::execution_text`] returns. +pub(super) struct RemapSources<'a> { + pub(super) checkout: &'a str, + pub(super) vendor: &'a str, + pub(super) cargo_home: &'a str, + pub(super) tools: &'a str, + pub(super) target: &'a str, +} + +/// Returns the `--config` pair remapping every source root for `triple`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when two remapped roots are equal or +/// nested, since rustc's last-match-wins rule would then make the order of +/// the flags decide the compiled path. +pub(super) fn remap_arguments( + triple: &str, + sources: &RemapSources<'_>, +) -> Result, Report> { + let pairs = [ + ("remapped checkout", sources.checkout), + ("remapped vendor directory", sources.vendor), + ("remapped Cargo home", sources.cargo_home), + ("remapped Rust tools", sources.tools), + ("remapped target directory", sources.target), + ]; + let labelled: Vec<(&str, &Path)> = pairs + .iter() + .map(|(label, from)| (*label, Path::new(*from))) + .collect(); + disjoint(&labelled)?; + let flags: Vec = pairs + .iter() + .zip(REMAPPED_PREFIXES) + .map(|((_, from), to)| format!("\"--remap-path-prefix={from}={to}\"")) + .collect(); + Ok(vec![ + "--config".to_owned(), + format!("target.\"{triple}\".rustflags=[{}]", flags.join(",")), + ]) +} + +/// Requires the reviewed Cargo configuration not to set `build.rustflags`. +/// +/// The remap is a target rustflags entry, and Cargo ignores `build.rustflags` +/// whenever any target rustflags exist, so such a setting would be silently +/// dropped instead of reviewed. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the bytes are not UTF-8 TOML or set +/// `build.rustflags`. +pub(super) fn check_cargo_config(bytes: &[u8]) -> Result<(), Report> { + let text = std::str::from_utf8(bytes) + .map_err(|error| recipe_error(format!("{CARGO_CONFIG} is not UTF-8: {error}")))?; + let table: toml::Table = toml::from_str(text) + .map_err(|error| recipe_error(format!("{CARGO_CONFIG} is not TOML: {error}")))?; + let sets_build_rustflags = table + .get("build") + .and_then(toml::Value::as_table) + .is_some_and(|build| build.contains_key("rustflags")); + if sets_build_rustflags { + return Err(recipe_error(format!( + "{CARGO_CONFIG} sets build.rustflags, which the target path remap would supersede" + ))); + } + Ok(()) +} + +/// Refuses the legacy extensionless `.cargo/config` at the checkout root. +/// +/// Cargo reads it beside `.cargo/config.toml`, so an unreviewed layer could +/// carry rustflags the remap check never sees. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] when the approved source tracks it, it +/// exists on disk in any form, or its absence cannot be established. +pub(super) fn check_no_extensionless_config( + checkout: &Path, + tracked: bool, +) -> Result<(), Report> { + if tracked { + return Err(recipe_error(format!( + "the approved source tracks {LEGACY_CARGO_CONFIG}, which is refused" + ))); + } + match std::fs::symlink_metadata(checkout.join(LEGACY_CARGO_CONFIG)) { + Ok(_) => Err(recipe_error(format!( + "the checkout holds {LEGACY_CARGO_CONFIG}, which is refused" + ))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(recipe_error(format!( + "{LEGACY_CARGO_CONFIG} cannot be inspected: {error}" + ))), + } +} + +/// Fixed prefixes found in final bytes that hold no forbidden path. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RemapScan { + found: Vec<&'static str>, +} + +impl RemapScan { + /// The fixed prefixes, each followed by `/`, found in the bytes. + pub(crate) fn found(&self) -> &[&'static str] { + &self.found + } +} + +/// Returns whether `needle` occurs in `haystack`. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// Proves remapping on final bytes by substring search. +/// +/// The bytes must contain none of `forbidden` (host roots of the build) nor +/// the container mount prefix `/boundary/`, and at least one fixed prefix +/// followed by `/`. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Recipe`] for more than 64 forbidden prefixes, an +/// empty or longer than 4096-byte prefix, any forbidden prefix found, or no +/// fixed prefix found. +pub(crate) fn scan_remapped_bytes( + bytes: &[u8], + forbidden: &[&str], +) -> Result> { + if forbidden.len() > MAX_FORBIDDEN { + return Err(recipe_error("too many forbidden prefixes for one scan")); + } + for prefix in forbidden.iter().copied().chain([EXECUTION_MOUNT_PREFIX]) { + if prefix.is_empty() || prefix.len() > MAX_PREFIX_BYTES { + return Err(recipe_error("forbidden prefix is empty or too long")); + } + if contains(bytes, prefix.as_bytes()) { + return Err(recipe_error(format!( + "final bytes still hold the unremapped path prefix {prefix:?}" + ))); + } + } + let found: Vec<&'static str> = REMAPPED_PREFIXES + .into_iter() + .filter(|prefix| contains(bytes, format!("{prefix}/").as_bytes())) + .collect(); + if found.is_empty() { + return Err(recipe_error( + "final bytes hold no remapped path prefix, so remapping is unproven", + )); + } + Ok(RemapScan { found }) +} diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs index 8633920c8d..6755eb9772 100644 --- a/crates/trusted-server-release/src/recipe/tests.rs +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -326,11 +326,13 @@ fn checked_b() -> CheckedAmbient { mod ambient; mod bind; mod candidate; +mod compare; mod complete; mod fixture; #[cfg(target_os = "linux")] mod full; mod plan; +mod remap; mod sources; mod tools; diff --git a/crates/trusted-server-release/src/recipe/tests/candidate.rs b/crates/trusted-server-release/src/recipe/tests/candidate.rs index b8d654e362..45a47ec310 100644 --- a/crates/trusted-server-release/src/recipe/tests/candidate.rs +++ b/crates/trusted-server-release/src/recipe/tests/candidate.rs @@ -11,6 +11,8 @@ use std::time::Duration; use serde_json::{Map, Value, json}; +use super::super::ambient::Dimension; +use super::super::compare::FastlyEqualityChecked; use super::*; use crate::boundary::DockerSettings; use crate::process::ProcessLimits; @@ -296,6 +298,68 @@ pub(super) fn cargo_measurement(completed: &RecipeCompleted) -> Value { }) } +/// The ambient conditions one build ran under, as the measurement states them. +/// +/// Umask and jobs are the profile values the plan enforced on the child, and +/// the host locale and timezone are profile labels: the child always ran +/// with the normalized `C` locale and `UTC` timezone. +fn ambient_measurement(completed: &RecipeCompleted) -> Value { + let ambient = completed.ambient(); + let observed = ambient.observed(); + json!({ + "role": role_text(ambient.role()), + "umask": format!("{:03o}", observed.umask), + "jobs": observed.jobs, + "checkout_root": observed.checkout_root.to_string_lossy(), + "ambient_started_at_seconds": observed.started_at_seconds, + "run_started_at_seconds": completed.started_at_seconds(), + "execution_uid": observed.execution_uid, + "host_locale_label": observed.host_locale, + "host_timezone_label": observed.host_timezone, + "child_locale": observed.child_locale, + "child_timezone": observed.child_timezone, + "observation": "profile-enforced-not-independently-observed", + }) +} + +/// The ordinary CI measurement of the Fastly A/B final-byte comparison. +pub(super) fn equality_measurement( + a: &RecipeCompleted, + b: &RecipeCompleted, + checked: &FastlyEqualityChecked, +) -> Value { + let claim = checked.ambient().claim(); + let names = |set: &BTreeSet| -> Vec { + set.iter().map(ToString::to_string).collect() + }; + let build = |completed: &RecipeCompleted| { + let mut value = cargo_measurement(completed); + value["ambient"] = ambient_measurement(completed); + value + }; + json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-fastly-ab-equality", + "asset": checked.asset().artifact().name, + "source_commit": a.commit().as_str(), + "equality": true, + "artifact": { + "sha256": checked.sha256(), + "size": checked.size(), + }, + "builds": { + "A": build(a), + "B": build(b), + }, + "ambient_claim": { + "varied": names(&claim.varied), + "normalized": names(&claim.normalized), + "unsupported": names(&claim.unsupported), + }, + "release_qualification": false, + }) +} + /// The ordinary CI measurement of the Cargo denial fixture. pub(super) fn denial_measurement( proof_errno: i32, diff --git a/crates/trusted-server-release/src/recipe/tests/compare.rs b/crates/trusted-server-release/src/recipe/tests/compare.rs new file mode 100644 index 0000000000..d38e489d14 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/compare.rs @@ -0,0 +1,257 @@ +use sha2::{Digest as _, Sha256}; + +use super::super::ambient::AmbientClaim; +use super::super::compare::{ + FASTLY_ASSET, check_fastly_equality, describe_mismatch, first_difference, +}; +use super::super::completed::SuccessFacts; +use super::complete::{Built, built, built_with, wasm_with}; +use super::fixture::OTHER_COMMIT; +use super::*; + +/// A minimal Wasm module that keeps the debug sections a build must retain. +fn module(info: &[u8]) -> Vec { + wasm_with(&[(".debug_info", info), (".debug_line", b"line")]) +} + +/// A completed fictional build with the fixture roots its output lives in. +struct Completed { + _built: Built, + completed: RecipeCompleted, +} + +impl std::ops::Deref for Completed { + type Target = RecipeCompleted; + + fn deref(&self) -> &RecipeCompleted { + &self.completed + } +} + +/// Completes a fictional build of `asset` for `role` with exactly `bytes`. +fn completed(asset: &'static str, role: Role, bytes: &[u8], derived: bool) -> Completed { + finish(built_with(asset, role, bytes, derived)) +} + +fn finish(mut built: Built) -> Completed { + let stdout = built.stdout(|_| {}); + let completed = built.finish(stdout).expect("should complete the build"); + Completed { + _built: built, + completed, + } +} + +fn rejection(a: &RecipeCompleted, b: &RecipeCompleted) -> String { + match check_fastly_equality(a, b) { + Ok(_) => panic!("should reject the pair"), + Err(error) => format!("{error:?}"), + } +} + +#[test] +fn identical_a_and_b_fastly_bytes_are_accepted_with_the_standard_claim() { + let bytes = module(b"info"); + let a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let b = completed(FASTLY_ASSET, Role::B, &bytes, false); + let checked = check_fastly_equality(&a, &b).expect("should accept identical final bytes"); + assert_eq!( + checked.asset().artifact().name, + FASTLY_ASSET, + "should name Fastly" + ); + assert_eq!( + checked.sha256(), + hex::encode(Sha256::digest(&bytes)), + "should report the shared digest" + ); + assert_eq!(checked.size(), bytes.len() as u64, "should report the size"); + assert_eq!( + checked.ambient().claim(), + &AmbientClaim::standard(), + "should bind the honest ambient placement" + ); + + let measurement = super::candidate::equality_measurement(&a, &b, &checked); + assert_eq!( + ( + &measurement["kind"], + &measurement["equality"], + &measurement["release_qualification"], + ), + ( + &serde_json::json!("ordinary-ci-fastly-ab-equality"), + &serde_json::json!(true), + &serde_json::json!(false), + ), + "should report candidate equality without release qualification" + ); + for (role, umask, jobs) in [("A", "022", 4), ("B", "027", 2)] { + let build = &measurement["builds"][role]; + assert_eq!( + build["artifact"]["sha256"], + checked.sha256(), + "should report the {role} digest" + ); + assert_eq!( + (&build["ambient"]["umask"], &build["ambient"]["jobs"]), + (&serde_json::json!(umask), &serde_json::json!(jobs)), + "should report the {role} ambient record" + ); + assert_eq!( + ( + &build["ambient"]["child_locale"], + &build["ambient"]["child_timezone"] + ), + (&serde_json::json!("C"), &serde_json::json!("UTC")), + "should report the normalized {role} child locale and timezone" + ); + } + assert_eq!( + measurement["ambient_claim"]["unsupported"], + serde_json::json!(["ExecutionUser"]), + "should not claim the execution user varied" + ); +} + +#[test] +fn one_byte_difference_reports_sizes_digests_and_first_offset() { + let a = completed(FASTLY_ASSET, Role::A, &module(b"info"), false); + let b = completed(FASTLY_ASSET, Role::B, &module(b"inFo"), false); + let message = rejection(&a, &b); + let offset = + first_difference(&module(b"info"), &module(b"inFo")).expect("should find the changed byte"); + for part in [ + "final Fastly bytes differ".to_owned(), + format!("first differing offset {offset}"), + format!("A size {}", module(b"info").len()), + a.artifact().sha256().to_owned(), + b.artifact().sha256().to_owned(), + "custom section \".debug_info\"".to_owned(), + "1 differing bytes".to_owned(), + ] { + assert!(message.contains(&part), "should report {part}: {message}"); + } +} + +#[test] +fn first_difference_covers_prefixes_and_equal_inputs() { + assert_eq!(first_difference(b"abc", b"abc"), None, "should find none"); + assert_eq!( + first_difference(b"abc", b"abd"), + Some(2), + "should find byte 2" + ); + assert_eq!( + first_difference(b"ab", b"abc"), + Some(2), + "should report the shorter length for a strict prefix" + ); + let message = describe_mismatch(b"ab", b"abc"); + assert!( + message.contains("A size 2") && message.contains("B size 3"), + "should report both sizes: {message}" + ); + assert!( + message.contains("1 differing bytes") && message.contains("unparsed module"), + "should count the extra byte and survive a non-module: {message}" + ); + let long = vec![0_u8; 4096]; + let mut changed = long.clone(); + changed[100] = 1; + assert!( + describe_mismatch(&long, &changed).len() < 600, + "should bound the diagnostic window" + ); +} + +#[test] +fn same_role_or_swapped_pairs_are_rejected() { + let bytes = module(b"info"); + let a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let other_a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let b = completed(FASTLY_ASSET, Role::B, &bytes, false); + for (first, second) in [(&a, &other_a), (&b, &a)] { + assert!( + rejection(first, second).contains("role A then a role B"), + "should require exactly role A then role B" + ); + } +} + +#[test] +fn mismatched_inputs_or_another_asset_are_rejected() { + let bytes = module(b"info"); + let a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let derived = completed(FASTLY_ASSET, Role::B, &bytes, true); + assert!( + rejection(&a, &derived).contains("derived Git manifests"), + "should reject builds over different derived manifests" + ); + let native = finish(built( + "ts-aarch64-apple-darwin", + Role::A, + b"fictional executable", + )); + let b = completed(FASTLY_ASSET, Role::B, &bytes, false); + assert!( + rejection(&native, &b).contains("compares only"), + "should compare only the Fastly asset" + ); +} + +/// One forged input of role B and the label its rejection must name. +type Forge = (&'static str, fn(&mut SuccessFacts)); + +#[test] +fn each_mismatched_input_is_rejected_by_name() { + let bytes = module(b"info"); + let a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let cases: [Forge; 8] = [ + ("source commits", |facts| { + facts.commit = CommitId::parse(OTHER_COMMIT).expect("should parse commit"); + }), + ("Cargo.lock files", |facts| { + facts.cargo_lock_sha256 = "e".repeat(64); + }), + ("vendored materials", |facts| { + facts.materials_digest = "e".repeat(64); + }), + ("JS handoffs", |facts| { + facts.js_inventory_sha256 = "e".repeat(64); + }), + ("JS handoffs", |facts| { + facts.js_settings_sha256 = "e".repeat(64) + }), + ("JS handoffs", |facts| { + facts.js_tools_sha256 = "e".repeat(64) + }), + ("Rust tools", |facts| { + facts.rust_tools_digest = "e".repeat(64); + }), + ("compares only", |facts| { + facts.asset = AssetId::parse("ts-aarch64-apple-darwin").expect("should resolve"); + }), + ]; + for (label, forge) in cases { + let mut b = completed(FASTLY_ASSET, Role::B, &bytes, false); + forge(b.completed.facts_mut_for_tests()); + let message = rejection(&a, &b); + assert!( + message.contains(label), + "should reject a B build with mismatched {label}: {message}" + ); + } +} + +#[test] +fn artifact_changed_after_completion_is_rejected() { + let bytes = module(b"info"); + let a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let b = completed(FASTLY_ASSET, Role::B, &bytes, false); + std::fs::write(b.artifact().path(), module(b"inFo")).expect("should tamper with B"); + assert!( + rejection(&a, &b).contains("artifact bytes changed"), + "should rehash both artifacts before comparing" + ); +} diff --git a/crates/trusted-server-release/src/recipe/tests/complete.rs b/crates/trusted-server-release/src/recipe/tests/complete.rs index 8b611567e1..91fc725bd8 100644 --- a/crates/trusted-server-release/src/recipe/tests/complete.rs +++ b/crates/trusted-server-release/src/recipe/tests/complete.rs @@ -10,7 +10,7 @@ use super::*; const FINGERPRINT: [u8; 32] = [7; 32]; -fn wasm_with(customs: &[(&str, &[u8])]) -> Vec { +pub(super) fn wasm_with(customs: &[(&str, &[u8])]) -> Vec { let mut out = b"\0asm\x01\0\0\0".to_vec(); for (name, data) in customs { let mut body = vec![u8::try_from(name.len()).expect("should fit")]; @@ -35,19 +35,19 @@ fn executable_name(asset: &str) -> String { } } -struct Built { +pub(super) struct Built { fixture: Fixture, prepared: Option, asset: &'static str, role: Role, } -fn built(asset: &'static str, role: Role, bytes: &[u8]) -> Built { +pub(super) fn built(asset: &'static str, role: Role, bytes: &[u8]) -> Built { built_with(asset, role, bytes, false) } /// Builds a bound recipe, optionally over materials with a derived manifest. -fn built_with(asset: &'static str, role: Role, bytes: &[u8], derived: bool) -> Built { +pub(super) fn built_with(asset: &'static str, role: Role, bytes: &[u8], derived: bool) -> Built { let fixture = Fixture::new(COMMIT); let prepared = if derived { let text = if role == Role::A { "A" } else { "B" }; @@ -86,7 +86,7 @@ impl Built { .expect("should hold the prepared recipe") } - fn stdout(&self, edit: impl FnOnce(&mut Value)) -> Vec { + pub(super) fn stdout(&self, edit: impl FnOnce(&mut Value)) -> Vec { let artifact = AssetId::parse(self.asset) .expect("should resolve asset") .artifact(); @@ -121,7 +121,10 @@ impl Built { } } - fn finish(&mut self, stdout: Vec) -> Result> { + pub(super) fn finish( + &mut self, + stdout: Vec, + ) -> Result> { let paths = self.fixture.paths(self.role); let run = self.run(stdout); self.prepared diff --git a/crates/trusted-server-release/src/recipe/tests/full.rs b/crates/trusted-server-release/src/recipe/tests/full.rs index 78e5385606..35466086a7 100644 --- a/crates/trusted-server-release/src/recipe/tests/full.rs +++ b/crates/trusted-server-release/src/recipe/tests/full.rs @@ -11,8 +11,11 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use super::super::ambient::ObservedAmbient; +use super::super::compare::{FASTLY_ASSET, check_fastly_equality}; +use super::super::remap::{RemapScan, scan_remapped_bytes}; use super::candidate::{ - CargoFixture, cargo_docker_settings, cargo_measurement, denial_measurement, reported_errno, + CargoFixture, cargo_docker_settings, cargo_measurement, denial_measurement, + equality_measurement, reported_errno, }; use super::*; use crate::boundary::{Boundary, DenialProof, ImageSelection, ProbeProgram}; @@ -20,7 +23,7 @@ use crate::documents::{parse_json, read_bounded}; use crate::materials::{GitVendorInput, LockedMaterials, NormalizedManifest, VendorInputs}; use crate::preflight::child::build_child_for_tests; use crate::preflight::{AuditedEnvironment, audit_environment}; -use crate::preparation::tests::full::run_two_fresh_js_builds; +use crate::preparation::tests::full::{JsFixtureRun, run_two_fresh_js_builds}; use crate::process::{Cancellation, ProcessLimits}; /// The committed denial fixture workspace. @@ -48,11 +51,18 @@ fn minimal_environment() -> AuditedEnvironment { .expect("should audit the minimal environment") } -/// Role A conditions of an ordinary CI build. +/// Conditions of an ordinary CI build of `role`. /// -/// The umask, jobs, locale and timezone are the profile values the plan -/// enforces on the child; this is not an independent qualified observation. -fn ordinary_ci_ambient(checkout: &Path) -> CheckedAmbient { +/// The umask and jobs are the profile values the plan enforces on the child. +/// Role B uses the test-only alternate profile: its host locale and timezone +/// are profile labels, while the child runs with the normalized `C` locale +/// and `UTC` timezone either way. This is not an independent qualified +/// observation, and production role B stays unqualified. +fn ordinary_ci_ambient(role: Role, checkout: &Path) -> CheckedAmbient { + let (profile, base) = match role { + Role::A => (AmbientProfile::A, observed_a()), + Role::B => (AmbientProfile::alternate_for_tests(), observed_b()), + }; // Reading the real user ID has no preconditions. let execution_uid = unsafe { libc::getuid() }; let started_at_seconds = std::time::SystemTime::now() @@ -60,16 +70,16 @@ fn ordinary_ci_ambient(checkout: &Path) -> CheckedAmbient { .expect("should read a clock after the Unix epoch") .as_secs(); CheckedAmbient::check( - Role::A, - &AmbientProfile::A, + role, + &profile, ObservedAmbient { execution_uid, checkout_root: checkout.to_owned(), started_at_seconds, - ..observed_a() + ..base }, ) - .expect("should match the role A profile") + .expect("should match the role profile") } /// A configured container boundary with its proof for this instance. @@ -190,7 +200,7 @@ fn bind_denial_case(fixture: &CargoFixture, member: &str) -> DenialCase { .expect("should select the ordinary CI Rust tools"), materials: PreparedCargoMaterials::for_tests(&base.join("materials"), commit.as_str(), &[]) .expect("should describe the empty vendor directory"), - ambient: ordinary_ci_ambient(&checkout), + ambient: ordinary_ci_ambient(Role::A, &checkout), request, }) .expect("should bind the denial case"); @@ -315,36 +325,42 @@ fn prepare_vendor( .expect("should vendor every locked original") } -#[test] -#[ignore = "requires explicit JS and Cargo ordinary CI fixtures and Linux Docker; run separately in producer CI"] -fn real_js_handoff_to_fastly_cargo_build() { - let fixture = cargo_fixture(); - let js = run_two_fresh_js_builds(); - assert_eq!( - js.source.commit().as_str(), - fixture.source_commit, - "should build the Cargo fixture's source commit" - ); - println!( - "{}", - serde_json::to_string_pretty(&js.measurement).expect("should encode the JS measurement") - ); - let cancellation = Cancellation::default(); - let deadline = Instant::now() + Duration::from_secs(40 * 60); +/// One confined Fastly build with the private roots its output lives in. +struct FastlyBuild { + _materials: tempfile::TempDir, + _work: tempfile::TempDir, + completed: RecipeCompleted, + /// Host roots of this build that must not appear in the final bytes. + host_roots: Vec, + /// The checkout root as the confined build saw it. + execution_checkout: String, +} + +fn path_text(path: &Path) -> String { + path.to_str().expect("should be a UTF-8 path").to_owned() +} + +/// Builds the Fastly Wasm for `role` from the shared JS handoff. +/// +/// Each role gets its own fresh source copy (a distinct checkout root), its +/// own freshly prepared vendor tree, fresh state, output and target roots, +/// and its own boundary with its own denial proof. The target directory and +/// source copy are removed after the build; only the copied output remains. +fn build_fastly( + js: &JsFixtureRun, + fixture: &CargoFixture, + role: Role, + deadline: Instant, + cancellation: &Cancellation, +) -> FastlyBuild { let fresh = js .source - .materialize(deadline, &cancellation) - .expect("should materialize a third fresh tracked source"); + .materialize(deadline, cancellation) + .expect("should materialize a fresh tracked source for this role"); let materials_directory = tempfile::tempdir().expect("should create the materials root"); let materials_root = fs::canonicalize(materials_directory.path()).expect("should resolve the materials root"); - let materials = prepare_vendor( - &fixture, - &js.locked, - &materials_root, - deadline, - &cancellation, - ); + let materials = prepare_vendor(fixture, &js.locked, &materials_root, deadline, cancellation); assert_eq!( materials.cargo_lock_sha256(), fixture.cargo_lock_sha256, @@ -362,9 +378,12 @@ fn real_js_handoff_to_fastly_cargo_build() { ) .expect("should resolve the retained root"); let request = RecipeRequest::parse(&RecipeInput { - asset: "trusted-server-fastly.wasm", + asset: FASTLY_ASSET, variant: "default", - role: "A", + role: match role { + Role::A => "A", + Role::B => "B", + }, commit: &fixture.source_commit, js_dir: &retained.join("bundles"), js_inventory: &retained.join("tsjs-inventory.json"), @@ -382,14 +401,32 @@ fn real_js_handoff_to_fastly_cargo_build() { SelectedRustTools::for_tests(&fixture.tools(), ReleaseTarget::Wasm32Wasip1) .expect("should select the ordinary CI Rust tools"), materials, - ordinary_ci_ambient(fresh.root()), + ordinary_ci_ambient(role, fresh.root()), request, ) .expect("should bind the real source, handoff, tools and materials"); - let proven = proven_boundary(&prepared, &fixture); + let proven = proven_boundary(&prepared, fixture); + let execution_checkout = path_text( + &proven + .boundary + .execution_path(fresh.root()) + .expect("should translate the checkout root"), + ); + let host_roots = [ + fresh.root(), + materials_root.as_path(), + base.as_path(), + retained.as_path(), + ] + .map(path_text) + .to_vec(); let completed = prepared .execute(&proven.boundary, &proven.proof) - .expect("should build the Fastly Wasm through the confined recipe"); + .unwrap_or_else(|error| { + panic!( + "should build the role {role:?} Fastly Wasm through the confined recipe: {error:?}" + ) + }); completed .rehash_for_handoff() .expect("should rehash the artifact before reporting it"); @@ -397,10 +434,81 @@ fn real_js_handoff_to_fastly_cargo_build() { !completed.derived_manifest_digests().is_empty(), "should mark the build as consuming derived Git manifests" ); - let measurement = cargo_measurement(&completed); + println!( + "{}", + serde_json::to_string_pretty(&cargo_measurement(&completed)) + .expect("should encode the build measurement") + ); + fs::remove_dir_all(base.join("target")).expect("should remove the spent target directory"); + fresh.close().expect("should remove the fresh source copy"); + FastlyBuild { + _materials: materials_directory, + _work: work, + completed, + host_roots, + execution_checkout, + } +} + +/// Scans the final bytes for every host root and the container prefix, and +/// requires at least one fixed remapped prefix. +fn scan_final_bytes(fixture: &CargoFixture, builds: [&FastlyBuild; 2]) -> RemapScan { + let mut forbidden: Vec = builds + .iter() + .flat_map(|build| build.host_roots.iter().cloned()) + .collect(); + forbidden.push(path_text(&fixture.root)); + if let Some(runner_temp) = std::env::var_os("RUNNER_TEMP") { + forbidden.push( + runner_temp + .into_string() + .expect("should name RUNNER_TEMP in UTF-8"), + ); + } + let forbidden: Vec<&str> = forbidden.iter().map(String::as_str).collect(); + let bytes = fs::read(builds[0].completed.artifact().path()) + .expect("should read the compared final bytes"); + scan_remapped_bytes(&bytes, &forbidden) + .unwrap_or_else(|error| panic!("should prove remapping in the final bytes: {error:?}")) +} + +#[test] +#[ignore = "requires explicit JS and Cargo ordinary CI fixtures and Linux Docker; run separately in producer CI"] +fn fastly_role_a_and_b_builds_are_byte_identical() { + let fixture = cargo_fixture(); + let js = run_two_fresh_js_builds(); + assert_eq!( + js.source.commit().as_str(), + fixture.source_commit, + "should build the Cargo fixture's source commit" + ); + println!( + "{}", + serde_json::to_string_pretty(&js.measurement).expect("should encode the JS measurement") + ); + let cancellation = Cancellation::default(); + let deadline = Instant::now() + Duration::from_secs(40 * 60); + let a = build_fastly(&js, &fixture, Role::A, deadline, &cancellation); + let b = build_fastly(&js, &fixture, Role::B, deadline, &cancellation); + let checked = check_fastly_equality(&a.completed, &b.completed).unwrap_or_else(|error| { + panic!("should build byte-identical Fastly A/B outputs: {error:?}") + }); + let scan = scan_final_bytes(&fixture, [&a, &b]); + let mut measurement = equality_measurement(&a.completed, &b.completed, &checked); + for (role, build) in [("A", &a), ("B", &b)] { + // Container paths are identical, so checkout variation is host-only on Linux. + measurement["builds"][role]["ambient"]["execution_checkout_root"] = + serde_json::json!(build.execution_checkout); + } + measurement["remap_scan"] = serde_json::json!({ + "forbidden": ["/boundary/", "host build roots", "fixture root", "RUNNER_TEMP"], + "found": scan.found(), + }); let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); println!("{text}"); - fs::write(fixture.root.join("cargo-execution-measurement.json"), text) - .expect("should retain the Cargo measurement"); - fresh.close().expect("should remove the fresh source copy"); + fs::write( + fixture.root.join("cargo-ab-equality-measurement.json"), + text, + ) + .expect("should retain the A/B equality measurement"); } diff --git a/crates/trusted-server-release/src/recipe/tests/plan.rs b/crates/trusted-server-release/src/recipe/tests/plan.rs index 6b33c80ec2..6ad108d122 100644 --- a/crates/trusted-server-release/src/recipe/tests/plan.rs +++ b/crates/trusted-server-release/src/recipe/tests/plan.rs @@ -27,6 +27,21 @@ fn expected_replacement() -> Vec { .collect() } +/// The exact target path remap for the fixture's mount layout. +fn expected_remap(triple: &str) -> Vec { + let flags = [ + "\"--remap-path-prefix=/boundary/immutable/0=/trusted-server\"", + "\"--remap-path-prefix=/boundary/immutable/2/vendor=/cargo-vendor\"", + "\"--remap-path-prefix=/boundary/writable/0/cargo-home=/cargo-home\"", + "\"--remap-path-prefix=/boundary/tool/0=/rust-tools\"", + "\"--remap-path-prefix=/boundary/writable/1=/cargo-target\"", + ]; + vec![ + "--config".to_owned(), + format!("target.\"{triple}\".rustflags=[{}]", flags.join(",")), + ] +} + #[test] fn cargo_plan_has_fixed_release_frozen_json_flags_and_exact_source_replacement() { assert!( @@ -58,6 +73,7 @@ fn cargo_plan_has_fixed_release_frozen_json_flags_and_exact_source_replacement() .map(str::to_owned) .collect(); expected_arguments.extend(expected_replacement()); + expected_arguments.extend(expected_remap(artifact.target.triple())); expected_arguments.extend( ["--message-format=json-render-diagnostics", "--jobs", jobs] .into_iter() @@ -139,7 +155,7 @@ fn assert_environment(actual: &BTreeMap, fixture: &Fixture, targ /// One mapped location per path-carrying variable, so each can be omitted alone. fn granular(fixture: &Fixture, omit: &[&str]) -> PrefixPaths { let retained = fixture.retained(); - let all: [(&str, PathBuf, &str, bool); 9] = [ + let all: [(&str, PathBuf, &str, bool); 11] = [ ("target", fixture.base.join("target"), "/c/target", true), ( "cargo-home", @@ -174,6 +190,13 @@ fn granular(fixture: &Fixture, omit: &[&str]) -> PrefixPaths { "/c/vendor", false, ), + ( + "checkout", + fixture.base.join("checkout-a"), + "/c/checkout", + false, + ), + ("tools", fixture.base.join("tools"), "/c/tools", false), ]; all.iter().filter(|(name, ..)| !omit.contains(name)).fold( PrefixPaths::new(), @@ -190,15 +213,17 @@ fn path_variables_outside_every_root_are_rejected() { prepared .plan(&granular(&fixture, &[])) .expect("should derive the plan when every location is mapped"); - let cases: [(&str, &[&str]); 8] = [ + let cases: [(&str, &[&str]); 10] = [ ("CARGO_TARGET_DIR", &["target"]), ("CARGO_HOME", &["cargo-home"]), ("HOME", &["home"]), ("TMPDIR", &["tmp"]), ("TSJS_PREBUILT_DIR", &["bundles"]), ("TRUSTED_SERVER__TSJS_PREBUILT_INVENTORY", &["inventory"]), - ("RUSTC", &["rustc", "bin"]), + ("RUSTC", &["rustc", "bin", "tools"]), ("vendor directory", &["vendor"]), + ("checkout root", &["checkout"]), + ("Rust tools root", &["tools"]), ]; for (label, omit) in cases { let message = plan_error(&prepared, &granular(&fixture, omit)); @@ -207,7 +232,7 @@ fn path_variables_outside_every_root_are_rejected() { "should reject {label} outside the roots: {message}" ); } - let message = plan_error(&prepared, &granular(&fixture, &["bin"])); + let message = plan_error(&prepared, &granular(&fixture, &["bin", "tools"])); assert!( message.contains("PATH") && message.contains("outside the boundary roots"), "should reject the tool directory in PATH outside the roots: {message}" @@ -281,8 +306,15 @@ fn cargo_plan_rejects_inherited_rustflags_and_selectors() { .collect(); assert_eq!( configs.len(), - expected_replacement().len() / 2, - "should carry only the exact source-replacement configs" + expected_replacement().len() / 2 + 1, + "should carry only the exact source-replacement configs and the path remap" + ); + assert_eq!( + configs.last().map(|config| config.as_str()), + expected_remap("x86_64-unknown-linux-gnu") + .last() + .map(String::as_str), + "should remap paths only through target rustflags of the asset triple" ); for argument in &plan.arguments { assert!( diff --git a/crates/trusted-server-release/src/recipe/tests/remap.rs b/crates/trusted-server-release/src/recipe/tests/remap.rs new file mode 100644 index 0000000000..2cb4a39974 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/remap.rs @@ -0,0 +1,273 @@ +use super::super::paths::PrefixPaths; +use super::super::remap::{ + REMAPPED_PREFIXES, RemapSources, check_cargo_config, check_no_extensionless_config, + remap_arguments, scan_remapped_bytes, +}; +use super::fixture::*; +use super::*; + +const TRACKED_CARGO_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../.cargo/config.toml" +)); + +/// The `--config` value of `plan`'s remap entry. +fn remap_config(plan: &CargoPlan) -> &str { + plan.arguments + .iter() + .rfind(|argument| argument.contains(".rustflags=")) + .expect("should carry the remap config") +} + +fn sources<'a>(checkout: &'a str, vendor: &'a str) -> RemapSources<'a> { + RemapSources { + checkout, + vendor, + cargo_home: "/x/state/cargo-home", + tools: "/x/tools", + target: "/x/target", + } +} + +#[test] +fn remap_maps_every_source_root_to_a_fixed_prefix_in_a_fixed_order() { + let arguments = remap_arguments("wasm32-wasip1", &sources("/x/checkout", "/x/vendor")) + .expect("should remap disjoint roots"); + assert_eq!( + arguments, + [ + "--config".to_owned(), + [ + "target.\"wasm32-wasip1\".rustflags=[", + "\"--remap-path-prefix=/x/checkout=/trusted-server\",", + "\"--remap-path-prefix=/x/vendor=/cargo-vendor\",", + "\"--remap-path-prefix=/x/state/cargo-home=/cargo-home\",", + "\"--remap-path-prefix=/x/tools=/rust-tools\",", + "\"--remap-path-prefix=/x/target=/cargo-target\"]", + ] + .concat(), + ], + "should emit checkout, vendor, Cargo home, tools, target in that order \ + through one quoted target rustflags entry" + ); + assert_eq!( + REMAPPED_PREFIXES, + [ + "/trusted-server", + "/cargo-vendor", + "/cargo-home", + "/rust-tools", + "/cargo-target" + ], + "should keep the fixed prefixes in emitted order" + ); +} + +#[test] +fn nested_or_equal_remapped_roots_are_rejected() { + for (checkout, vendor) in [ + ("/x/checkout", "/x/checkout/vendor"), + ("/x/vendor/inner", "/x/vendor"), + ("/x/same", "/x/same"), + ("/x/checkout", "/x/tools"), + ] { + let error = remap_arguments("wasm32-wasip1", &sources(checkout, vendor)) + .expect_err("should reject overlapping roots whose order would decide the path"); + assert!( + format!("{error:?}").contains("overlap"), + "should name the overlap for {checkout} and {vendor}" + ); + } +} + +#[test] +fn plan_rejects_remapped_roots_that_overlap_in_the_execution_namespace() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("trusted-server-fastly.wasm", Role::A) + .expect("should bind the recipe"); + let nested = PrefixPaths::new() + .with( + &fixture.base.join("materials"), + "/boundary/immutable/0/materials", + false, + ) + .with( + &fixture.base.join("checkout-a"), + "/boundary/immutable/0", + false, + ) + .with(&fixture.retained(), "/boundary/immutable/1", false) + .with(&fixture.base.join("tools"), "/boundary/tool/0", false) + .with( + &fixture.base.join("cargo-state"), + "/boundary/writable/0", + true, + ) + .with(&fixture.base.join("target"), "/boundary/writable/1", true); + assert!( + plan_error(&prepared, &nested).contains("overlap"), + "should reject a vendor tree mounted inside the checkout" + ); +} + +#[test] +fn remap_is_identical_for_both_roles_in_the_same_mount_layout() { + let fixture = Fixture::new(COMMIT); + let a = fixture + .bind("trusted-server-fastly.wasm", Role::A) + .expect("should bind role A") + .plan(&fixture.paths(Role::A)) + .expect("should plan A"); + let other = Fixture::new(COMMIT); + let b = other + .bind("trusted-server-fastly.wasm", Role::B) + .expect("should bind role B") + .plan(&other.paths(Role::B)) + .expect("should plan B"); + assert_ne!( + a.working_directory, b.working_directory, + "should run from distinct host checkouts" + ); + assert_eq!( + remap_config(&a), + remap_config(&b), + "should hand both roles the same remap in the same execution layout" + ); + for plan in [&a, &b] { + assert_eq!( + ( + plan.environment.get("LC_ALL").map(String::as_str), + plan.environment.get("TZ").map(String::as_str), + ), + (Some("C"), Some("UTC")), + "should keep the compiler locale and timezone normalized for each role" + ); + } +} + +#[test] +fn remap_follows_identity_paths_to_the_same_fixed_prefixes() { + let fixture = Fixture::new(COMMIT); + let prepared = fixture + .bind("trusted-server-fastly.wasm", Role::A) + .expect("should bind the recipe"); + let base = fixture.base.to_str().expect("should be UTF-8"); + let retained = fixture.retained(); + let identity = PrefixPaths::new().with(&fixture.base, base, true).with( + &retained, + retained.to_str().expect("should be UTF-8"), + false, + ); + let plan = prepared + .plan(&identity) + .expect("should plan with host paths"); + assert_eq!( + remap_config(&plan), + format!( + "target.\"wasm32-wasip1\".rustflags=[\ + \"--remap-path-prefix={base}/checkout-a=/trusted-server\",\ + \"--remap-path-prefix={base}/materials/vendor=/cargo-vendor\",\ + \"--remap-path-prefix={base}/cargo-state/cargo-home=/cargo-home\",\ + \"--remap-path-prefix={base}/tools=/rust-tools\",\ + \"--remap-path-prefix={base}/target=/cargo-target\"]" + ), + "should remap host-identical execution paths, as on macOS, to the fixed prefixes" + ); +} + +#[test] +fn tracked_cargo_config_keeps_target_rustflags_reviewable() { + check_cargo_config(TRACKED_CARGO_CONFIG.as_bytes()) + .expect("should accept the tracked Cargo configuration"); + check_cargo_config(b"[target.wasm32-wasip1]\nrustflags = [\"--cfg\", \"example\"]\n") + .expect("should accept target rustflags, which the remap extends"); + check_cargo_config(b"[build]\njobs = 2\n").expect("should accept other build keys"); +} + +#[test] +fn cargo_config_that_the_remap_would_supersede_is_rejected() { + for (label, bytes) in [ + ( + "build rustflags", + &b"[build]\nrustflags = [\"--cfg\", \"example\"]\n"[..], + ), + ( + "dotted build rustflags", + b"build.rustflags = \"--cfg example\"\n", + ), + ("non-UTF-8", b"\xff\xfe"), + ("non-TOML", b"[build\n"), + ] { + assert!( + check_cargo_config(bytes).is_err(), + "should reject a Cargo configuration with {label}" + ); + } +} + +#[test] +fn extensionless_cargo_config_is_rejected_tracked_or_on_disk() { + let directory = tempfile::tempdir().expect("should create a checkout"); + let checkout = directory.path(); + std::fs::create_dir(checkout.join(".cargo")).expect("should create .cargo"); + std::fs::write(checkout.join(".cargo/config.toml"), b"[alias]\n") + .expect("should write the reviewed configuration"); + check_no_extensionless_config(checkout, false) + .expect("should accept a checkout with only config.toml"); + assert!( + format!( + "{:?}", + check_no_extensionless_config(checkout, true).expect_err("should reject tracked") + ) + .contains("tracks .cargo/config"), + "should reject a tracked extensionless configuration" + ); + std::os::unix::fs::symlink("config.toml", checkout.join(".cargo/config")) + .expect("should alias the legacy name"); + assert!( + format!( + "{:?}", + check_no_extensionless_config(checkout, false).expect_err("should reject on disk") + ) + .contains("holds .cargo/config"), + "should reject an extensionless configuration present on disk, even as a link" + ); +} + +#[test] +fn remap_scan_requires_a_fixed_prefix_and_no_unremapped_path() { + let clean = b"\0asm../trusted-server/crates/core/src/lib.rs\0/rustc/abc/library"; + let scan = scan_remapped_bytes(clean, &["/tmp/fixture-root", "/home/runner/work/_temp"]) + .expect("should accept remapped bytes"); + assert_eq!( + scan.found(), + ["/trusted-server"], + "should list found prefixes" + ); + for (label, bytes) in [ + ("container mount", &b"x/boundary/immutable/0/src/lib.rs"[..]), + ( + "host fixture root", + b"/trusted-server/a /tmp/fixture-root/x", + ), + ( + "host runner temp", + b"/cargo-vendor/a /home/runner/work/_temp/y", + ), + ("no fixed prefix", b"/rustc/abc/library/core/src/lib.rs"), + ("prefix without separator", b"/trusted-server-core"), + ] { + assert!( + scan_remapped_bytes(bytes, &["/tmp/fixture-root", "/home/runner/work/_temp"]).is_err(), + "should reject bytes with {label}" + ); + } + let too_many = vec!["/x"; 65]; + for forbidden in [&too_many[..], &[""][..]] { + assert!( + scan_remapped_bytes(clean, forbidden).is_err(), + "should bound the forbidden prefixes" + ); + } +} diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 382a84f7dc..9eb0a940b1 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -324,7 +324,7 @@ Implementation sequencing: review the local validation framework and candidate b - [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture MUST NOT invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. - [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. -Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. Role B and A/B equality, the `ts` executables, remapping, Viceroy final-byte acceptance and qualified pins remain open. macOS boundaries stay rejected by the execute gate until Task 8 evidence. +Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. The plan now remaps the checkout, vendor, Cargo home, Rust tool (sysroot) and target roots to fixed prefixes through one `--config target..rustflags` entry, which merges after reviewed target rustflags (environment rustflags stay rejected, a tracked `build.rustflags`, which target rustflags would supersede, is refused, as is any extensionless `.cargo/config`). The remapped roots must be pairwise disjoint, because rustc applies the last matching prefix, and the remap is bound by the plan digest. On Linux both roles see identical container paths (`/boundary/...`), so the checkout-root variation is host-only; the remap is proven on the final Fastly bytes by a scan that rejects `/boundary/` and every host build root and requires a fixed remapped prefix. Literals built from `env!("OUT_DIR")` or `env!("CARGO_MANIFEST_DIR")` are not remapped by rustc and are caught by the same scan. A pure comparison requires one role A and one role B Fastly build over identical source, lock, materials, JS handoff and tools, an honest ambient pairing and complete final-byte equality, reporting sizes, digests, the first differing offset and its Wasm section otherwise. The ordinary CI job now builds roles A (umask 022, four jobs) and B (test-only alternate profile: umask 027, two jobs, distinct checkout root and start time) from one JS handoff and compares them; that evidence is pending its first CI run. The `ts` executables, Viceroy final-byte acceptance and qualified pins remain open, and production role B stays unqualified. macOS boundaries stay rejected by the execute gate until Task 8 evidence. ### Task 10: Record selected Cargo units and native materials From 26045fdb011a97379fc42dc539e6a837d454c5c7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 11:08:40 +0530 Subject: [PATCH 073/153] Name recipe build roles Canonical and Reproduction Replace the letter roles with descriptive names: Role::Canonical is the build whose bytes ship and Role::Reproduction is the independent rebuild that must match or is measured. The spec's A/B spelling remains only at its external interfaces, the closed --role A|B request and the tracked ambient_profiles keys, mapped in Role::parse and Role::spec_label. Rename the dependent profiles, helpers, tests, error text, measurement fields and the ignored CI equality test, and describe the two independent JS builds without letter labels. --- .github/workflows/test.yml | 13 +- .../trusted-server-release/src/javascript.rs | 10 +- .../src/policy/tools.rs | 5 +- .../src/preparation/tests/full.rs | 2 +- crates/trusted-server-release/src/recipe.rs | 32 +++- .../src/recipe/ambient.rs | 111 +++++++----- .../src/recipe/compare.rs | 135 ++++++++------ .../src/recipe/tests.rs | 35 ++-- .../src/recipe/tests/ambient.rs | 167 +++++++++++------- .../src/recipe/tests/bind.rs | 122 +++++++++---- .../src/recipe/tests/candidate.rs | 19 +- .../src/recipe/tests/compare.rs | 78 ++++---- .../src/recipe/tests/complete.rs | 36 ++-- .../src/recipe/tests/fixture.rs | 30 ++-- .../src/recipe/tests/full.rs | 48 ++--- .../src/recipe/tests/plan.rs | 44 +++-- .../src/recipe/tests/remap.rs | 36 ++-- .../src/recipe/tests/tools.rs | 4 +- ...6-10-13-attested-release-implementation.md | 2 +- 19 files changed, 558 insertions(+), 371 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee082b2b03..2436ad3e81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -467,19 +467,20 @@ jobs: cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-denial-measurement.json" - # Role A and role B build from the same JS handoff in separate fresh - # roots and boundaries; the final Fastly files must be byte-identical. - - name: Build Fastly roles A and B from one JS handoff and compare final bytes (Linux) + # The canonical and reproduction builds use the same JS handoff in + # separate fresh roots and boundaries; the final Fastly files must be + # byte-identical. + - name: Build canonical and reproduction Fastly from one JS handoff and compare final bytes (Linux) shell: bash run: | set -euo pipefail - TEST_NAME=recipe::tests::full::fastly_role_a_and_b_builds_are_byte_identical + TEST_NAME=recipe::tests::full::fastly_canonical_and_reproduction_builds_are_byte_identical TEST_LIST="$(mktemp)" trap 'rm -f "$TEST_LIST"' EXIT cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --list > "$TEST_LIST" - awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Fastly A/B equality fixture"; exit 1 } }' "$TEST_LIST" + awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Fastly canonical/reproduction equality fixture"; exit 1 } }' "$TEST_LIST" cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture - cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-ab-equality-measurement.json" + cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-canonical-reproduction-equality-measurement.json" test-typescript: name: vitest diff --git a/crates/trusted-server-release/src/javascript.rs b/crates/trusted-server-release/src/javascript.rs index 83c570448a..71f3357245 100644 --- a/crates/trusted-server-release/src/javascript.rs +++ b/crates/trusted-server-release/src/javascript.rs @@ -120,7 +120,7 @@ impl JsBuildCompleted { pub(crate) fn inventory_sha256(&self) -> &str { &self.inventory_sha256 } - /// Returns the independent A/B successful registered test counts. + /// Returns the successful registered test counts of both independent JS builds. pub(crate) fn test_counts(&self) -> [u64; 2] { self.tests } @@ -319,7 +319,7 @@ fn compare_modules( || filenames(second, expected.len())? != expected { return Err(preparation_error( - "A/B final bundle sets differ from approved source", + "independent JS build bundle sets differ from approved source", )); } for module in modules.values() { @@ -330,7 +330,9 @@ fn compare_modules( || first.sha256 != module.sha256 || first.bytes.len() as u64 != module.size { - return Err(preparation_error("full A/B final JS module bytes differ")); + return Err(preparation_error( + "final JS module bytes differ between the independent builds", + )); } } Ok(()) @@ -732,7 +734,7 @@ impl PreparedJsBuild<'_> { .ok_or_else(|| preparation_error("B omitted a tracked module"))?; if first.sha256 != last.sha256 || first.size != last.size { return Err(preparation_error( - "A/B inventory module measurements differ", + "independent JS build inventory module measurements differ", )); } } diff --git a/crates/trusted-server-release/src/policy/tools.rs b/crates/trusted-server-release/src/policy/tools.rs index 5ff02871c2..48e1928493 100644 --- a/crates/trusted-server-release/src/policy/tools.rs +++ b/crates/trusted-server-release/src/policy/tools.rs @@ -416,10 +416,7 @@ struct FixedRecipe { /// Complete effective settings shared by every executable recipe. fn artifact_settings() -> Value { json!({ - "ambient_profiles": { - "A": AmbientProfile::A.to_json(), - "B": AmbientProfile::B_PENDING.to_json(), - }, + "ambient_profiles": AmbientProfile::tracked_json(), "aws_lc_target_selector": true, "aws_lc_use_system": false, "cargo_config": ".cargo/config.toml", diff --git a/crates/trusted-server-release/src/preparation/tests/full.rs b/crates/trusted-server-release/src/preparation/tests/full.rs index 354d46305c..1a53d4d5e3 100644 --- a/crates/trusted-server-release/src/preparation/tests/full.rs +++ b/crates/trusted-server-release/src/preparation/tests/full.rs @@ -187,7 +187,7 @@ fn two_fresh_js_builds( let completed=prepared.run([&first,&second],[&first_proof,&second_proof]).expect("should complete both actual fresh offline installations, lifecycle observations, negative tool controls, full suites and final-byte equality"); assert!( owned_roots.iter().all(|root| !root.exists()), - "should close all six private A/B source, state and metadata roots before completion" + "should close all six private source, state and metadata roots of both builds before completion" ); assert_eq!( completed.source_commit(), diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index b65a2e1d9f..5139a444bf 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -132,27 +132,43 @@ impl AssetId { /// Which independent build of the pair a recipe belongs to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Role { - /// The first, reference build. - A, - /// The second build under varied ambient conditions. - B, + /// The canonical build whose output is proposed for publication. + Canonical, + /// The independent reproduction under varied ambient conditions, which + /// must match the canonical build. + Reproduction, } impl Role { - /// Parses exactly `A` or `B`. + /// Parses exactly `A` or `B`, the external spelling of the two roles. + /// + /// The spec (R-REPEAT, "Build A" and "Build B") and the closed + /// `build.sh --role A|B` interface of the implementation plan (Task 9) + /// name the roles `A` and `B`, as do the tracked `ambient_profiles` keys + /// of `release-tools.json`. This and [`Self::spec_label`] are the only + /// places that spelling maps to [`Self::Canonical`] and + /// [`Self::Reproduction`]. /// /// # Errors /// /// Returns [`ReleaseError::Recipe`] for any other text. pub(crate) fn parse(text: &str) -> Result> { match text { - "A" => Ok(Self::A), - "B" => Ok(Self::B), + "A" => Ok(Self::Canonical), + "B" => Ok(Self::Reproduction), _ => Err(recipe_error(format!( "unknown role {text:?}; expected A or B" ))), } } + + /// Returns the external spelling [`Self::parse`] accepts for this role. + pub(crate) fn spec_label(self) -> &'static str { + match self { + Self::Canonical => "A", + Self::Reproduction => "B", + } + } } /// Requires the default feature variant. @@ -175,7 +191,7 @@ pub(crate) struct RecipeInput<'a> { pub(crate) asset: &'a str, /// Feature variant; only `default` is accepted. pub(crate) variant: &'a str, - /// `A` or `B`. + /// `A` (canonical) or `B` (reproduction); see [`Role::parse`]. pub(crate) role: &'a str, /// Approved 40-digit source commit. pub(crate) commit: &'a str, diff --git a/crates/trusted-server-release/src/recipe/ambient.rs b/crates/trusted-server-release/src/recipe/ambient.rs index 95a430adc5..217f40729d 100644 --- a/crates/trusted-server-release/src/recipe/ambient.rs +++ b/crates/trusted-server-release/src/recipe/ambient.rs @@ -1,4 +1,5 @@ -//! Ambient build conditions: compiled profiles, observations and A/B pairing. +//! Ambient build conditions: compiled profiles, observations and the +//! canonical/reproduction pairing. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -16,7 +17,8 @@ const NORMALIZED_LOCALE: &str = "C"; /// Timezone every compiler child sees, whatever the host reports. const NORMALIZED_TIMEZONE: &str = "UTC"; -/// A qualified alternate B profile; none exists, so production B stays closed. +/// A qualified alternate reproduction profile; none exists, so the production +/// reproduction build stays closed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum QualifiedAlternate {} @@ -32,8 +34,9 @@ pub(crate) struct AmbientProfile { } impl AmbientProfile { - /// Profile of the first build; its symbolic user and root are qualification-selected. - pub(crate) const A: Self = Self { + /// Profile of the canonical build; its symbolic user and root are + /// qualification-selected. + pub(crate) const CANONICAL: Self = Self { checkout_root: "external-release-A", execution_user: "qualification-selected", jobs: 4, @@ -42,8 +45,8 @@ impl AmbientProfile { umask: 0o022, }; - /// Placeholder of the second build; its alternates await qualification. - pub(crate) const B_PENDING: Self = Self { + /// Placeholder of the reproduction build; its alternates await qualification. + pub(crate) const REPRODUCTION_PENDING: Self = Self { checkout_root: "external-release-B", execution_user: "qualified-alternate-or-unsupported", jobs: 2, @@ -56,13 +59,13 @@ impl AmbientProfile { /// /// # Errors /// - /// Returns [`ReleaseError::Unqualified`] for role B: its alternate - /// locale, timezone and user have not been qualified. + /// Returns [`ReleaseError::Unqualified`] for the reproduction build: its + /// alternate locale, timezone and user have not been qualified. pub(crate) fn for_role(role: Role) -> Result> { match role { - Role::A => Ok(Self::A), - Role::B => Err(Report::new(ReleaseError::Unqualified) - .attach("role B alternate ambient profile is not qualified")), + Role::Canonical => Ok(Self::CANONICAL), + Role::Reproduction => Err(Report::new(ReleaseError::Unqualified) + .attach("reproduction build alternate ambient profile is not qualified")), } } @@ -87,10 +90,29 @@ impl AmbientProfile { locale, timezone, execution_user: "qualification-selected", - ..Self::B_PENDING + ..Self::REPRODUCTION_PENDING } } + /// The compiled profiles `release-tools.json` tracks, by role. + fn tracked() -> [(Role, Self); 2] { + [ + (Role::Canonical, Self::CANONICAL), + (Role::Reproduction, Self::REPRODUCTION_PENDING), + ] + } + + /// Renders the `ambient_profiles` map exactly as `release-tools.json` + /// states it, keyed by each role's [`Role::spec_label`]. + pub(crate) fn tracked_json() -> Value { + Value::Object( + Self::tracked() + .into_iter() + .map(|(role, profile)| (role.spec_label().to_owned(), profile.to_json())) + .collect(), + ) + } + /// Renders the profile exactly as `release-tools.json` states it. pub(crate) fn to_json(&self) -> Value { json!({ @@ -110,7 +132,8 @@ impl AmbientProfile { /// /// Returns [`ReleaseError::Recipe`] naming the profile that drifted. pub(crate) fn check_profile_drift(settings: &Value) -> Result<(), Report> { - for (name, compiled) in [("A", AmbientProfile::A), ("B", AmbientProfile::B_PENDING)] { + for (role, compiled) in AmbientProfile::tracked() { + let name = role.spec_label(); if settings["ambient_profiles"][name] != compiled.to_json() { return Err(recipe_error(format!( "tracked ambient profile {name} differs from the compiled profile" @@ -152,7 +175,7 @@ impl Dimension { ]; } -/// How one dimension is reported in the A/B pair. +/// How one dimension is reported in the canonical/reproduction pair. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Treatment { Varied, @@ -284,13 +307,13 @@ fn same( /// Requires the profile to be the runnable one compiled for `role`. fn check_profile_role(role: Role, profile: &AmbientProfile) -> Result<(), Report> { let belongs = match role { - Role::A => *profile == AmbientProfile::A, - Role::B => { - *profile != AmbientProfile::B_PENDING - && profile.umask != AmbientProfile::A.umask - && profile.jobs != AmbientProfile::A.jobs - && profile.locale != AmbientProfile::A.locale - && profile.timezone != AmbientProfile::A.timezone + Role::Canonical => *profile == AmbientProfile::CANONICAL, + Role::Reproduction => { + *profile != AmbientProfile::REPRODUCTION_PENDING + && profile.umask != AmbientProfile::CANONICAL.umask + && profile.jobs != AmbientProfile::CANONICAL.jobs + && profile.locale != AmbientProfile::CANONICAL.locale + && profile.timezone != AmbientProfile::CANONICAL.timezone } }; if belongs { @@ -350,28 +373,31 @@ fn expected_treatment(dimension: Dimension) -> Treatment { /// Requires every dimension that must vary to vary, and the user to match. fn check_variation( - first: &ObservedAmbient, - second: &ObservedAmbient, + canonical: &ObservedAmbient, + reproduction: &ObservedAmbient, ) -> Result<(), Report> { for (dimension, differs) in [ - (Dimension::Umask, first.umask != second.umask), - (Dimension::Jobs, first.jobs != second.jobs), + (Dimension::Umask, canonical.umask != reproduction.umask), + (Dimension::Jobs, canonical.jobs != reproduction.jobs), ( Dimension::StartTime, - first.started_at_seconds != second.started_at_seconds, + canonical.started_at_seconds != reproduction.started_at_seconds, ), ] { if !differs { return Err(recipe_error(format!( - "{dimension} is identical across the A and B builds" + "{dimension} is identical across the canonical and reproduction builds" ))); } } disjoint(&[ - ("checkout root A", first.checkout_root.as_path()), - ("checkout root B", second.checkout_root.as_path()), + ("canonical checkout root", canonical.checkout_root.as_path()), + ( + "reproduction checkout root", + reproduction.checkout_root.as_path(), + ), ])?; - if first.execution_uid != second.execution_uid { + if canonical.execution_uid != reproduction.execution_uid { return Err(recipe_error( "execution user differs but no alternate user is qualified", )); @@ -393,30 +419,33 @@ fn check_claim(claim: &AmbientClaim) -> Result<(), Report> { Ok(()) } -/// A verified A/B ambient pairing. +/// A verified canonical/reproduction ambient pairing. #[derive(Clone, Debug)] pub(crate) struct AmbientRecord { claim: AmbientClaim, } impl AmbientRecord { - /// Verifies that the A and B builds varied exactly what the claim states. + /// Verifies that the canonical and reproduction builds varied exactly + /// what the claim states. /// /// # Errors /// - /// Returns [`ReleaseError::Recipe`] when the roles are not A then B, a - /// varied dimension did not vary, checkout roots overlap, the execution - /// user differs without qualification, or the claim omits or misplaces a - /// dimension. + /// Returns [`ReleaseError::Recipe`] when the roles are not canonical then + /// reproduction, a varied dimension did not vary, checkout roots overlap, + /// the execution user differs without qualification, or the claim omits + /// or misplaces a dimension. pub(crate) fn pair( - first: &CheckedAmbient, - second: &CheckedAmbient, + canonical: &CheckedAmbient, + reproduction: &CheckedAmbient, claim: &AmbientClaim, ) -> Result> { - if first.role != Role::A || second.role != Role::B { - return Err(recipe_error("ambient pair must be role A then role B")); + if canonical.role != Role::Canonical || reproduction.role != Role::Reproduction { + return Err(recipe_error( + "ambient pair must be the canonical then the reproduction build", + )); } - check_variation(&first.observed, &second.observed)?; + check_variation(&canonical.observed, &reproduction.observed)?; check_claim(claim)?; Ok(Self { claim: claim.clone(), diff --git a/crates/trusted-server-release/src/recipe/compare.rs b/crates/trusted-server-release/src/recipe/compare.rs index 355b365658..57365f5557 100644 --- a/crates/trusted-server-release/src/recipe/compare.rs +++ b/crates/trusted-server-release/src/recipe/compare.rs @@ -1,10 +1,12 @@ -//! Complete final-byte comparison of the role A and role B Fastly builds. +//! Complete final-byte comparison of the canonical and reproduction Fastly +//! builds. //! //! The Fastly executable has no normalized or excluded region: the two final //! files must be identical byte for byte. Before comparing bytes, the pair -//! must be one A and one B build of the same asset from the same source, -//! `Cargo.lock`, vendored materials, JS handoff and Rust tools, and their -//! ambient records must vary exactly what the standard claim states. +//! must be one canonical and one reproduction build of the same asset from +//! the same source, `Cargo.lock`, vendored materials, JS handoff and Rust +//! tools, and their ambient records must vary exactly what the standard claim +//! states. use std::io::Read as _; @@ -17,12 +19,13 @@ use super::artifact::EXECUTABLE_LIMIT; use super::{AssetId, RecipeCompleted, Role, recipe_error}; use crate::error::ReleaseError; -/// The only asset whose A/B outputs must be byte-identical. +/// The only asset whose canonical and reproduction outputs must be byte-identical. pub(crate) const FASTLY_ASSET: &str = "trusted-server-fastly.wasm"; /// Bytes shown from each side at the first difference. const WINDOW: usize = 16; -/// Proof that the A and B Fastly builds produced identical final bytes. +/// Proof that the canonical and reproduction Fastly builds produced identical +/// final bytes. /// /// It has private fields and only [`check_fastly_equality`] constructs it. #[derive(Debug)] @@ -55,51 +58,63 @@ impl FastlyEqualityChecked { } } -/// Requires `first` and `second` to be equal, naming `label` otherwise. +/// Requires `canonical` and `reproduction` to be equal, naming `label` otherwise. fn same_input( label: &str, - first: &T, - second: &T, + canonical: &T, + reproduction: &T, ) -> Result<(), Report> { - if first == second { + if canonical == reproduction { return Ok(()); } Err(recipe_error(format!( - "A and B Fastly builds used different {label}" + "canonical and reproduction Fastly builds used different {label}" ))) } -/// Requires one A and one B build of the Fastly asset over identical inputs. -fn check_pair(a: &RecipeCompleted, b: &RecipeCompleted) -> Result<(), Report> { - if a.role() != Role::A || b.role() != Role::B { +/// Requires one canonical and one reproduction build of the Fastly asset over +/// identical inputs. +fn check_pair( + canonical: &RecipeCompleted, + reproduction: &RecipeCompleted, +) -> Result<(), Report> { + if canonical.role() != Role::Canonical || reproduction.role() != Role::Reproduction { return Err(recipe_error( - "Fastly equality needs a role A then a role B build", + "Fastly equality needs a canonical then a reproduction build", )); } let fastly = AssetId::parse(FASTLY_ASSET)?; - if a.asset() != fastly || b.asset() != fastly { + if canonical.asset() != fastly || reproduction.asset() != fastly { return Err(recipe_error(format!( "Fastly equality compares only {FASTLY_ASSET}" ))); } - same_input("source commits", a.commit(), b.commit())?; + same_input("source commits", canonical.commit(), reproduction.commit())?; same_input( "Cargo.lock files", - a.cargo_lock_sha256(), - b.cargo_lock_sha256(), + canonical.cargo_lock_sha256(), + reproduction.cargo_lock_sha256(), )?; same_input( "vendored materials", - a.materials_digest(), - b.materials_digest(), + canonical.materials_digest(), + reproduction.materials_digest(), )?; same_input( "derived Git manifests", - a.derived_manifest_digests(), - b.derived_manifest_digests(), + canonical.derived_manifest_digests(), + reproduction.derived_manifest_digests(), + )?; + same_input( + "JS handoffs", + &canonical.js_digests(), + &reproduction.js_digests(), )?; - same_input("JS handoffs", &a.js_digests(), &b.js_digests())?; - same_input("Rust tools", a.rust_tools_digest(), b.rust_tools_digest()) + same_input( + "Rust tools", + canonical.rust_tools_digest(), + reproduction.rust_tools_digest(), + ) } /// Reads the retained artifact and requires its recorded size and digest. @@ -124,31 +139,40 @@ fn read_artifact(completed: &RecipeCompleted) -> Result, Report Result> { - check_pair(a, b)?; - let ambient = AmbientRecord::pair(a.ambient(), b.ambient(), &AmbientClaim::standard())?; - let first = read_artifact(a)?; - let second = read_artifact(b)?; - if first != second { - return Err(recipe_error(describe_mismatch(&first, &second))); + check_pair(canonical, reproduction)?; + let ambient = AmbientRecord::pair( + canonical.ambient(), + reproduction.ambient(), + &AmbientClaim::standard(), + )?; + let canonical_bytes = read_artifact(canonical)?; + let reproduction_bytes = read_artifact(reproduction)?; + if canonical_bytes != reproduction_bytes { + return Err(recipe_error(describe_mismatch( + &canonical_bytes, + &reproduction_bytes, + ))); } Ok(FastlyEqualityChecked { - asset: a.asset(), - sha256: a.artifact().sha256().to_owned(), - size: a.artifact().size(), + asset: canonical.asset(), + sha256: canonical.artifact().sha256().to_owned(), + size: canonical.artifact().size(), ambient, }) } @@ -184,28 +208,29 @@ fn section_at(bytes: &[u8], offset: usize) -> String { } /// Describes a final-byte mismatch with bounded diagnostics. -pub(super) fn describe_mismatch(first: &[u8], second: &[u8]) -> String { - let offset = first_difference(first, second).unwrap_or(first.len()); +pub(super) fn describe_mismatch(canonical: &[u8], reproduction: &[u8]) -> String { + let offset = first_difference(canonical, reproduction).unwrap_or(canonical.len()); let window = |bytes: &[u8]| { let end = bytes.len().min(offset.saturating_add(WINDOW)); hex::encode(bytes.get(offset..end).unwrap_or_default()) }; - let differing = first + let differing = canonical .iter() - .zip(second) + .zip(reproduction) .filter(|(left, right)| left != right) .count() - + first.len().abs_diff(second.len()); + + canonical.len().abs_diff(reproduction.len()); format!( - "final Fastly bytes differ: A size {} sha256 {}, B size {} sha256 {}; \ - first differing offset {offset} (A {}: {}, B {}: {}); {differing} differing bytes", - first.len(), - hex::encode(Sha256::digest(first)), - second.len(), - hex::encode(Sha256::digest(second)), - section_at(first, offset), - window(first), - section_at(second, offset), - window(second), + "final Fastly bytes differ: canonical size {} sha256 {}, \ + reproduction size {} sha256 {}; first differing offset {offset} \ + (canonical {}: {}, reproduction {}: {}); {differing} differing bytes", + canonical.len(), + hex::encode(Sha256::digest(canonical)), + reproduction.len(), + hex::encode(Sha256::digest(reproduction)), + section_at(canonical, offset), + window(canonical), + section_at(reproduction, offset), + window(reproduction), ) } diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs index 6755eb9772..c1b2761d55 100644 --- a/crates/trusted-server-release/src/recipe/tests.rs +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -66,8 +66,11 @@ fn asset_selection_comes_from_fixed_table_not_json() { #[test] fn unknown_role_is_rejected() { - assert_eq!(Role::parse("A").expect("should accept A"), Role::A); - assert_eq!(Role::parse("B").expect("should accept B"), Role::B); + assert_eq!(Role::parse("A").expect("should accept A"), Role::Canonical); + assert_eq!( + Role::parse("B").expect("should accept B"), + Role::Reproduction + ); for text in ["", "a", "b", "C", "AB", " A"] { assert!( Role::parse(text).is_err_and(|error| format!("{error:?}").contains("unknown role")), @@ -116,7 +119,7 @@ impl Workspace { RecipeInput { asset: "ts-aarch64-apple-darwin", variant: "default", - role: "A", + role: Role::Canonical.spec_label(), commit: COMMIT, js_dir: &paths[0], js_inventory: &paths[1], @@ -281,7 +284,7 @@ fn overlapping_roots_are_rejected() { } } -fn observed_a() -> ObservedAmbient { +fn observed_canonical() -> ObservedAmbient { ObservedAmbient { umask: 0o022, jobs: 4, @@ -290,12 +293,12 @@ fn observed_a() -> ObservedAmbient { child_locale: "C".to_owned(), child_timezone: "UTC".to_owned(), execution_uid: 501, - checkout_root: PathBuf::from("/example/release-a"), + checkout_root: PathBuf::from("/example/release-canonical"), started_at_seconds: 1_700_000_000, } } -fn observed_b() -> ObservedAmbient { +fn observed_reproduction() -> ObservedAmbient { ObservedAmbient { umask: 0o027, jobs: 2, @@ -304,23 +307,27 @@ fn observed_b() -> ObservedAmbient { child_locale: "C".to_owned(), child_timezone: "UTC".to_owned(), execution_uid: 501, - checkout_root: PathBuf::from("/example/release-b"), + checkout_root: PathBuf::from("/example/release-reproduction"), started_at_seconds: 1_700_000_900, } } -fn checked_a() -> CheckedAmbient { - CheckedAmbient::check(Role::A, &AmbientProfile::A, observed_a()) - .expect("should accept the baseline A ambient") +fn checked_canonical() -> CheckedAmbient { + CheckedAmbient::check( + Role::Canonical, + &AmbientProfile::CANONICAL, + observed_canonical(), + ) + .expect("should accept the baseline canonical ambient") } -fn checked_b() -> CheckedAmbient { +fn checked_reproduction() -> CheckedAmbient { CheckedAmbient::check( - Role::B, + Role::Reproduction, &AmbientProfile::alternate_for_tests(), - observed_b(), + observed_reproduction(), ) - .expect("should accept the baseline B ambient") + .expect("should accept the baseline reproduction ambient") } mod ambient; diff --git a/crates/trusted-server-release/src/recipe/tests/ambient.rs b/crates/trusted-server-release/src/recipe/tests/ambient.rs index f3c5c440b5..cfc37ba82f 100644 --- a/crates/trusted-server-release/src/recipe/tests/ambient.rs +++ b/crates/trusted-server-release/src/recipe/tests/ambient.rs @@ -20,35 +20,51 @@ fn check_error(role: Role, profile: &AmbientProfile, observed: ObservedAmbient) } #[test] -fn role_b_with_role_a_umask_is_rejected() { - checked_a(); - checked_b(); - let b_profile = AmbientProfile::alternate_for_tests(); +fn reproduction_with_canonical_umask_is_rejected() { + checked_canonical(); + checked_reproduction(); + let reproduction_profile = AmbientProfile::alternate_for_tests(); let wrong_umask = ObservedAmbient { umask: 0o022, - ..observed_b() + ..observed_reproduction() }; assert!( - check_error(Role::B, &b_profile, wrong_umask).contains("umask"), - "should reject role B observed under the role A umask" + check_error(Role::Reproduction, &reproduction_profile, wrong_umask).contains("umask"), + "should reject the reproduction build observed under the canonical umask" ); - let a_with_b_umask = ObservedAmbient { + let canonical_with_reproduction_umask = ObservedAmbient { umask: 0o027, - ..observed_a() + ..observed_canonical() }; assert!( - check_error(Role::A, &AmbientProfile::A, a_with_b_umask).contains("umask"), - "should reject role A observed under the role B umask" + check_error( + Role::Canonical, + &AmbientProfile::CANONICAL, + canonical_with_reproduction_umask + ) + .contains("umask"), + "should reject the canonical build observed under the reproduction umask" ); assert!( - check_error(Role::B, &AmbientProfile::A, observed_a()).contains("runnable profile"), - "should reject role B bound to the role A profile" + check_error( + Role::Reproduction, + &AmbientProfile::CANONICAL, + observed_canonical() + ) + .contains("runnable profile"), + "should reject the reproduction build bound to the canonical profile" ); assert!( - check_error(Role::A, &b_profile, observed_b()).contains("runnable profile"), - "should reject role A bound to an alternate profile" + check_error( + Role::Canonical, + &reproduction_profile, + observed_reproduction() + ) + .contains("runnable profile"), + "should reject the canonical build bound to an alternate profile" ); - let closed = AmbientProfile::for_role(Role::B).expect_err("should keep B closed"); + let closed = AmbientProfile::for_role(Role::Reproduction) + .expect_err("should keep the reproduction build closed"); assert!( format!("{closed:?}").contains("not qualified"), "should name the missing qualification" @@ -57,26 +73,35 @@ fn role_b_with_role_a_umask_is_rejected() { #[test] fn observed_jobs_mismatch_is_rejected() { - let a_wrong = ObservedAmbient { + let canonical_wrong = ObservedAmbient { jobs: 2, - ..observed_a() + ..observed_canonical() }; assert!( - check_error(Role::A, &AmbientProfile::A, a_wrong).contains("jobs"), - "should reject A observed with B jobs" + check_error(Role::Canonical, &AmbientProfile::CANONICAL, canonical_wrong).contains("jobs"), + "should reject the canonical build observed with reproduction jobs" ); - let b_wrong = ObservedAmbient { + let reproduction_wrong = ObservedAmbient { jobs: 8, - ..observed_b() + ..observed_reproduction() }; assert!( - check_error(Role::B, &AmbientProfile::alternate_for_tests(), b_wrong).contains("jobs"), - "should reject B observed with unrelated jobs" + check_error( + Role::Reproduction, + &AmbientProfile::alternate_for_tests(), + reproduction_wrong + ) + .contains("jobs"), + "should reject the reproduction build observed with unrelated jobs" ); } -fn claim_error(first: &CheckedAmbient, second: &CheckedAmbient, claim: &AmbientClaim) -> String { - match AmbientRecord::pair(first, second, claim) { +fn claim_error( + canonical: &CheckedAmbient, + reproduction: &CheckedAmbient, + claim: &AmbientClaim, +) -> String { + match AmbientRecord::pair(canonical, reproduction, claim) { Ok(_) => panic!("should reject the pairing"), Err(error) => format!("{error:?}"), } @@ -84,8 +109,12 @@ fn claim_error(first: &CheckedAmbient, second: &CheckedAmbient, claim: &AmbientC #[test] fn normalized_locale_reported_as_varied_is_rejected() { - let record = AmbientRecord::pair(&checked_a(), &checked_b(), &AmbientClaim::standard()) - .expect("should accept the standard claim"); + let record = AmbientRecord::pair( + &checked_canonical(), + &checked_reproduction(), + &AmbientClaim::standard(), + ) + .expect("should accept the standard claim"); assert_eq!( record.claim(), &AmbientClaim::standard(), @@ -96,17 +125,18 @@ fn normalized_locale_reported_as_varied_is_rejected() { claim.normalized.remove(&dimension); claim.varied.insert(dimension); assert!( - claim_error(&checked_a(), &checked_b(), &claim).contains("misreports"), + claim_error(&checked_canonical(), &checked_reproduction(), &claim) + .contains("misreports"), "should reject {dimension} claimed as varied" ); } let leaking_child = ObservedAmbient { child_locale: "en_US.UTF-8".to_owned(), - ..observed_b() + ..observed_reproduction() }; assert!( check_error( - Role::B, + Role::Reproduction, &AmbientProfile::alternate_for_tests(), leaking_child ) @@ -121,20 +151,20 @@ fn unsupported_execution_user_claimed_as_varied_is_rejected() { claim.unsupported.remove(&Dimension::ExecutionUser); claim.varied.insert(Dimension::ExecutionUser); assert!( - claim_error(&checked_a(), &checked_b(), &claim).contains("misreports"), + claim_error(&checked_canonical(), &checked_reproduction(), &claim).contains("misreports"), "should reject an unqualified user claimed as varied" ); let other_user = CheckedAmbient::check( - Role::B, + Role::Reproduction, &AmbientProfile::alternate_for_tests(), ObservedAmbient { execution_uid: 502, - ..observed_b() + ..observed_reproduction() }, ) .expect("should check the alternate user in isolation"); assert!( - claim_error(&checked_a(), &other_user, &AmbientClaim::standard()) + claim_error(&checked_canonical(), &other_user, &AmbientClaim::standard()) .contains("execution user"), "should reject an actually different unqualified user" ); @@ -148,50 +178,57 @@ fn omitted_ambient_dimension_is_rejected() { claim.normalized.remove(&dimension); claim.unsupported.remove(&dimension); assert!( - claim_error(&checked_a(), &checked_b(), &claim).contains("omits"), + claim_error(&checked_canonical(), &checked_reproduction(), &claim).contains("omits"), "should reject a claim omitting {dimension}" ); } let mut duplicated = AmbientClaim::standard(); duplicated.normalized.insert(Dimension::Jobs); assert!( - claim_error(&checked_a(), &checked_b(), &duplicated).contains("misreports"), + claim_error(&checked_canonical(), &checked_reproduction(), &duplicated) + .contains("misreports"), "should reject a dimension placed twice" ); } #[test] -fn identical_checkout_roots_for_a_and_b_are_rejected() { +fn identical_canonical_and_reproduction_checkout_roots_are_rejected() { for root in [ - "/example/release-a", - "/example/release-a/nested", + "/example/release-canonical", + "/example/release-canonical/nested", "/example", ] { let overlapping = CheckedAmbient::check( - Role::B, + Role::Reproduction, &AmbientProfile::alternate_for_tests(), ObservedAmbient { checkout_root: PathBuf::from(root), - ..observed_b() + ..observed_reproduction() }, ) .expect("should check the alternate root in isolation"); assert!( - claim_error(&checked_a(), &overlapping, &AmbientClaim::standard()).contains("overlap"), - "should reject checkout root {root} overlapping A" + claim_error( + &checked_canonical(), + &overlapping, + &AmbientClaim::standard() + ) + .contains("overlap"), + "should reject checkout root {root} overlapping the canonical build" ); } let same_start = CheckedAmbient::check( - Role::B, + Role::Reproduction, &AmbientProfile::alternate_for_tests(), ObservedAmbient { - started_at_seconds: observed_a().started_at_seconds, - ..observed_b() + started_at_seconds: observed_canonical().started_at_seconds, + ..observed_reproduction() }, ) .expect("should check the alternate start in isolation"); assert!( - claim_error(&checked_a(), &same_start, &AmbientClaim::standard()).contains("StartTime"), + claim_error(&checked_canonical(), &same_start, &AmbientClaim::standard()) + .contains("StartTime"), "should reject builds that did not start at different times" ); } @@ -209,11 +246,11 @@ fn rust_profile_drift_from_release_tools_json_is_rejected() { .expect("should accept the tracked ambient profiles"); checked += 1; for (profile, field, value) in [ - ("A", "umask", Value::from("027")), - ("A", "jobs", Value::from(2)), - ("B", "jobs", Value::from(4)), - ("B", "umask", Value::from("022")), - ("B", "locale", Value::from("C")), + (Role::Canonical.spec_label(), "umask", Value::from("027")), + (Role::Canonical.spec_label(), "jobs", Value::from(2)), + (Role::Reproduction.spec_label(), "jobs", Value::from(4)), + (Role::Reproduction.spec_label(), "umask", Value::from("022")), + (Role::Reproduction.spec_label(), "locale", Value::from("C")), ] { let mut drifted = recipe["settings"].clone(); drifted["ambient_profiles"][profile][field] = value; @@ -227,34 +264,36 @@ fn rust_profile_drift_from_release_tools_json_is_rejected() { missing["ambient_profiles"] .as_object_mut() .expect("should hold profiles") - .remove("B"); + .remove(Role::Reproduction.spec_label()); assert!( - check_profile_drift(&missing) - .is_err_and(|error| format!("{error:?}").contains("profile B")), - "should reject a missing B profile" + check_profile_drift(&missing).is_err_and(|error| { + format!("{error:?}") + .contains(&format!("profile {}", Role::Reproduction.spec_label())) + }), + "should reject a missing reproduction profile" ); } assert_eq!(checked, 4, "should check every artifact recipe"); } #[test] -fn role_b_with_a_locale_or_timezone_is_rejected() { +fn reproduction_with_canonical_locale_or_timezone_is_rejected() { let same_locale = AmbientProfile::alternate_with_host_for_tests("C", "Pacific/Auckland"); let observed = ObservedAmbient { host_locale: "C".to_owned(), - ..observed_b() + ..observed_reproduction() }; assert!( - check_error(Role::B, &same_locale, observed).contains("runnable profile"), - "should reject role B that keeps the role A locale" + check_error(Role::Reproduction, &same_locale, observed).contains("runnable profile"), + "should reject a reproduction build that keeps the canonical locale" ); let same_timezone = AmbientProfile::alternate_with_host_for_tests("en_US.UTF-8", "UTC"); let observed = ObservedAmbient { host_timezone: "UTC".to_owned(), - ..observed_b() + ..observed_reproduction() }; assert!( - check_error(Role::B, &same_timezone, observed).contains("runnable profile"), - "should reject role B that keeps the role A timezone" + check_error(Role::Reproduction, &same_timezone, observed).contains("runnable profile"), + "should reject a reproduction build that keeps the canonical timezone" ); } diff --git a/crates/trusted-server-release/src/recipe/tests/bind.rs b/crates/trusted-server-release/src/recipe/tests/bind.rs index ec4c408c44..f5fd770872 100644 --- a/crates/trusted-server-release/src/recipe/tests/bind.rs +++ b/crates/trusted-server-release/src/recipe/tests/bind.rs @@ -9,11 +9,11 @@ use crate::boundary::{Boundary, DenialProof}; #[test] fn js_handoff_for_other_source_commit_is_rejected() { Fixture::new(COMMIT) - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the matching handoff"); let other = Fixture::new(OTHER_COMMIT); assert!( - bind_error(other.bind("ts-aarch64-apple-darwin", Role::A)) + bind_error(other.bind("ts-aarch64-apple-darwin", Role::Canonical)) .contains("another source commit"), "should reject a JS handoff built from another commit" ); @@ -22,9 +22,14 @@ fn js_handoff_for_other_source_commit_is_rejected() { "should not create the target after a rejection" ); let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", OTHER_COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + OTHER_COMMIT, + ); assert!( - bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("another source commit"), + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)) + .contains("another source commit"), "should reject a request naming another commit" ); } @@ -32,18 +37,28 @@ fn js_handoff_for_other_source_commit_is_rejected() { #[test] fn js_handoff_mutated_after_completion_is_rejected() { let changed = Fixture::new(COMMIT); - let request = changed.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = changed.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); fs::write(changed.retained().join("bundles/tsjs-core.js"), b"tampered") .expect("should mutate a retained module"); assert!( - bind_error(changed.bind_with(request, Role::A, COMMIT)).contains("no longer rehashes"), + bind_error(changed.bind_with(request, Role::Canonical, COMMIT)) + .contains("no longer rehashes"), "should reject a mutated retained module" ); let extra = Fixture::new(COMMIT); - let request = extra.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = extra.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); fs::write(extra.retained().join("unrecorded"), b"extra").expect("should add an entry"); assert!( - bind_error(extra.bind_with(request, Role::A, COMMIT)).contains("no longer rehashes"), + bind_error(extra.bind_with(request, Role::Canonical, COMMIT)) + .contains("no longer rehashes"), "should reject an unrecorded retained entry" ); assert!( @@ -56,14 +71,22 @@ fn js_handoff_mutated_after_completion_is_rejected() { fn request_for_other_js_directory_or_role_is_rejected() { let fixture = Fixture::new(COMMIT); let foreign = Fixture::new(COMMIT); - let request = foreign.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = foreign.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); assert!( - bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("does not name"), + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("does not name"), "should reject a request naming another JS handoff directory" ); - let request = fixture.request("ts-aarch64-apple-darwin", "B", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Reproduction.spec_label(), + COMMIT, + ); assert!( - bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("ambient role"), + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("ambient role"), "should reject an ambient of another role" ); } @@ -72,7 +95,7 @@ fn request_for_other_js_directory_or_role_is_rejected() { fn bind_creates_fresh_target_and_private_cargo_state() { let fixture = Fixture::new(COMMIT); fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); for relative in [ "target", @@ -99,7 +122,7 @@ fn bind_creates_fresh_target_and_private_cargo_state() { fn execute_rejects_boundary_with_other_roots() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); let other = BoundaryRoots::new( &[fixture.base.join("other-source")], @@ -122,7 +145,7 @@ fn execute_rejects_boundary_with_other_roots() { fn execute_rejects_boundary_without_owned_descendant_cleanup() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); let roots = prepared .boundary_roots() @@ -148,8 +171,8 @@ fn qualified_rust_tools_constructor_is_uninhabited() { let _constructor: fn(&Path, QualifiedRustTools) -> SelectedRustTools = SelectedRustTools::qualified; let _ = never; - let closed = - AmbientProfile::for_role(Role::B).expect_err("should keep the alternate ambient closed"); + let closed = AmbientProfile::for_role(Role::Reproduction) + .expect_err("should keep the alternate ambient closed"); assert!( format!("{closed:?}").contains("not qualified"), "should name the missing qualification" @@ -176,13 +199,17 @@ fn ordinary_ci_selection_carries_no_release_authority() { fn root_filled_between_parse_and_bind_is_rejected() { for (relative, label) in [("cargo-state", "cargo state"), ("output", "output")] { let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); fs::write( fixture.base.join(relative).join("late"), b"written after parse", ) .expect("should fill the root after parsing"); - let error = bind_error(fixture.bind_with(request, Role::A, COMMIT)); + let error = bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)); assert!( error.contains(label) && error.contains("cannot be reused"), "should reject a {label} root filled after parsing" @@ -193,10 +220,15 @@ fn root_filled_between_parse_and_bind_is_rejected() { ); } let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); fs::create_dir(fixture.base.join("target")).expect("should create the target after parse"); assert!( - bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("cannot be reused"), + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)) + .contains("cannot be reused"), "should reject a target created after parsing" ); } @@ -204,7 +236,11 @@ fn root_filled_between_parse_and_bind_is_rejected() { #[test] fn materials_changed_or_foreign_after_preparation_are_rejected() { let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); let materials = fixture.materials(COMMIT); fs::write( fixture @@ -215,7 +251,7 @@ fn materials_changed_or_foreign_after_preparation_are_rejected() { .expect("should edit a vendored file"); let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); assert!( - bind_error(fixture.bind_parts(request, Role::A, COMMIT, tools, materials)) + bind_error(fixture.bind_parts(request, Role::Canonical, COMMIT, tools, materials)) .contains("no longer rehash"), "should reject materials edited after preparation" ); @@ -225,11 +261,15 @@ fn materials_changed_or_foreign_after_preparation_are_rejected() { ); let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); let foreign = fixture.materials(OTHER_COMMIT); let tools = fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin); assert!( - bind_error(fixture.bind_parts(request, Role::A, COMMIT, tools, foreign)) + bind_error(fixture.bind_parts(request, Role::Canonical, COMMIT, tools, foreign)) .contains("another source commit"), "should reject materials derived from another commit" ); @@ -238,11 +278,15 @@ fn materials_changed_or_foreign_after_preparation_are_rejected() { #[test] fn tools_selected_for_another_target_are_rejected() { let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); let tools = fixture.tools_for(ReleaseTarget::X86_64UnknownLinuxGnu); let materials = fixture.materials(COMMIT); assert!( - bind_error(fixture.bind_parts(request, Role::A, COMMIT, tools, materials)) + bind_error(fixture.bind_parts(request, Role::Canonical, COMMIT, tools, materials)) .contains("another target"), "should reject tools whose standard library was checked for another target" ); @@ -251,18 +295,28 @@ fn tools_selected_for_another_target_are_rejected() { #[test] fn target_inside_checkout_is_rejected_before_creation() { let fixture = Fixture::new(COMMIT); - let inside = fixture.base.join("checkout-a/target"); - let request = fixture.request_at("ts-aarch64-apple-darwin", "A", COMMIT, &inside); + let inside = fixture.base.join("checkout-canonical/target"); + let request = fixture.request_at( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + &inside, + ); assert!( - bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("overlap"), + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("overlap"), "should reject a target inside the checkout root" ); assert!(!inside.exists(), "should create nothing in the checkout"); let fixture = Fixture::new(COMMIT); let inside_tools = fixture.base.join("tools/target"); - let request = fixture.request_at("ts-aarch64-apple-darwin", "A", COMMIT, &inside_tools); + let request = fixture.request_at( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + &inside_tools, + ); assert!( - bind_error(fixture.bind_with(request, Role::A, COMMIT)).contains("overlap"), + bind_error(fixture.bind_with(request, Role::Canonical, COMMIT)).contains("overlap"), "should reject a target inside the tools root" ); assert!(!inside_tools.exists(), "should create nothing in the tools"); @@ -272,11 +326,11 @@ fn target_inside_checkout_is_rejected_before_creation() { fn boundary_roots_mount_materials_read_only_state_and_target_writable_output_unmounted() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); let expected = BoundaryRoots::new( &[ - fixture.base.join("checkout-a"), + fixture.base.join("checkout-canonical"), fixture.retained(), fixture.base.join("materials"), ], diff --git a/crates/trusted-server-release/src/recipe/tests/candidate.rs b/crates/trusted-server-release/src/recipe/tests/candidate.rs index 45a47ec310..1e52ede29e 100644 --- a/crates/trusted-server-release/src/recipe/tests/candidate.rs +++ b/crates/trusted-server-release/src/recipe/tests/candidate.rs @@ -262,8 +262,8 @@ pub(super) fn cargo_docker_settings(user: (u32, u32), parallelism: u32) -> Docke fn role_text(role: Role) -> &'static str { match role { - Role::A => "A", - Role::B => "B", + Role::Canonical => "canonical", + Role::Reproduction => "reproduction", } } @@ -322,10 +322,11 @@ fn ambient_measurement(completed: &RecipeCompleted) -> Value { }) } -/// The ordinary CI measurement of the Fastly A/B final-byte comparison. +/// The ordinary CI measurement of the Fastly canonical/reproduction final-byte +/// comparison. pub(super) fn equality_measurement( - a: &RecipeCompleted, - b: &RecipeCompleted, + canonical: &RecipeCompleted, + reproduction: &RecipeCompleted, checked: &FastlyEqualityChecked, ) -> Value { let claim = checked.ambient().claim(); @@ -339,17 +340,17 @@ pub(super) fn equality_measurement( }; json!({ "schema_version": SCHEMA_VERSION, - "kind": "ordinary-ci-fastly-ab-equality", + "kind": "ordinary-ci-fastly-canonical-reproduction-equality", "asset": checked.asset().artifact().name, - "source_commit": a.commit().as_str(), + "source_commit": canonical.commit().as_str(), "equality": true, "artifact": { "sha256": checked.sha256(), "size": checked.size(), }, "builds": { - "A": build(a), - "B": build(b), + "canonical": build(canonical), + "reproduction": build(reproduction), }, "ambient_claim": { "varied": names(&claim.varied), diff --git a/crates/trusted-server-release/src/recipe/tests/compare.rs b/crates/trusted-server-release/src/recipe/tests/compare.rs index d38e489d14..066bd3654e 100644 --- a/crates/trusted-server-release/src/recipe/tests/compare.rs +++ b/crates/trusted-server-release/src/recipe/tests/compare.rs @@ -42,19 +42,20 @@ fn finish(mut built: Built) -> Completed { } } -fn rejection(a: &RecipeCompleted, b: &RecipeCompleted) -> String { - match check_fastly_equality(a, b) { +fn rejection(canonical: &RecipeCompleted, reproduction: &RecipeCompleted) -> String { + match check_fastly_equality(canonical, reproduction) { Ok(_) => panic!("should reject the pair"), Err(error) => format!("{error:?}"), } } #[test] -fn identical_a_and_b_fastly_bytes_are_accepted_with_the_standard_claim() { +fn identical_canonical_and_reproduction_fastly_bytes_are_accepted_with_the_standard_claim() { let bytes = module(b"info"); - let a = completed(FASTLY_ASSET, Role::A, &bytes, false); - let b = completed(FASTLY_ASSET, Role::B, &bytes, false); - let checked = check_fastly_equality(&a, &b).expect("should accept identical final bytes"); + let canonical = completed(FASTLY_ASSET, Role::Canonical, &bytes, false); + let reproduction = completed(FASTLY_ASSET, Role::Reproduction, &bytes, false); + let checked = check_fastly_equality(&canonical, &reproduction) + .expect("should accept identical final bytes"); assert_eq!( checked.asset().artifact().name, FASTLY_ASSET, @@ -72,7 +73,7 @@ fn identical_a_and_b_fastly_bytes_are_accepted_with_the_standard_claim() { "should bind the honest ambient placement" ); - let measurement = super::candidate::equality_measurement(&a, &b, &checked); + let measurement = super::candidate::equality_measurement(&canonical, &reproduction, &checked); assert_eq!( ( &measurement["kind"], @@ -80,13 +81,13 @@ fn identical_a_and_b_fastly_bytes_are_accepted_with_the_standard_claim() { &measurement["release_qualification"], ), ( - &serde_json::json!("ordinary-ci-fastly-ab-equality"), + &serde_json::json!("ordinary-ci-fastly-canonical-reproduction-equality"), &serde_json::json!(true), &serde_json::json!(false), ), "should report candidate equality without release qualification" ); - for (role, umask, jobs) in [("A", "022", 4), ("B", "027", 2)] { + for (role, umask, jobs) in [("canonical", "022", 4), ("reproduction", "027", 2)] { let build = &measurement["builds"][role]; assert_eq!( build["artifact"]["sha256"], @@ -116,17 +117,17 @@ fn identical_a_and_b_fastly_bytes_are_accepted_with_the_standard_claim() { #[test] fn one_byte_difference_reports_sizes_digests_and_first_offset() { - let a = completed(FASTLY_ASSET, Role::A, &module(b"info"), false); - let b = completed(FASTLY_ASSET, Role::B, &module(b"inFo"), false); - let message = rejection(&a, &b); + let canonical = completed(FASTLY_ASSET, Role::Canonical, &module(b"info"), false); + let reproduction = completed(FASTLY_ASSET, Role::Reproduction, &module(b"inFo"), false); + let message = rejection(&canonical, &reproduction); let offset = first_difference(&module(b"info"), &module(b"inFo")).expect("should find the changed byte"); for part in [ "final Fastly bytes differ".to_owned(), format!("first differing offset {offset}"), - format!("A size {}", module(b"info").len()), - a.artifact().sha256().to_owned(), - b.artifact().sha256().to_owned(), + format!("canonical size {}", module(b"info").len()), + canonical.artifact().sha256().to_owned(), + reproduction.artifact().sha256().to_owned(), "custom section \".debug_info\"".to_owned(), "1 differing bytes".to_owned(), ] { @@ -149,7 +150,7 @@ fn first_difference_covers_prefixes_and_equal_inputs() { ); let message = describe_mismatch(b"ab", b"abc"); assert!( - message.contains("A size 2") && message.contains("B size 3"), + message.contains("canonical size 2") && message.contains("reproduction size 3"), "should report both sizes: {message}" ); assert!( @@ -168,13 +169,13 @@ fn first_difference_covers_prefixes_and_equal_inputs() { #[test] fn same_role_or_swapped_pairs_are_rejected() { let bytes = module(b"info"); - let a = completed(FASTLY_ASSET, Role::A, &bytes, false); - let other_a = completed(FASTLY_ASSET, Role::A, &bytes, false); - let b = completed(FASTLY_ASSET, Role::B, &bytes, false); - for (first, second) in [(&a, &other_a), (&b, &a)] { + let canonical = completed(FASTLY_ASSET, Role::Canonical, &bytes, false); + let other_canonical = completed(FASTLY_ASSET, Role::Canonical, &bytes, false); + let reproduction = completed(FASTLY_ASSET, Role::Reproduction, &bytes, false); + for (first, second) in [(&canonical, &other_canonical), (&reproduction, &canonical)] { assert!( - rejection(first, second).contains("role A then a role B"), - "should require exactly role A then role B" + rejection(first, second).contains("a canonical then a reproduction build"), + "should require exactly the canonical then the reproduction build" ); } } @@ -182,31 +183,31 @@ fn same_role_or_swapped_pairs_are_rejected() { #[test] fn mismatched_inputs_or_another_asset_are_rejected() { let bytes = module(b"info"); - let a = completed(FASTLY_ASSET, Role::A, &bytes, false); - let derived = completed(FASTLY_ASSET, Role::B, &bytes, true); + let canonical = completed(FASTLY_ASSET, Role::Canonical, &bytes, false); + let derived = completed(FASTLY_ASSET, Role::Reproduction, &bytes, true); assert!( - rejection(&a, &derived).contains("derived Git manifests"), + rejection(&canonical, &derived).contains("derived Git manifests"), "should reject builds over different derived manifests" ); let native = finish(built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional executable", )); - let b = completed(FASTLY_ASSET, Role::B, &bytes, false); + let reproduction = completed(FASTLY_ASSET, Role::Reproduction, &bytes, false); assert!( - rejection(&native, &b).contains("compares only"), + rejection(&native, &reproduction).contains("compares only"), "should compare only the Fastly asset" ); } -/// One forged input of role B and the label its rejection must name. +/// One forged input of the reproduction build and the label its rejection must name. type Forge = (&'static str, fn(&mut SuccessFacts)); #[test] fn each_mismatched_input_is_rejected_by_name() { let bytes = module(b"info"); - let a = completed(FASTLY_ASSET, Role::A, &bytes, false); + let canonical = completed(FASTLY_ASSET, Role::Canonical, &bytes, false); let cases: [Forge; 8] = [ ("source commits", |facts| { facts.commit = CommitId::parse(OTHER_COMMIT).expect("should parse commit"); @@ -234,12 +235,12 @@ fn each_mismatched_input_is_rejected_by_name() { }), ]; for (label, forge) in cases { - let mut b = completed(FASTLY_ASSET, Role::B, &bytes, false); - forge(b.completed.facts_mut_for_tests()); - let message = rejection(&a, &b); + let mut reproduction = completed(FASTLY_ASSET, Role::Reproduction, &bytes, false); + forge(reproduction.completed.facts_mut_for_tests()); + let message = rejection(&canonical, &reproduction); assert!( message.contains(label), - "should reject a B build with mismatched {label}: {message}" + "should reject a reproduction build with mismatched {label}: {message}" ); } } @@ -247,11 +248,12 @@ fn each_mismatched_input_is_rejected_by_name() { #[test] fn artifact_changed_after_completion_is_rejected() { let bytes = module(b"info"); - let a = completed(FASTLY_ASSET, Role::A, &bytes, false); - let b = completed(FASTLY_ASSET, Role::B, &bytes, false); - std::fs::write(b.artifact().path(), module(b"inFo")).expect("should tamper with B"); + let canonical = completed(FASTLY_ASSET, Role::Canonical, &bytes, false); + let reproduction = completed(FASTLY_ASSET, Role::Reproduction, &bytes, false); + std::fs::write(reproduction.artifact().path(), module(b"inFo")) + .expect("should tamper with the reproduction build"); assert!( - rejection(&a, &b).contains("artifact bytes changed"), + rejection(&canonical, &reproduction).contains("artifact bytes changed"), "should rehash both artifacts before comparing" ); } diff --git a/crates/trusted-server-release/src/recipe/tests/complete.rs b/crates/trusted-server-release/src/recipe/tests/complete.rs index 91fc725bd8..58a946bda7 100644 --- a/crates/trusted-server-release/src/recipe/tests/complete.rs +++ b/crates/trusted-server-release/src/recipe/tests/complete.rs @@ -50,7 +50,7 @@ pub(super) fn built(asset: &'static str, role: Role, bytes: &[u8]) -> Built { pub(super) fn built_with(asset: &'static str, role: Role, bytes: &[u8], derived: bool) -> Built { let fixture = Fixture::new(COMMIT); let prepared = if derived { - let text = if role == Role::A { "A" } else { "B" }; + let text = role.spec_label(); let request = fixture.request(asset, text, COMMIT); let tools = fixture.tools_for(request.asset.artifact().target); let materials = fixture @@ -144,11 +144,11 @@ fn rejection(result: Result>) -> String { #[test] fn successful_run_binds_every_input_and_copies_the_artifact() { let bytes = b"fictional native executable"; - let mut built = built("ts-aarch64-apple-darwin", Role::A, bytes); + let mut built = built("ts-aarch64-apple-darwin", Role::Canonical, bytes); let stdout = built.stdout(|_| {}); let expected_plan = built .prepared() - .plan(&built.fixture.paths(Role::A)) + .plan(&built.fixture.paths(Role::Canonical)) .expect("should derive the plan") .digest() .expect("should digest the plan"); @@ -169,7 +169,7 @@ fn successful_run_binds_every_input_and_copies_the_artifact() { "ts-aarch64-apple-darwin", "should bind the asset" ); - assert_eq!(completed.role(), Role::A, "should bind the role"); + assert_eq!(completed.role(), Role::Canonical, "should bind the role"); assert_eq!( completed.commit().as_str(), COMMIT, @@ -207,7 +207,7 @@ fn successful_run_binds_every_input_and_copies_the_artifact() { ); assert_eq!( completed.ambient().role(), - Role::A, + Role::Canonical, "should bind the ambient" ); assert_eq!( @@ -300,7 +300,7 @@ fn rehash_rejects_changed_output_and_retained_stream() { for (label, tamper) in tampers { let mut built = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); let stdout = built.stdout(|_| {}); @@ -334,7 +334,7 @@ fn malformed_streams_complete_nothing_and_copy_nothing() { for (label, edit) in edits { let mut built = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); let stdout = built.stdout(edit); @@ -355,7 +355,7 @@ fn malformed_streams_complete_nothing_and_copy_nothing() { fn changed_tools_or_materials_or_existing_output_reject_completion() { let mut built_tools = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); let stdout = built_tools.stdout(|_| {}); @@ -368,7 +368,7 @@ fn changed_tools_or_materials_or_existing_output_reject_completion() { let mut built_materials = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); let stdout = built_materials.stdout(|_| {}); @@ -387,7 +387,7 @@ fn changed_tools_or_materials_or_existing_output_reject_completion() { let mut built_output = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); let stdout = built_output.stdout(|_| {}); @@ -409,7 +409,7 @@ fn changed_tools_or_materials_or_existing_output_reject_completion() { fn wasm_artifacts_must_keep_debug_sections() { let asset = "trusted-server-fastly.wasm"; let stripped = wasm_with(&[("name", b"n")]); - let mut built_stripped = built(asset, Role::A, &stripped); + let mut built_stripped = built(asset, Role::Canonical, &stripped); let stdout = built_stripped.stdout(|_| {}); let message = rejection(built_stripped.finish(stdout)); assert!( @@ -418,7 +418,7 @@ fn wasm_artifacts_must_keep_debug_sections() { ); let kept = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); - let mut built_kept = built(asset, Role::A, &kept); + let mut built_kept = built(asset, Role::Canonical, &kept); let stdout = built_kept.stdout(|_| {}); let completed = built_kept .finish(stdout) @@ -429,7 +429,7 @@ fn wasm_artifacts_must_keep_debug_sections() { "should publish under the asset name" ); - let mut built_text = built(asset, Role::A, b"not wasm at all"); + let mut built_text = built(asset, Role::Canonical, b"not wasm at all"); let stdout = built_text.stdout(|_| {}); assert!( built_text.finish(stdout).is_err(), @@ -441,7 +441,7 @@ fn wasm_artifacts_must_keep_debug_sections() { fn execute_stays_closed_for_an_ordinary_ci_selection() { let built = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); assert_eq!( @@ -455,7 +455,7 @@ fn execute_stays_closed_for_an_ordinary_ci_selection() { fn derived_manifest_digests_are_carried_into_the_completion() { let mut plain = built( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", ); let stdout = plain.stdout(|_| {}); @@ -467,7 +467,7 @@ fn derived_manifest_digests_are_carried_into_the_completion() { let mut derived = built_with( "ts-aarch64-apple-darwin", - Role::A, + Role::Canonical, b"fictional native executable", true, ); @@ -484,7 +484,7 @@ fn derived_manifest_digests_are_carried_into_the_completion() { fn candidate_measurements_report_no_release_qualification() { let asset = "trusted-server-fastly.wasm"; let kept = wasm_with(&[(".debug_info", b"i"), (".debug_line", b"l")]); - let mut built = built_with(asset, Role::A, &kept, true); + let mut built = built_with(asset, Role::Canonical, &kept, true); let stdout = built.stdout(|_| {}); let completed = built.finish(stdout).expect("should complete the run"); @@ -498,7 +498,7 @@ fn candidate_measurements_report_no_release_qualification() { "should never claim release qualification" ); assert_eq!(measurement["asset"], asset, "should name the asset"); - assert_eq!(measurement["role"], "A", "should name the role"); + assert_eq!(measurement["role"], "canonical", "should name the role"); assert_eq!( measurement["source_commit"], COMMIT, "should bind the commit" diff --git a/crates/trusted-server-release/src/recipe/tests/fixture.rs b/crates/trusted-server-release/src/recipe/tests/fixture.rs index ab7661e2bf..9090349426 100644 --- a/crates/trusted-server-release/src/recipe/tests/fixture.rs +++ b/crates/trusted-server-release/src/recipe/tests/fixture.rs @@ -43,8 +43,8 @@ impl Fixture { "materials/vendor/example-1.0.0", "cargo-state", "output", - "checkout-a", - "checkout-b", + "checkout-canonical", + "checkout-reproduction", "tools/bin", "other-source", "other-work", @@ -118,20 +118,20 @@ impl Fixture { pub(super) fn ambient(&self, role: Role) -> CheckedAmbient { match role { - Role::A => CheckedAmbient::check( - Role::A, - &AmbientProfile::A, + Role::Canonical => CheckedAmbient::check( + Role::Canonical, + &AmbientProfile::CANONICAL, ObservedAmbient { - checkout_root: self.base.join("checkout-a"), - ..observed_a() + checkout_root: self.base.join("checkout-canonical"), + ..observed_canonical() }, ), - Role::B => CheckedAmbient::check( - Role::B, + Role::Reproduction => CheckedAmbient::check( + Role::Reproduction, &AmbientProfile::alternate_for_tests(), ObservedAmbient { - checkout_root: self.base.join("checkout-b"), - ..observed_b() + checkout_root: self.base.join("checkout-reproduction"), + ..observed_reproduction() }, ), } @@ -188,16 +188,16 @@ impl Fixture { asset: &str, role: Role, ) -> Result> { - let text = if role == Role::A { "A" } else { "B" }; + let text = role.spec_label(); self.bind_with(self.request(asset, text, COMMIT), role, COMMIT) } /// The fake namespace mirroring the boundary's mount layout for `role`. pub(super) fn paths(&self, role: Role) -> PrefixPaths { - let checkout = if role == Role::A { - "checkout-a" + let checkout = if role == Role::Canonical { + "checkout-canonical" } else { - "checkout-b" + "checkout-reproduction" }; PrefixPaths::new() .with(&self.base.join(checkout), "/boundary/immutable/0", false) diff --git a/crates/trusted-server-release/src/recipe/tests/full.rs b/crates/trusted-server-release/src/recipe/tests/full.rs index 35466086a7..f5bd54fee1 100644 --- a/crates/trusted-server-release/src/recipe/tests/full.rs +++ b/crates/trusted-server-release/src/recipe/tests/full.rs @@ -54,14 +54,17 @@ fn minimal_environment() -> AuditedEnvironment { /// Conditions of an ordinary CI build of `role`. /// /// The umask and jobs are the profile values the plan enforces on the child. -/// Role B uses the test-only alternate profile: its host locale and timezone +/// The reproduction build uses the test-only alternate profile: its host locale and timezone /// are profile labels, while the child runs with the normalized `C` locale /// and `UTC` timezone either way. This is not an independent qualified -/// observation, and production role B stays unqualified. +/// observation, and the production reproduction build stays unqualified. fn ordinary_ci_ambient(role: Role, checkout: &Path) -> CheckedAmbient { let (profile, base) = match role { - Role::A => (AmbientProfile::A, observed_a()), - Role::B => (AmbientProfile::alternate_for_tests(), observed_b()), + Role::Canonical => (AmbientProfile::CANONICAL, observed_canonical()), + Role::Reproduction => ( + AmbientProfile::alternate_for_tests(), + observed_reproduction(), + ), }; // Reading the real user ID has no preconditions. let execution_uid = unsafe { libc::getuid() }; @@ -173,7 +176,7 @@ fn bind_denial_case(fixture: &CargoFixture, member: &str) -> DenialCase { &RecipeInput { asset: member, variant: "default", - role: "A", + role: Role::Canonical.spec_label(), commit: &fixture.source_commit, js_dir: &retained.join("bundles"), js_inventory: &retained.join("tsjs-inventory.json"), @@ -200,7 +203,7 @@ fn bind_denial_case(fixture: &CargoFixture, member: &str) -> DenialCase { .expect("should select the ordinary CI Rust tools"), materials: PreparedCargoMaterials::for_tests(&base.join("materials"), commit.as_str(), &[]) .expect("should describe the empty vendor directory"), - ambient: ordinary_ci_ambient(Role::A, &checkout), + ambient: ordinary_ci_ambient(Role::Canonical, &checkout), request, }) .expect("should bind the denial case"); @@ -380,10 +383,7 @@ fn build_fastly( let request = RecipeRequest::parse(&RecipeInput { asset: FASTLY_ASSET, variant: "default", - role: match role { - Role::A => "A", - Role::B => "B", - }, + role: role.spec_label(), commit: &fixture.source_commit, js_dir: &retained.join("bundles"), js_inventory: &retained.join("tsjs-inventory.json"), @@ -474,7 +474,7 @@ fn scan_final_bytes(fixture: &CargoFixture, builds: [&FastlyBuild; 2]) -> RemapS #[test] #[ignore = "requires explicit JS and Cargo ordinary CI fixtures and Linux Docker; run separately in producer CI"] -fn fastly_role_a_and_b_builds_are_byte_identical() { +fn fastly_canonical_and_reproduction_builds_are_byte_identical() { let fixture = cargo_fixture(); let js = run_two_fresh_js_builds(); assert_eq!( @@ -488,14 +488,18 @@ fn fastly_role_a_and_b_builds_are_byte_identical() { ); let cancellation = Cancellation::default(); let deadline = Instant::now() + Duration::from_secs(40 * 60); - let a = build_fastly(&js, &fixture, Role::A, deadline, &cancellation); - let b = build_fastly(&js, &fixture, Role::B, deadline, &cancellation); - let checked = check_fastly_equality(&a.completed, &b.completed).unwrap_or_else(|error| { - panic!("should build byte-identical Fastly A/B outputs: {error:?}") - }); - let scan = scan_final_bytes(&fixture, [&a, &b]); - let mut measurement = equality_measurement(&a.completed, &b.completed, &checked); - for (role, build) in [("A", &a), ("B", &b)] { + let canonical = build_fastly(&js, &fixture, Role::Canonical, deadline, &cancellation); + let reproduction = build_fastly(&js, &fixture, Role::Reproduction, deadline, &cancellation); + let checked = check_fastly_equality(&canonical.completed, &reproduction.completed) + .unwrap_or_else(|error| { + panic!( + "should build byte-identical canonical and reproduction Fastly outputs: {error:?}" + ) + }); + let scan = scan_final_bytes(&fixture, [&canonical, &reproduction]); + let mut measurement = + equality_measurement(&canonical.completed, &reproduction.completed, &checked); + for (role, build) in [("canonical", &canonical), ("reproduction", &reproduction)] { // Container paths are identical, so checkout variation is host-only on Linux. measurement["builds"][role]["ambient"]["execution_checkout_root"] = serde_json::json!(build.execution_checkout); @@ -507,8 +511,10 @@ fn fastly_role_a_and_b_builds_are_byte_identical() { let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); println!("{text}"); fs::write( - fixture.root.join("cargo-ab-equality-measurement.json"), + fixture + .root + .join("cargo-canonical-reproduction-equality-measurement.json"), text, ) - .expect("should retain the A/B equality measurement"); + .expect("should retain the canonical/reproduction equality measurement"); } diff --git a/crates/trusted-server-release/src/recipe/tests/plan.rs b/crates/trusted-server-release/src/recipe/tests/plan.rs index 6ad108d122..d258a338b2 100644 --- a/crates/trusted-server-release/src/recipe/tests/plan.rs +++ b/crates/trusted-server-release/src/recipe/tests/plan.rs @@ -49,8 +49,12 @@ fn cargo_plan_has_fixed_release_frozen_json_flags_and_exact_source_replacement() "should select a nonzero number of assets" ); for (index, artifact) in FIXED_ARTIFACTS.iter().enumerate() { - let role = if index % 2 == 0 { Role::A } else { Role::B }; - let jobs = if role == Role::A { "4" } else { "2" }; + let role = if index % 2 == 0 { + Role::Canonical + } else { + Role::Reproduction + }; + let jobs = if role == Role::Canonical { "4" } else { "2" }; let fixture = Fixture::new(COMMIT); let prepared = fixture .bind(artifact.name, role) @@ -89,10 +93,10 @@ fn cargo_plan_has_fixed_release_frozen_json_flags_and_exact_source_replacement() fixture.base.join("tools/bin/cargo"), "should run the selected Cargo by its host path" ); - let checkout = if role == Role::A { - "checkout-a" + let checkout = if role == Role::Canonical { + "checkout-canonical" } else { - "checkout-b" + "checkout-reproduction" }; assert_eq!( plan.working_directory, @@ -192,7 +196,7 @@ fn granular(fixture: &Fixture, omit: &[&str]) -> PrefixPaths { ), ( "checkout", - fixture.base.join("checkout-a"), + fixture.base.join("checkout-canonical"), "/c/checkout", false, ), @@ -208,7 +212,7 @@ fn granular(fixture: &Fixture, omit: &[&str]) -> PrefixPaths { fn path_variables_outside_every_root_are_rejected() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); prepared .plan(&granular(&fixture, &[])) @@ -242,7 +246,11 @@ fn path_variables_outside_every_root_are_rejected() { #[test] fn child_home_and_cargo_home_must_be_the_request_state() { let fixture = Fixture::new(COMMIT); - let request = fixture.request("ts-aarch64-apple-darwin", "A", COMMIT); + let request = fixture.request( + "ts-aarch64-apple-darwin", + Role::Canonical.spec_label(), + COMMIT, + ); let commit = CommitId::parse(COMMIT).expect("should parse commit"); let mut forged = child_request(&request); forged.home = fixture.base.join("other-work/home"); @@ -255,7 +263,7 @@ fn child_home_and_cargo_home_must_be_the_request_state() { completed: &fixture.completed, tools: fixture.tools_for(ReleaseTarget::Aarch64AppleDarwin), materials: fixture.materials(COMMIT), - ambient: fixture.ambient(Role::A), + ambient: fixture.ambient(Role::Canonical), request, }); assert!( @@ -286,10 +294,10 @@ fn cargo_plan_rejects_inherited_rustflags_and_selectors() { } let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-x86_64-unknown-linux-gnu", Role::A) + .bind("ts-x86_64-unknown-linux-gnu", Role::Canonical) .expect("should bind from a benign environment"); let plan = prepared - .plan(&fixture.paths(Role::A)) + .plan(&fixture.paths(Role::Canonical)) .expect("should derive the plan"); for name in plan.environment.keys() { assert!( @@ -344,9 +352,9 @@ fn cargo_plan_rejects_inherited_rustflags_and_selectors() { fn plan_digest_binds_every_part_of_the_plan() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); - let paths = fixture.paths(Role::A); + let paths = fixture.paths(Role::Canonical); let plan = prepared.plan(&paths).expect("should derive the plan"); let digest = plan.digest().expect("should digest the plan"); assert_eq!(digest.len(), 64, "should be a SHA-256 hex digest"); @@ -386,7 +394,7 @@ fn plan_digest_binds_every_part_of_the_plan() { ); } let mut moved = prepared.plan(&paths).expect("should derive the plan"); - moved.working_directory = fixture.base.join("checkout-b"); + moved.working_directory = fixture.base.join("checkout-reproduction"); assert_ne!( moved.digest().expect("should digest the moved plan"), digest, @@ -396,7 +404,7 @@ fn plan_digest_binds_every_part_of_the_plan() { #[test] fn plan_carries_the_role_umask() { - for (role, expected) in [(Role::A, 0o022), (Role::B, 0o027)] { + for (role, expected) in [(Role::Canonical, 0o022), (Role::Reproduction, 0o027)] { let fixture = Fixture::new(COMMIT); let prepared = fixture .bind("ts-aarch64-apple-darwin", role) @@ -414,7 +422,7 @@ fn plan_carries_the_role_umask() { #[test] fn confined_command_applies_the_role_umask_and_keeps_host_program_paths() { - for (role, expected) in [(Role::A, "022"), (Role::B, "027")] { + for (role, expected) in [(Role::Canonical, "022"), (Role::Reproduction, "027")] { let fixture = Fixture::new(COMMIT); let prepared = fixture .bind("ts-aarch64-apple-darwin", role) @@ -459,7 +467,7 @@ fn real_boundary_translates_every_plan_path() { use crate::boundary::Boundary; let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); let roots = prepared.boundary_roots().expect("should derive roots"); let boundary = Boundary::mac_sandbox_for_tests(roots).expect("should configure, not run"); @@ -487,7 +495,7 @@ fn real_boundary_translates_every_plan_path() { fn execution_paths_with_control_characters_or_separators_are_rejected() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); for bad in [ "/c/bin\nx", diff --git a/crates/trusted-server-release/src/recipe/tests/remap.rs b/crates/trusted-server-release/src/recipe/tests/remap.rs index 2cb4a39974..b1b29cda96 100644 --- a/crates/trusted-server-release/src/recipe/tests/remap.rs +++ b/crates/trusted-server-release/src/recipe/tests/remap.rs @@ -84,7 +84,7 @@ fn nested_or_equal_remapped_roots_are_rejected() { fn plan_rejects_remapped_roots_that_overlap_in_the_execution_namespace() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("trusted-server-fastly.wasm", Role::A) + .bind("trusted-server-fastly.wasm", Role::Canonical) .expect("should bind the recipe"); let nested = PrefixPaths::new() .with( @@ -93,7 +93,7 @@ fn plan_rejects_remapped_roots_that_overlap_in_the_execution_namespace() { false, ) .with( - &fixture.base.join("checkout-a"), + &fixture.base.join("checkout-canonical"), "/boundary/immutable/0", false, ) @@ -114,27 +114,27 @@ fn plan_rejects_remapped_roots_that_overlap_in_the_execution_namespace() { #[test] fn remap_is_identical_for_both_roles_in_the_same_mount_layout() { let fixture = Fixture::new(COMMIT); - let a = fixture - .bind("trusted-server-fastly.wasm", Role::A) - .expect("should bind role A") - .plan(&fixture.paths(Role::A)) - .expect("should plan A"); + let canonical = fixture + .bind("trusted-server-fastly.wasm", Role::Canonical) + .expect("should bind the canonical build") + .plan(&fixture.paths(Role::Canonical)) + .expect("should plan the canonical build"); let other = Fixture::new(COMMIT); - let b = other - .bind("trusted-server-fastly.wasm", Role::B) - .expect("should bind role B") - .plan(&other.paths(Role::B)) - .expect("should plan B"); + let reproduction = other + .bind("trusted-server-fastly.wasm", Role::Reproduction) + .expect("should bind the reproduction build") + .plan(&other.paths(Role::Reproduction)) + .expect("should plan the reproduction build"); assert_ne!( - a.working_directory, b.working_directory, + canonical.working_directory, reproduction.working_directory, "should run from distinct host checkouts" ); assert_eq!( - remap_config(&a), - remap_config(&b), + remap_config(&canonical), + remap_config(&reproduction), "should hand both roles the same remap in the same execution layout" ); - for plan in [&a, &b] { + for plan in [&canonical, &reproduction] { assert_eq!( ( plan.environment.get("LC_ALL").map(String::as_str), @@ -150,7 +150,7 @@ fn remap_is_identical_for_both_roles_in_the_same_mount_layout() { fn remap_follows_identity_paths_to_the_same_fixed_prefixes() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("trusted-server-fastly.wasm", Role::A) + .bind("trusted-server-fastly.wasm", Role::Canonical) .expect("should bind the recipe"); let base = fixture.base.to_str().expect("should be UTF-8"); let retained = fixture.retained(); @@ -166,7 +166,7 @@ fn remap_follows_identity_paths_to_the_same_fixed_prefixes() { remap_config(&plan), format!( "target.\"wasm32-wasip1\".rustflags=[\ - \"--remap-path-prefix={base}/checkout-a=/trusted-server\",\ + \"--remap-path-prefix={base}/checkout-canonical=/trusted-server\",\ \"--remap-path-prefix={base}/materials/vendor=/cargo-vendor\",\ \"--remap-path-prefix={base}/cargo-state/cargo-home=/cargo-home\",\ \"--remap-path-prefix={base}/tools=/rust-tools\",\ diff --git a/crates/trusted-server-release/src/recipe/tests/tools.rs b/crates/trusted-server-release/src/recipe/tests/tools.rs index 0a9c1d8f41..69cb98b3ad 100644 --- a/crates/trusted-server-release/src/recipe/tests/tools.rs +++ b/crates/trusted-server-release/src/recipe/tests/tools.rs @@ -161,7 +161,7 @@ fn tool_digest_detects_edits_hard_links_and_escaping_aliases() { fn tools_edited_after_binding_are_rejected_at_the_execute_gate() { let fixture = Fixture::new(COMMIT); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); prepared .check_tools() @@ -207,7 +207,7 @@ fn handoff_edits_and_cargo_home_caches_are_rejected_around_the_run() { let fixture = Fixture::new(COMMIT); let retained = fixture.retained(); let prepared = fixture - .bind("ts-aarch64-apple-darwin", Role::A) + .bind("ts-aarch64-apple-darwin", Role::Canonical) .expect("should bind the recipe"); prepared .check_inputs() diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 9eb0a940b1..5adbc98319 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -324,7 +324,7 @@ Implementation sequencing: review the local validation framework and candidate b - [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture MUST NOT invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. - [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. -Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. The plan now remaps the checkout, vendor, Cargo home, Rust tool (sysroot) and target roots to fixed prefixes through one `--config target..rustflags` entry, which merges after reviewed target rustflags (environment rustflags stay rejected, a tracked `build.rustflags`, which target rustflags would supersede, is refused, as is any extensionless `.cargo/config`). The remapped roots must be pairwise disjoint, because rustc applies the last matching prefix, and the remap is bound by the plan digest. On Linux both roles see identical container paths (`/boundary/...`), so the checkout-root variation is host-only; the remap is proven on the final Fastly bytes by a scan that rejects `/boundary/` and every host build root and requires a fixed remapped prefix. Literals built from `env!("OUT_DIR")` or `env!("CARGO_MANIFEST_DIR")` are not remapped by rustc and are caught by the same scan. A pure comparison requires one role A and one role B Fastly build over identical source, lock, materials, JS handoff and tools, an honest ambient pairing and complete final-byte equality, reporting sizes, digests, the first differing offset and its Wasm section otherwise. The ordinary CI job now builds roles A (umask 022, four jobs) and B (test-only alternate profile: umask 027, two jobs, distinct checkout root and start time) from one JS handoff and compares them; that evidence is pending its first CI run. The `ts` executables, Viceroy final-byte acceptance and qualified pins remain open, and production role B stays unqualified. macOS boundaries stay rejected by the execute gate until Task 8 evidence. +Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. The plan now remaps the checkout, vendor, Cargo home, Rust tool (sysroot) and target roots to fixed prefixes through one `--config target..rustflags` entry, which merges after reviewed target rustflags (environment rustflags stay rejected, a tracked `build.rustflags`, which target rustflags would supersede, is refused, as is any extensionless `.cargo/config`). The remapped roots must be pairwise disjoint, because rustc applies the last matching prefix, and the remap is bound by the plan digest. On Linux both roles see identical container paths (`/boundary/...`), so the checkout-root variation is host-only; the remap is proven on the final Fastly bytes by a scan that rejects `/boundary/` and every host build root and requires a fixed remapped prefix. Literals built from `env!("OUT_DIR")` or `env!("CARGO_MANIFEST_DIR")` are not remapped by rustc and are caught by the same scan. A pure comparison requires one role A and one role B Fastly build over identical source, lock, materials, JS handoff and tools, an honest ambient pairing and complete final-byte equality, reporting sizes, digests, the first differing offset and its Wasm section otherwise. The ordinary CI job now builds roles A (umask 022, four jobs) and B (test-only alternate profile: umask 027, two jobs, distinct checkout root and start time) from one JS handoff and compares them; that evidence is pending its first CI run. The `ts` executables, Viceroy final-byte acceptance and qualified pins remain open, and production role B stays unqualified. macOS boundaries stay rejected by the execute gate until Task 8 evidence. In code, role A is `Role::Canonical` (Build A) and role B is `Role::Reproduction` (Build B); only `Role::parse` and `Role::spec_label` keep the external `A`/`B` spelling used by `--role` and the tracked `ambient_profiles` keys. ### Task 10: Record selected Cargo units and native materials From f0511dc8d32e681166df2db5de901c826b4edf61 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 11:54:25 +0530 Subject: [PATCH 074/153] Record Fastly canonical and reproduction byte equality evidence Ordinary PR CI built trusted-server-fastly.wasm as the canonical and the test-only reproduction build from one JS handoff and found them byte-identical, with a final-byte scan proving the path remapping. Record both runs and update the Task 9 progress note; the evidence remains unqualified and no Task 9 exit is claimed. --- ...6-10-13-attested-release-implementation.md | 2 +- ...-fastly-canonical-reproduction-equality.md | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md diff --git a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md index 5adbc98319..70e8e4aa9e 100644 --- a/docs/superpowers/plans/2026-10-13-attested-release-implementation.md +++ b/docs/superpowers/plans/2026-10-13-attested-release-implementation.md @@ -324,7 +324,7 @@ Implementation sequencing: review the local validation framework and candidate b - [ ] Before accepting each final Fastly A/B output, validate actual core-Wasm format with a pinned maintained validator and run a positive publisher-origin response/injection fixture through Viceroy using those exact final bytes. The fixture MUST NOT invoke Cargo/Fastly build or modify the wasm. Assert final hash unchanged, retain runtime outcome, and test non-wasm/component/wrong-ABI/health-only success rejection. These are required producer gates, separate from Task 8 throwaway qualification and later live deployment. - [ ] Run recipe/native negatives and one actual artifact per selected environment. Commit `Build and inspect canonical release executables`. -Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. The plan now remaps the checkout, vendor, Cargo home, Rust tool (sysroot) and target roots to fixed prefixes through one `--config target..rustflags` entry, which merges after reviewed target rustflags (environment rustflags stay rejected, a tracked `build.rustflags`, which target rustflags would supersede, is refused, as is any extensionless `.cargo/config`). The remapped roots must be pairwise disjoint, because rustc applies the last matching prefix, and the remap is bound by the plan digest. On Linux both roles see identical container paths (`/boundary/...`), so the checkout-root variation is host-only; the remap is proven on the final Fastly bytes by a scan that rejects `/boundary/` and every host build root and requires a fixed remapped prefix. Literals built from `env!("OUT_DIR")` or `env!("CARGO_MANIFEST_DIR")` are not remapped by rustc and are caught by the same scan. A pure comparison requires one role A and one role B Fastly build over identical source, lock, materials, JS handoff and tools, an honest ambient pairing and complete final-byte equality, reporting sizes, digests, the first differing offset and its Wasm section otherwise. The ordinary CI job now builds roles A (umask 022, four jobs) and B (test-only alternate profile: umask 027, two jobs, distinct checkout root and start time) from one JS handoff and compares them; that evidence is pending its first CI run. The `ts` executables, Viceroy final-byte acceptance and qualified pins remain open, and production role B stays unqualified. macOS boundaries stay rejected by the execute gate until Task 8 evidence. In code, role A is `Role::Canonical` (Build A) and role B is `Role::Reproduction` (Build B); only `Role::parse` and `Role::spec_label` keep the external `A`/`B` spelling used by `--role` and the tracked `ambient_profiles` keys. +Progress (2026-10-16, no Task 9 exit yet): the closed recipe, checked A/B ambient profiles, role umasks, path translation, source replacement, strict Cargo message selection and `RecipeCompleted` are implemented. Ordinary PR CI built `trusted-server-fastly.wasm` role A through `PreparedRecipe::execute` inside the Linux denial boundary and proved build-script, grandchild and procedural-macro denial; see the [confined Cargo candidate report](../../../scripts/release/qualification/2026-10-16-confined-cargo-candidate.md). That build used unauthenticated acquisition and `cargo vendor` manifests, so it is marked unqualified. The plan now remaps the checkout, vendor, Cargo home, Rust tool (sysroot) and target roots to fixed prefixes through one `--config target..rustflags` entry, which merges after reviewed target rustflags (environment rustflags stay rejected, a tracked `build.rustflags`, which target rustflags would supersede, is refused, as is any extensionless `.cargo/config`). The remapped roots must be pairwise disjoint, because rustc applies the last matching prefix, and the remap is bound by the plan digest. On Linux both roles see identical container paths (`/boundary/...`), so the checkout-root variation is host-only; the remap is proven on the final Fastly bytes by a scan that rejects `/boundary/` and every host build root and requires a fixed remapped prefix. Literals built from `env!("OUT_DIR")` or `env!("CARGO_MANIFEST_DIR")` are not remapped by rustc and are caught by the same scan. A pure comparison requires one role A and one role B Fastly build over identical source, lock, materials, JS handoff and tools, an honest ambient pairing and complete final-byte equality, reporting sizes, digests, the first differing offset and its Wasm section otherwise. The ordinary CI job now builds roles A (umask 022, four jobs) and B (test-only alternate profile: umask 027, two jobs, distinct checkout root and start time) from one JS handoff and compares them. Both builds were byte-identical in ordinary PR CI, and the scan found the remapped prefixes and no host or container paths; see the [canonical and reproduction equality report](../../../scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md). The `ts` executables, Viceroy final-byte acceptance and qualified pins remain open, and production role B stays unqualified. macOS boundaries stay rejected by the execute gate until Task 8 evidence. In code, role A is `Role::Canonical` (Build A) and role B is `Role::Reproduction` (Build B); only `Role::parse` and `Role::spec_label` keep the external `A`/`B` spelling used by `--role` and the tracked `ambient_profiles` keys. ### Task 10: Record selected Cargo units and native materials diff --git a/scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md b/scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md new file mode 100644 index 0000000000..afa9c00791 --- /dev/null +++ b/scripts/release/qualification/2026-10-16-fastly-canonical-reproduction-equality.md @@ -0,0 +1,60 @@ +# Task 9: Fastly canonical and reproduction builds are byte-identical + +Status: on 2026-10-16 ordinary PR CI built `trusted-server-fastly.wasm` twice +through `PreparedRecipe::execute`, as the canonical build (`Role::Canonical`, +the spec's Build A) and as the reproduction build (`Role::Reproduction`, Build +B), from one JS handoff and in separate fresh roots and denial boundaries. The +final bytes were identical, and a scan of those bytes proved that the path +remapping took effect. This is candidate evidence from an ordinary CI runner +and records `release_qualification: false`. The reproduction profile comes from +a test-only alternate; the production reproduction profile, the toolchain, the +image, the acquired crates and the `cargo vendor` Git manifests remain +unqualified. + +## Runs + +| Run | Source (PR merge commit) | Final SHA-256 | Size (bytes) | Test duration | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------ | ------------ | ------------- | +| [Primary, after the role rename](https://github.com/IABTechLab/trusted-server/actions/runs/38856856376/job/116830159922) | `4243e3a1c16bf1212ab585eecce8592bd5137950` | `a17a77cc5119384d2fb5f74d83cb76cbf264c768e508b8545cf8537db481f178` | 66,833,859 | 1,119.36 s | +| [Earlier, before the rename](https://github.com/IABTechLab/trusted-server/actions/runs/38855948935/job/116827324356) | `373463048c22237ba6e045e7fd0706bb4a104568` | `e5390f47e025f34b70190c944134d6e2b7209e792e8a4ec2d64c65ead594ca4b` | 66,833,943 | 1,064.12 s | + +Each run built both roles from the same commit and matched. The two runs +differ from each other because the source commit is embedded in the binary. +The earlier run's measurement still labels the roles `A` and `B`. Test +durations include the full two-fresh-installation JS acceptance (1,275 tests +per side, 13 byte-identical modules) that produces the shared JS handoff. Both +runs also repeated the build-script, grandchild and procedural-macro denial +test. + +## What varied + +| Dimension | Canonical | Reproduction | +| ---------------- | ------------------------------- | ------------------------------- | +| umask | 022 | 027 | +| Cargo jobs | 4 | 2 | +| Host checkout | separate fresh temporary copy | separate fresh temporary copy | +| Start time | recorded per run | recorded per run | +| Locale, timezone | normalized to `C`, `UTC` | normalized to `C`, `UTC` | +| Execution user | same CI user (unsupported here) | same CI user (unsupported here) | + +In the primary run the confined builds took 137 and 171 seconds. Inside the +Linux container both roles see every root at the same `/boundary//` +path, so the checkout-root difference is a host-only variation there. + +## Remapping proof + +The build remaps the checkout, vendor tree, Cargo home, Rust tool root and +target directory to fixed prefixes through one quoted +`--config target."".rustflags` entry, which Cargo appends to tracked +target rustflags. Because container paths match across roles on Linux, byte +equality alone would not prove the remapping. The test therefore scanned the +compared final bytes: they contained none of `/boundary/`, the host build +roots, the fixture root or `RUNNER_TEMP`, and they did contain the remapped +`/trusted-server` and `/cargo-vendor` prefixes. + +## Not covered + +The three `ts` executables, Viceroy final-byte acceptance, producer-owned Git +manifest normalization, the production reproduction profile, authenticated +acquisition, qualified tool and image pins, and hosted qualification remain +open under Tasks 8 and 9. From 4cc5700e834c3238f99a81b3a567edb634fecfe1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 16 Oct 2026 14:13:08 +0530 Subject: [PATCH 075/153] Gate final Fastly bytes on Wasm validation and a Viceroy request Before accepting a final Fastly artifact, fully validate it as a core Wasm command with the pinned wasmparser: no components, only Fastly import modules, a no-argument `_start`, an exported memory, the HTTP request and response modules and debug sections. Then serve the exact verified bytes, copied read-only and rehashed, with the explicitly selected and version-pinned Viceroy against a loopback publisher origin and the integration harness's config fixtures, which must match the built source. Health only gates readiness: acceptance needs a 200 HTML page carrying this run's origin marker once and one injected tsjs script, an observed origin request, no guest failure in Viceroy's output and a gate-initiated stop, and the artifact rehashes unchanged afterwards. Responses are parsed strictly and loopback only. The ordinary-CI canonical and reproduction test now runs this gate on both outputs and rejects a closed-origin run as health-only success, printing a measurement with release_qualification false. --- .github/workflows/test.yml | 33 +- crates/trusted-server-release/src/error.rs | 2 + crates/trusted-server-release/src/inspect.rs | 94 ++- .../src/inspect/tests.rs | 2 +- .../src/inspect/tests/builders.rs | 63 +- .../src/inspect/tests/wasm.rs | 104 ++- .../src/inspect/wasm.rs | 45 +- crates/trusted-server-release/src/recipe.rs | 1 + .../src/recipe/compare.rs | 2 +- .../src/recipe/runtime.rs | 586 +++++++++++++++++ .../src/recipe/runtime/config.rs | 340 ++++++++++ .../src/recipe/runtime/exchange.rs | 461 +++++++++++++ .../src/recipe/runtime/logs.rs | 98 +++ .../src/recipe/runtime/viceroy.rs | 160 +++++ .../src/recipe/tests.rs | 1 + .../src/recipe/tests/candidate.rs | 2 +- .../src/recipe/tests/full.rs | 123 +++- .../src/recipe/tests/runtime.rs | 619 ++++++++++++++++++ 18 files changed, 2718 insertions(+), 18 deletions(-) create mode 100644 crates/trusted-server-release/src/recipe/runtime.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/config.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/exchange.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/logs.rs create mode 100644 crates/trusted-server-release/src/recipe/runtime/viceroy.rs create mode 100644 crates/trusted-server-release/src/recipe/tests/runtime.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2436ad3e81..519356dd80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,8 +397,9 @@ jobs: runs-on: ubuntu-24.04 # Fixture preparation, two fresh JS builds (~12 min), then two confined # Fastly builds each with its own vendoring (~3-4 min each) and the - # final-byte comparison: 45 minutes left too little headroom. - timeout-minutes: 60 + # final-byte comparison: 45 minutes left too little headroom. The + # uncached Viceroy install (~5-8 min) and three gate runs add to that. + timeout-minutes: 75 env: # Optimize repeated whole-input hashes with test safety checks retained. CARGO_PROFILE_TEST_OPT_LEVEL: '2' @@ -417,8 +418,11 @@ jobs: shell: bash run: | set -euo pipefail - echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" - echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + { + echo "rust=$(awk '$1 == "rust" { print $2 }' .tool-versions)" + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" + echo "viceroy=$(awk '$1 == "viceroy" { print $2 }' .tool-versions)" + } >> "$GITHUB_OUTPUT" - name: Use pinned Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 (Node 24) @@ -441,6 +445,21 @@ jobs: test -n "$HOST_TARGET" echo "HOST_TARGET=$HOST_TARGET" >> "$GITHUB_ENV" + # Same pinned source install as the other Viceroy jobs, but uncached and + # into a private root: the runtime gate selects this exact absolute path, + # checks its reported version and records its digest. + - name: Install pinned Viceroy for the final-byte runtime gate + env: + VICEROY_VERSION: ${{ steps.tools.outputs.viceroy }} + shell: bash + run: | + set -euo pipefail + test -n "$VICEROY_VERSION" + VICEROY_ROOT="$RUNNER_TEMP/release-viceroy" + cargo install viceroy --version "$VICEROY_VERSION" --locked --root "$VICEROY_ROOT" + test "$("$VICEROY_ROOT/bin/viceroy" --version)" = "viceroy $VICEROY_VERSION" + echo "TS_RELEASE_VICEROY=$VICEROY_ROOT/bin/viceroy" >> "$GITHUB_ENV" + - name: Prepare ordinary CI JS and Cargo fixtures shell: bash run: | @@ -469,8 +488,9 @@ jobs: # The canonical and reproduction builds use the same JS handoff in # separate fresh roots and boundaries; the final Fastly files must be - # byte-identical. - - name: Build canonical and reproduction Fastly from one JS handoff and compare final bytes (Linux) + # byte-identical. Both outputs then pass the final-byte Viceroy runtime + # gate, and a closed-origin run must be rejected despite a healthy probe. + - name: Build canonical and reproduction Fastly from one JS handoff, compare final bytes and run the Viceroy gate (Linux) shell: bash run: | set -euo pipefail @@ -481,6 +501,7 @@ jobs: awk '/: test$/ { count++ } END { if (count != 1) { print "Expected exactly one Fastly canonical/reproduction equality fixture"; exit 1 } }' "$TEST_LIST" cargo test -p trusted-server-release --target "$HOST_TARGET" --locked "$TEST_NAME" -- --ignored --exact --nocapture cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/cargo-canonical-reproduction-equality-measurement.json" + cat "$TS_RELEASE_CARGO_FIXTURE_ROOT/fastly-runtime-gate-measurement.json" test-typescript: name: vitest diff --git a/crates/trusted-server-release/src/error.rs b/crates/trusted-server-release/src/error.rs index e785e6ac6e..3fd445fd9e 100644 --- a/crates/trusted-server-release/src/error.rs +++ b/crates/trusted-server-release/src/error.rs @@ -30,6 +30,8 @@ pub(crate) enum ReleaseError { Boundary, #[display("Release recipe inputs or execution plan are rejected")] Recipe, + #[display("Final Fastly runtime acceptance rejected the executable")] + Runtime, } impl core::error::Error for ReleaseError {} diff --git a/crates/trusted-server-release/src/inspect.rs b/crates/trusted-server-release/src/inspect.rs index afa62ecb2a..36fd153dfc 100644 --- a/crates/trusted-server-release/src/inspect.rs +++ b/crates/trusted-server-release/src/inspect.rs @@ -110,6 +110,10 @@ pub(crate) enum Violation { /// was stripped. #[display("WebAssembly module lacks a required nonempty debug section")] WasmDebugInfo, + /// The module is not a Fastly Compute WASI command: `_start` or `memory` + /// is missing or mistyped, or the downstream request ABI is not imported. + #[display("WebAssembly module lacks the Fastly Compute entry ABI")] + WasmAbi, } /// Builds the inspection rejection carrying `violation` and a detail line. @@ -372,6 +376,36 @@ impl WasmCustomSection { } } +/// The kind of one core Wasm export, with a function's arity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WasmExportKind { + /// A function taking `params` values and returning `results` values. + Function { params: usize, results: usize }, + /// A linear memory. + Memory, + /// A table, global or tag. + Other, +} + +/// One export observed in a core Wasm module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WasmExport { + name: String, + kind: WasmExportKind, +} + +impl WasmExport { + /// Export name as encoded in the module. + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Kind of the exported item. + pub(crate) fn kind(&self) -> WasmExportKind { + self.kind + } +} + /// Facts observed in an accepted core Wasm module. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WasmFacts { @@ -379,12 +413,70 @@ pub(crate) struct WasmFacts { import_modules: Vec, function_imports: usize, custom_sections: Vec, + exports: Vec, } /// DWARF sections whose presence proves the module was not stripped. const REQUIRED_DEBUG_SECTIONS: [&str; 2] = [".debug_info", ".debug_line"]; +/// Hostcall modules a Fastly program must import to receive the downstream +/// request and send its response. +const REQUIRED_FASTLY_IMPORT_MODULES: [&str; 2] = ["fastly_http_req", "fastly_http_resp"]; + impl WasmFacts { + /// Exports in module order. + pub(crate) fn exports(&self) -> &[WasmExport] { + &self.exports + } + + /// Requires the Fastly Compute WASI command entry ABI. + /// + /// The module must export a `_start` function taking and returning + /// nothing and a `memory`, and import the downstream request and response + /// hostcall modules. Import modules outside the reviewed host ABI were + /// already rejected by [`inspect_wasm`]. + /// + /// # Errors + /// + /// Returns [`Violation::WasmAbi`] naming the first missing or mistyped + /// requirement. + pub(crate) fn require_fastly_entry_abi(&self) -> Result<(), Report> { + let kind = |name: &str| { + self.exports + .iter() + .find(|export| export.name == name) + .map(|export| export.kind) + }; + match kind("_start") { + Some(WasmExportKind::Function { + params: 0, + results: 0, + }) => {} + other => { + return Err(rejected( + Violation::WasmAbi, + format!("export _start must be a nullary function, found {other:?}"), + )); + } + } + if kind("memory") != Some(WasmExportKind::Memory) { + return Err(rejected( + Violation::WasmAbi, + "export memory must be a linear memory", + )); + } + if let Some(missing) = REQUIRED_FASTLY_IMPORT_MODULES + .iter() + .find(|module| !self.import_modules.iter().any(|seen| seen == *module)) + { + return Err(rejected( + Violation::WasmAbi, + format!("missing required import module {missing}"), + )); + } + Ok(()) + } + /// Custom sections in file order. pub(crate) fn custom_sections(&self) -> &[WasmCustomSection] { &self.custom_sections @@ -429,4 +521,4 @@ impl WasmFacts { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/crates/trusted-server-release/src/inspect/tests.rs b/crates/trusted-server-release/src/inspect/tests.rs index 8d4798e6ec..73db82e538 100644 --- a/crates/trusted-server-release/src/inspect/tests.rs +++ b/crates/trusted-server-release/src/inspect/tests.rs @@ -3,7 +3,7 @@ //! Real-binary observations (dev `ts` builds and the Fastly Wasm) are manual //! evidence, not tests, because they depend on host build outputs. -mod builders; +pub(crate) mod builders; mod elf; mod macho; mod wasm; diff --git a/crates/trusted-server-release/src/inspect/tests/builders.rs b/crates/trusted-server-release/src/inspect/tests/builders.rs index 66296287f3..6483bb9fd5 100644 --- a/crates/trusted-server-release/src/inspect/tests/builders.rs +++ b/crates/trusted-server-release/src/inspect/tests/builders.rs @@ -498,12 +498,71 @@ pub(super) fn wasm_module_with_customs( imports: &[(&str, &str)], customs: &[(&str, &[u8])], ) -> Vec { - let mut out = wasm_module(imports); + append_customs(wasm_module(imports), customs) +} + +/// Appends custom sections `(name, payload)` in order to `module`. +pub(crate) fn append_customs(mut module: Vec, customs: &[(&str, &[u8])]) -> Vec { for (name, payload) in customs { let mut body = Vec::new(); name_bytes(&mut body, name); body.extend_from_slice(payload); - section(&mut out, 0, &body); + section(&mut module, 0, &body); + } + module +} + +/// The `_start` export shape of a [`command_module`]. +#[derive(Clone, Copy)] +pub(crate) enum Entry { + /// `_start` takes and returns nothing. + Nullary, + /// `_start` takes one `i32`. + TakesI32, + /// The function is exported as `main`, not `_start`. + Renamed, +} + +/// Builds a WASI-command-shaped core module importing `() -> ()` functions, +/// defining one function exported per `entry`, one memory exported as +/// `memory` when `export_memory` is set, and empty bodies. +pub(crate) fn command_module( + imports: &[(&str, &str)], + entry: Entry, + export_memory: bool, +) -> Vec { + let mut out = b"\0asm\x01\0\0\0".to_vec(); + section(&mut out, 1, &[2, 0x60, 0, 0, 0x60, 1, 0x7f, 0]); + if !imports.is_empty() { + let mut body = vec![u8::try_from(imports.len()).expect("should fit")]; + for (module, name) in imports { + name_bytes(&mut body, module); + name_bytes(&mut body, name); + body.extend_from_slice(&[0, 0]); + } + section(&mut out, 2, &body); + } + let entry_type = match entry { + Entry::Nullary | Entry::Renamed => 0, + Entry::TakesI32 => 1, + }; + section(&mut out, 3, &[1, entry_type]); + section(&mut out, 5, &[1, 0, 1]); + let entry_index = u8::try_from(imports.len()).expect("should fit"); + let mut exports = vec![if export_memory { 2 } else { 1 }]; + name_bytes( + &mut exports, + match entry { + Entry::Renamed => "main", + Entry::Nullary | Entry::TakesI32 => "_start", + }, + ); + exports.extend_from_slice(&[0, entry_index]); + if export_memory { + name_bytes(&mut exports, "memory"); + exports.extend_from_slice(&[2, 0]); } + section(&mut out, 7, &exports); + section(&mut out, 10, &[1, 2, 0, 0x0b]); out } diff --git a/crates/trusted-server-release/src/inspect/tests/wasm.rs b/crates/trusted-server-release/src/inspect/tests/wasm.rs index 5fb6e44771..4dbbc371a4 100644 --- a/crates/trusted-server-release/src/inspect/tests/wasm.rs +++ b/crates/trusted-server-release/src/inspect/tests/wasm.rs @@ -1,6 +1,6 @@ //! Core Wasm inspection tests. -use super::builders::{section, wasm_module, wasm_module_with_customs}; +use super::builders::{Entry, command_module, section, wasm_module, wasm_module_with_customs}; use super::*; #[test] @@ -193,3 +193,105 @@ fn debug_sections_are_required_and_nonempty() { ); } } + +/// Imports of a minimal Fastly command: WASI plus both downstream hostcalls. +const FASTLY_COMMAND_IMPORTS: [(&str, &str); 3] = [ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "body_downstream_get"), + ("fastly_http_resp", "send_downstream"), +]; + +#[test] +fn fastly_command_entry_abi_is_accepted_and_exports_are_recorded() { + let facts = inspect_wasm(&command_module( + &FASTLY_COMMAND_IMPORTS, + Entry::Nullary, + true, + )) + .expect("should accept a Fastly command module"); + + facts + .require_fastly_entry_abi() + .expect("should accept the Fastly entry ABI"); + let observed: Vec<(&str, WasmExportKind)> = facts + .exports() + .iter() + .map(|export| (export.name(), export.kind())) + .collect(); + assert_eq!( + observed, + [ + ( + "_start", + WasmExportKind::Function { + params: 0, + results: 0 + } + ), + ("memory", WasmExportKind::Memory), + ], + "should record every export with its kind in module order" + ); +} + +#[test] +fn wrong_fastly_entry_abi_is_rejected() { + let only_wasi = [("wasi_snapshot_preview1", "fd_write")]; + let without_response = [ + ("wasi_snapshot_preview1", "fd_write"), + ("fastly_http_req", "body_downstream_get"), + ]; + let cases: [(&str, Vec); 6] = [ + ( + "missing _start", + command_module(&FASTLY_COMMAND_IMPORTS, Entry::Renamed, true), + ), + ( + "_start taking an argument", + command_module(&FASTLY_COMMAND_IMPORTS, Entry::TakesI32, true), + ), + ( + "unexported memory", + command_module(&FASTLY_COMMAND_IMPORTS, Entry::Nullary, false), + ), + ( + "plain WASI command", + command_module(&only_wasi, Entry::Nullary, true), + ), + ( + "missing response hostcalls", + command_module(&without_response, Entry::Nullary, true), + ), + ( + "library without exports", + wasm_module(&FASTLY_COMMAND_IMPORTS), + ), + ]; + for (label, bytes) in cases { + let facts = inspect_wasm(&bytes).expect("should accept the module itself"); + assert_eq!( + violation(facts.require_fastly_entry_abi()), + Violation::WasmAbi, + "should reject a {label} as the Fastly entry ABI" + ); + } +} + +#[test] +fn foreign_host_abi_command_is_rejected_before_the_entry_check() { + let bytes = command_module( + &[ + ("fastly_http_req", "body_downstream_get"), + ("fastly_http_resp", "send_downstream"), + ("wasi:http/types@0.3.0", "handle"), + ], + Entry::Nullary, + true, + ); + + assert_eq!( + violation(inspect_wasm(&bytes)), + Violation::WasmImport, + "should reject a command importing a foreign host ABI" + ); +} diff --git a/crates/trusted-server-release/src/inspect/wasm.rs b/crates/trusted-server-release/src/inspect/wasm.rs index 88558d390c..f45949588a 100644 --- a/crates/trusted-server-release/src/inspect/wasm.rs +++ b/crates/trusted-server-release/src/inspect/wasm.rs @@ -3,9 +3,12 @@ use std::collections::BTreeSet; use error_stack::Report; -use wasmparser::{Parser, Payload, TypeRef, Validator, WasmFeatures}; +use wasmparser::types::{EntityType, Types}; +use wasmparser::{CompositeInnerType, Parser, Payload, TypeRef, Validator, WasmFeatures}; -use super::{Violation, WasmCustomSection, WasmFacts, rejected, sha256_hex}; +use super::{ + Violation, WasmCustomSection, WasmExport, WasmExportKind, WasmFacts, rejected, sha256_hex, +}; use crate::error::ReleaseError; /// Reviewed import modules of the Fastly Compute host ABI. @@ -57,9 +60,10 @@ pub(crate) fn inspect_wasm(bytes: &[u8]) -> Result Result Result, Report> { + let types = types.as_ref(); + let Some(exports) = types.core_exports() else { + return Err(rejected( + Violation::WasmEncoding, + "validated types describe a component", + )); + }; + Ok(exports + .map(|(name, entity)| { + let kind = match entity { + EntityType::Func(id) | EntityType::FuncExact(id) => { + match &types[id].composite_type.inner { + CompositeInnerType::Func(function) => WasmExportKind::Function { + params: function.params().len(), + results: function.results().len(), + }, + _ => WasmExportKind::Other, + } + } + EntityType::Memory(_) => WasmExportKind::Memory, + EntityType::Table(_) | EntityType::Global(_) | EntityType::Tag(_) => { + WasmExportKind::Other + } + }; + WasmExport { + name: name.to_owned(), + kind, + } + }) + .collect()) +} + /// Returns every custom section in file order. fn custom_sections(bytes: &[u8]) -> Result, Report> { let mut sections = Vec::new(); diff --git a/crates/trusted-server-release/src/recipe.rs b/crates/trusted-server-release/src/recipe.rs index 5139a444bf..ad86c9e8c9 100644 --- a/crates/trusted-server-release/src/recipe.rs +++ b/crates/trusted-server-release/src/recipe.rs @@ -46,6 +46,7 @@ mod fresh; mod paths; mod plan; mod remap; +mod runtime; mod sources; mod tools; diff --git a/crates/trusted-server-release/src/recipe/compare.rs b/crates/trusted-server-release/src/recipe/compare.rs index 57365f5557..4944e73a6f 100644 --- a/crates/trusted-server-release/src/recipe/compare.rs +++ b/crates/trusted-server-release/src/recipe/compare.rs @@ -118,7 +118,7 @@ fn check_pair( } /// Reads the retained artifact and requires its recorded size and digest. -fn read_artifact(completed: &RecipeCompleted) -> Result, Report> { +pub(super) fn read_artifact(completed: &RecipeCompleted) -> Result, Report> { completed.rehash_for_handoff()?; let artifact = completed.artifact(); if artifact.size() > EXECUTABLE_LIMIT { diff --git a/crates/trusted-server-release/src/recipe/runtime.rs b/crates/trusted-server-release/src/recipe/runtime.rs new file mode 100644 index 0000000000..761d53a0b5 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime.rs @@ -0,0 +1,586 @@ +//! Final-byte Fastly runtime acceptance through the pinned Viceroy. +//! +//! Before a final Fastly output is accepted, the gate validates the exact +//! bytes as a core Wasm Fastly command and then serves them with the pinned +//! Viceroy against a loopback publisher origin it starts itself. A publisher +//! request must return the origin's page, carrying this run's marker, with +//! exactly one injected Trusted Server script. The gate never invokes Cargo or +//! the Fastly CLI and never rewrites the module: Viceroy reads a private +//! read-only copy, and both the copy and the retained artifact must still +//! hash to the accepted digest afterwards. +//! +//! The settings-free `/health` route only gates readiness. Any rejection after +//! a successful health check (a failed publisher request, an unobserved +//! origin, an early Viceroy exit or a guest failure in its output) carries +//! [`HealthOnlySuccess`], so that outcome stays distinguishable from a +//! runtime that never started. +//! +//! The Viceroy and origin ports are chosen by binding an ephemeral loopback +//! port and releasing it, so another local process could take a port in that +//! window; such a race fails the gate, it cannot make it pass. + +mod config; +mod exchange; +mod logs; +mod viceroy; + +use std::ffi::OsString; +use std::fs; +use std::io::Write as _; +use std::net::{Ipv4Addr, SocketAddr, TcpListener}; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::Path; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::RecipeCompleted; +use super::compare::{FastlyEqualityChecked, read_artifact}; +use crate::error::ReleaseError; +use crate::inspect::inspect_wasm; +use crate::process::{BoundedCommand, Cancellation, ProcessLimits, ProcessOutcome}; +use crate::source::{ApprovedSource, CommitId}; +pub(crate) use exchange::PublisherObservation; +pub(crate) use logs::ViceroyOutput; +#[cfg(test)] +pub(crate) use viceroy::PINNED_VICEROY_VERSION; +pub(crate) use viceroy::SelectedViceroy; + +/// Wall-clock bound on the whole Viceroy run, compilation included. +const VICEROY_WALL: Duration = Duration::from_secs(15 * 60); +/// Bound on the time from spawn to a healthy runtime. +const STARTUP_DEADLINE: Duration = Duration::from_secs(10 * 60); +/// Interval between readiness probes. +const READINESS_POLL: Duration = Duration::from_millis(250); +/// Timeout of one readiness probe. +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +/// Timeout of the publisher request. +const PUBLISHER_TIMEOUT: Duration = Duration::from_secs(60); +/// Cap on each captured Viceroy output stream. +const VICEROY_OUTPUT_LIMIT: u64 = 8 * 1024 * 1024; +/// Headers of a top-level browser navigation. +const NAVIGATION_HEADERS: [(&str, &str); 4] = [ + ("Accept", "text/html,application/xhtml+xml"), + ("Sec-Fetch-Mode", "navigate"), + ("Sec-Fetch-Dest", "document"), + ("User-Agent", "trusted-server-release-runtime-gate"), +]; + +/// Typed reason attached to every [`ReleaseError::Runtime`] report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum RuntimeRejection { + /// The handed-off artifact is not the accepted, unchanged final output. + #[display("artifact is not the accepted final output")] + Artifact, + /// The Viceroy executable is unusable, unpinned or changed. + #[display("Viceroy selection failed")] + Viceroy, + /// The fixed fixture or its configuration could not be generated. + #[display("runtime fixture is unusable")] + Fixture, + /// Viceroy exited or never answered the health check in time. + #[display("runtime never became healthy")] + Startup, + /// A loopback HTTP exchange failed or did not parse. + #[display("HTTP exchange failed")] + Exchange, + /// The publisher page did not answer 200. + #[display("publisher page status is not 200")] + Status, + /// The Trusted Server script injection is missing or malformed. + #[display("Trusted Server script injection is missing or malformed")] + Injection, + /// The page does not carry this run's origin marker. + #[display("publisher page did not come from this run's origin")] + OriginMarker, + /// The origin fixture never received the publisher request. + #[display("origin fixture did not observe the publisher request")] + OriginUnobserved, + /// The executed copy changed during the run. + #[display("executed artifact changed during the run")] + Changed, + /// Viceroy ended other than by the gate's cancellation. + #[display("Viceroy did not end by the gate's cancellation")] + Supervision, + /// The captured Viceroy output reports a guest trap, panic or failed + /// request execution. + #[display("Viceroy output reports a guest failure")] + GuestFailure, +} + +/// Marks a rejection whose health check succeeded before the publisher +/// request failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +#[display("health check succeeded but publisher acceptance failed")] +pub(crate) struct HealthOnlySuccess; + +/// Builds a runtime rejection carrying `rejection` and a detail line. +fn runtime_error( + rejection: RuntimeRejection, + message: impl std::fmt::Display, +) -> Report { + Report::new(ReleaseError::Runtime) + .attach(rejection) + .attach(message.to_string()) +} + +/// Whether the gate serves the publisher origin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum OriginPresence { + /// The origin fixture serves the publisher page. + Served, + /// The origin port is closed, so only `/health` can succeed. Tests use + /// it to prove that a health-only success is rejected. + #[cfg(test)] + Closed, +} + +/// Proof that the retained final Fastly output of an approved source passed +/// core-format validation and a publisher request through the pinned +/// Viceroy. +/// +/// It has private fields and only [`accept_fastly_runtime`] constructs it; +/// a bare [`run_gate`] observation is not acceptance. +#[derive(Debug)] +pub(crate) struct FastlyRuntimeAccepted { + source_commit: CommitId, + observed: GateObservation, +} + +impl FastlyRuntimeAccepted { + /// The approved source commit whose fixtures and output were accepted. + pub(crate) fn source_commit(&self) -> &CommitId { + &self.source_commit + } + + /// What the gate run observed. + pub(crate) fn observed(&self) -> &GateObservation { + &self.observed + } +} + +/// What one successful gate run observed over exact final bytes. +/// +/// It has private fields and only [`run_gate`] constructs it. +#[derive(Debug)] +pub(crate) struct GateObservation { + artifact_sha256: String, + artifact_size: u64, + viceroy_version: String, + viceroy_sha256: String, + fixture_sha256: String, + config_sha256: String, + health_status: u16, + publisher: PublisherObservation, + origin_requests: Vec, + startup: Duration, + viceroy_outcome: ProcessOutcome, + viceroy_output: ViceroyOutput, +} + +impl GateObservation { + /// Digests and sanitized stderr tail of the captured Viceroy output. + pub(crate) fn viceroy_output(&self) -> &ViceroyOutput { + &self.viceroy_output + } + + /// SHA-256 of the executed final bytes, unchanged by the run. + pub(crate) fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } + + /// Size of the executed final bytes. + pub(crate) fn artifact_size(&self) -> u64 { + self.artifact_size + } + + /// The reported Viceroy version. + pub(crate) fn viceroy_version(&self) -> &str { + &self.viceroy_version + } + + /// SHA-256 of the Viceroy executable. + pub(crate) fn viceroy_sha256(&self) -> &str { + &self.viceroy_sha256 + } + + /// SHA-256 binding the fixed template, app config and store ID. + pub(crate) fn fixture_sha256(&self) -> &str { + &self.fixture_sha256 + } + + /// SHA-256 of the generated Viceroy configuration of this run. + pub(crate) fn config_sha256(&self) -> &str { + &self.config_sha256 + } + + /// Status of the readiness health check. + pub(crate) fn health_status(&self) -> u16 { + self.health_status + } + + /// What the accepted publisher response showed. + pub(crate) fn publisher(&self) -> &PublisherObservation { + &self.publisher + } + + /// Request lines the origin fixture received. + pub(crate) fn origin_requests(&self) -> &[String] { + &self.origin_requests + } + + /// Time from spawn to a healthy runtime. + pub(crate) fn startup(&self) -> Duration { + self.startup + } + + /// How the Viceroy run ended; always the gate's cancellation. + pub(crate) fn viceroy_outcome(&self) -> ProcessOutcome { + self.viceroy_outcome + } +} + +/// Runs the runtime gate over the retained final Fastly output that passed +/// the canonical and reproduction equality check. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Runtime`] with [`RuntimeRejection::Artifact`] when +/// `completed` is not the checked output of `source` or no longer rehashes, +/// [`RuntimeRejection::Fixture`] when `source` does not track the embedded +/// fixtures unchanged, [`ReleaseError::Inspect`] when the bytes are not a +/// valid Fastly core command, and the gate's other rejections from +/// [`run_gate`]. +pub(crate) fn accept_fastly_runtime( + source: &ApprovedSource, + completed: &RecipeCompleted, + checked: &FastlyEqualityChecked, + viceroy: &SelectedViceroy, +) -> Result> { + if source.commit() != completed.commit() { + return Err(runtime_error( + RuntimeRejection::Artifact, + "artifact was not built from the supplied approved source", + )); + } + config::require_built_source_fixtures(&|path| source.tracked_sha256(path).map(str::to_owned))?; + let artifact = completed.artifact(); + if completed.asset() != checked.asset() + || artifact.sha256() != checked.sha256() + || artifact.size() != checked.size() + { + return Err(runtime_error( + RuntimeRejection::Artifact, + "artifact is not the output the equality check accepted", + )); + } + let bytes = read_artifact(completed).map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Artifact) + })?; + let observed = run_gate(&bytes, viceroy, OriginPresence::Served)?; + completed.rehash_for_handoff().map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Changed) + })?; + Ok(FastlyRuntimeAccepted { + source_commit: completed.commit().clone(), + observed, + }) +} + +/// Validates `bytes` and serves them through `viceroy` against the origin. +/// +/// # Errors +/// +/// Returns [`ReleaseError::Inspect`] for invalid core Wasm, a foreign host +/// ABI, a missing entry ABI or stripped debug sections; otherwise +/// [`ReleaseError::Runtime`] with a [`RuntimeRejection`]. Every rejection +/// after a healthy readiness check also carries [`HealthOnlySuccess`]. +pub(super) fn run_gate( + bytes: &[u8], + viceroy: &SelectedViceroy, + origin: OriginPresence, +) -> Result> { + let facts = inspect_wasm(bytes)?; + facts.require_fastly_entry_abi()?; + facts.require_debug_sections()?; + viceroy.reverify()?; + let private = tempfile::tempdir().map_err(|error| fixture_io("private directory", &error))?; + let wasm = private.path().join("final.wasm"); + write_private(&wasm, bytes, 0o400)?; + require_unchanged(&wasm, facts.sha256())?; + let marker = run_marker(facts.sha256()); + let origin_fixture = match origin { + OriginPresence::Served => Some(exchange::OriginFixture::start(&marker)?), + #[cfg(test)] + OriginPresence::Closed => None, + }; + let origin_address = match &origin_fixture { + Some(fixture) => fixture.address(), + None => free_loopback_address()?, + }; + let generated = config::viceroy_config(&format!("http://{origin_address}"))?; + let config_path = private.path().join("viceroy.toml"); + write_private(&config_path, generated.text.as_bytes(), 0o400)?; + let home = private.path().join("home"); + fs::create_dir(&home).map_err(|error| fixture_io("home directory", &error))?; + let address = free_loopback_address()?; + let run = serve(&ServeInputs { + viceroy, + wasm: &wasm, + config: &config_path, + home: &home, + working_directory: private.path(), + address, + marker: &marker, + }); + let origin_requests = origin_fixture + .map(exchange::OriginFixture::stop) + .unwrap_or_default(); + require_unchanged(&wasm, facts.sha256())?; + viceroy.reverify()?; + let served = run?; + if !origin_requests + .iter() + .any(|line| line.starts_with("GET / ")) + { + return Err(runtime_error( + RuntimeRejection::OriginUnobserved, + format!("origin fixture received {origin_requests:?}"), + ) + .attach(HealthOnlySuccess)); + } + Ok(GateObservation { + artifact_sha256: facts.sha256().to_owned(), + artifact_size: bytes.len() as u64, + viceroy_version: viceroy.version().to_owned(), + viceroy_sha256: viceroy.sha256().to_owned(), + fixture_sha256: generated.fixture_sha256, + config_sha256: generated.sha256, + health_status: served.health_status, + publisher: served.publisher, + origin_requests, + startup: served.startup, + viceroy_outcome: served.outcome, + viceroy_output: served.output, + }) +} + +/// Inputs of one Viceroy serving run. +struct ServeInputs<'a> { + viceroy: &'a SelectedViceroy, + wasm: &'a Path, + config: &'a Path, + home: &'a Path, + working_directory: &'a Path, + address: SocketAddr, + marker: &'a str, +} + +/// What one successful serving run observed. +struct Served { + health_status: u16, + publisher: PublisherObservation, + startup: Duration, + outcome: ProcessOutcome, + output: ViceroyOutput, +} + +/// Cancels the Viceroy run when dropped, so a panic while exchanging stops +/// Viceroy at once instead of waiting out [`VICEROY_WALL`] in the scope join. +struct CancelOnDrop<'a>(&'a Cancellation); + +impl Drop for CancelOnDrop<'_> { + fn drop(&mut self) { + self.0.cancel(); + } +} + +/// Runs Viceroy, waits for health, checks one publisher request and stops it. +fn serve(inputs: &ServeInputs<'_>) -> Result> { + let cancellation = Cancellation::default(); + let command = viceroy_command(inputs, &cancellation)?; + let started = Instant::now(); + std::thread::scope(|scope| { + let running = scope.spawn(|| command.run()); + let stop = CancelOnDrop(&cancellation); + let exchanged = exchange_while_running(inputs, started, &|| running.is_finished()); + drop(stop); + let result = running + .join() + .map_err(|_| { + runtime_error(RuntimeRejection::Supervision, "Viceroy supervisor panicked") + })? + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Supervision) + })?; + let (publisher, startup) = exchanged.map_err(|error| { + error.attach(format!( + "Viceroy {} after {:?}; stderr tail: {}", + result.outcome(), + result.elapsed(), + logs::sanitized_tail(result.stderr()) + )) + })?; + if result.outcome() != ProcessOutcome::Cancelled { + return Err(runtime_error( + RuntimeRejection::Supervision, + format!("Viceroy {} before the gate stopped it", result.outcome()), + ) + .attach(HealthOnlySuccess)); + } + let output = logs::scan_viceroy_output(result.stdout(), result.stderr()) + .map_err(|error| error.attach(HealthOnlySuccess))?; + Ok(Served { + health_status: 200, + publisher, + startup, + outcome: result.outcome(), + output, + }) + }) +} + +/// Builds the fixed Viceroy command line. +fn viceroy_command( + inputs: &ServeInputs<'_>, + cancellation: &Cancellation, +) -> Result> { + let arguments = [ + OsString::from("serve"), + OsString::from("--addr"), + OsString::from(inputs.address.to_string()), + OsString::from("--config"), + inputs.config.as_os_str().to_owned(), + OsString::from("--unknown-import-behavior"), + OsString::from("link-error"), + inputs.wasm.as_os_str().to_owned(), + ]; + BoundedCommand::new( + inputs.viceroy.path(), + inputs.working_directory, + ProcessLimits { + wall: VICEROY_WALL, + stdout_max: VICEROY_OUTPUT_LIMIT, + stderr_max: VICEROY_OUTPUT_LIMIT, + }, + ) + .and_then(|command| { + command + .arguments(arguments) + .environment("HOME", inputs.home) + }) + .map(|command| command.cancellation(cancellation.clone())) + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Supervision) + }) +} + +/// Waits for health, then sends and checks the publisher request. +fn exchange_while_running( + inputs: &ServeInputs<'_>, + started: Instant, + exited: &dyn Fn() -> bool, +) -> Result<(PublisherObservation, Duration), Report> { + loop { + if exited() { + return Err(runtime_error( + RuntimeRejection::Startup, + "Viceroy exited before answering the health check", + )); + } + if started.elapsed() > STARTUP_DEADLINE { + return Err(runtime_error( + RuntimeRejection::Startup, + "Viceroy did not answer the health check before the startup deadline", + )); + } + if exchange::get(inputs.address, "/health", &[], PROBE_TIMEOUT) + .is_ok_and(|response| exchange::is_health_ok(&response)) + { + break; + } + std::thread::sleep(READINESS_POLL); + } + let startup = started.elapsed(); + exchange::get(inputs.address, "/", &NAVIGATION_HEADERS, PUBLISHER_TIMEOUT) + .and_then(|response| exchange::check_publisher_response(&response, inputs.marker)) + .map(|publisher| (publisher, startup)) + .map_err(|error| error.attach(HealthOnlySuccess)) +} + +fn fixture_io(what: &str, error: &std::io::Error) -> Report { + runtime_error( + RuntimeRejection::Fixture, + format!("cannot create the {what}: {error}"), + ) +} + +/// Writes a new private file with `mode` and syncs it. +fn write_private(path: &Path, bytes: &[u8], mode: u32) -> Result<(), Report> { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(path) + .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all())) + .map_err(|error| fixture_io("private file", &error)) +} + +/// Requires the file at `path` to hash to `sha256`. +fn require_unchanged(path: &Path, sha256: &str) -> Result<(), Report> { + let bytes = fs::read(path).map_err(|error| { + runtime_error( + RuntimeRejection::Changed, + format!("cannot reread the copy: {error}"), + ) + })?; + if hex::encode(Sha256::digest(&bytes)) != sha256 { + return Err(runtime_error( + RuntimeRejection::Changed, + "executed copy no longer hashes to the accepted digest", + )); + } + Ok(()) +} + +/// Returns a loopback address whose port was free a moment ago. +fn free_loopback_address() -> Result> { + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .and_then(|listener| listener.local_addr()) + .map_err(|error| fixture_io("loopback port", &error)) +} + +/// Returns a marker unique to this run, so a cached or foreign page fails. +fn run_marker(artifact_sha256: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seed = format!("{artifact_sha256}:{nanos}:{}", std::process::id()); + let digest = hex::encode(Sha256::digest(seed.as_bytes())); + format!("trusted-server-release-origin-{}", &digest[..24]) +} + +#[cfg(test)] +pub(super) mod tests_support { + //! Narrow access to the gate's pure parts for the recipe tests. + + pub(in crate::recipe) use super::config::{ + app_config_data, canonical, config_store_id, envelope_json, require_built_source_fixtures, + viceroy_config, + }; + pub(in crate::recipe) use super::exchange::{ + HttpResponse, OriginFixture, check_publisher_response, get, is_health_ok, origin_page, + parse_response, + }; + pub(in crate::recipe) use super::logs::{sanitized_tail, scan_viceroy_output}; + pub(in crate::recipe) use super::viceroy::parse_version_output; +} diff --git a/crates/trusted-server-release/src/recipe/runtime/config.rs b/crates/trusted-server-release/src/recipe/runtime/config.rs new file mode 100644 index 0000000000..ab6f3590db --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/config.rs @@ -0,0 +1,340 @@ +//! Fixed Viceroy local configuration for the final-byte runtime gate. +//! +//! The gate reuses the integration harness fixtures verbatim: its Viceroy +//! template (KV, secret and JWKS stores with generated test-only values) and +//! its readable Trusted Server app config. Like the harness generator, it +//! overrides only `publisher.origin_url`, wraps the app config in a version 1 +//! blob envelope and injects it into the template's single generated-store +//! marker under the config store ID the tracked `edgezero.toml` declares. +//! +//! The producer has no dependency on the core crate, so the app config is +//! converted from TOML to JSON here and its envelope digest is computed with +//! the envelope's canonical form: compact JSON with object keys sorted by +//! UTF-8 bytes. Floats and datetimes are rejected rather than canonicalized, +//! so the fixture can never depend on number formatting. +//! +//! # Embedded fixtures +//! +//! The three fixture files are compiled into the producer from the producer's +//! own source tree, not read from the source that was built. The producer may +//! be built from a different revision than the approved source, so +//! [`require_built_source_fixtures`] fails closed unless the built source +//! tracks each file at the exact embedded content. + +use std::fmt::Write as _; + +use error_stack::Report; +use serde_json::{Map, Value}; +use sha2::{Digest as _, Sha256}; + +use super::{RuntimeRejection, runtime_error}; +use crate::error::ReleaseError; + +/// The integration harness Viceroy template. +const VICEROY_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../trusted-server-integration-tests/fixtures/configs/viceroy-template.toml" +)); +/// The integration harness readable app config. +const APP_CONFIG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml" +)); +/// The tracked `EdgeZero` manifest naming the default config store. +const EDGEZERO_MANIFEST: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../edgezero.toml")); +/// The single line the generated config store replaces. +const GENERATED_STORES_MARKER: &str = " # GENERATED_TRUSTED_SERVER_CONFIG_STORES"; +/// Fixed envelope timestamp, the same one the harness generator stamps. +const GENERATED_AT: &str = "2026-06-23T00:00:00Z"; +/// The only blob envelope version the runtime accepts. +const ENVELOPE_VERSION: u64 = 1; + +/// Each embedded fixture with its repository-relative tracked path. +const EMBEDDED_FIXTURES: [(&str, &str); 3] = [ + ( + "crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml", + VICEROY_TEMPLATE, + ), + ( + "crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml", + APP_CONFIG, + ), + ("edgezero.toml", EDGEZERO_MANIFEST), +]; + +/// Requires the built source to track every embedded fixture unchanged. +/// +/// `tracked_sha256` returns the SHA-256 of a tracked path's content in the +/// built source, or `None` when the path is not tracked. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] naming the first fixture that is +/// untracked or differs from the built source. +pub(in crate::recipe) fn require_built_source_fixtures( + tracked_sha256: &dyn Fn(&str) -> Option, +) -> Result<(), Report> { + for (path, embedded) in EMBEDDED_FIXTURES { + let expected = sha256_hex(embedded.as_bytes()); + match tracked_sha256(path) { + Some(built) if built == expected => {} + Some(built) => { + return Err(runtime_error( + RuntimeRejection::Fixture, + format!( + "embedded fixture {path} differs from built source \ + (embedded {expected}, built {built})" + ), + )); + } + None => { + return Err(runtime_error( + RuntimeRejection::Fixture, + format!("embedded fixture {path} is not tracked by the built source"), + )); + } + } + } + Ok(()) +} + +/// One generated Viceroy configuration and the digests that identify it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::recipe) struct GeneratedConfig { + /// The complete Viceroy `local_server` configuration. + pub(in crate::recipe) text: String, + /// SHA-256 of [`Self::text`]. + pub(in crate::recipe) sha256: String, + /// SHA-256 binding the fixed template, app config and store ID. + pub(in crate::recipe) fixture_sha256: String, +} + +/// Returns the default config store ID declared by `edgezero.toml`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] when the manifest does not parse or +/// the ID is missing or not a plain lowercase identifier. +pub(in crate::recipe) fn config_store_id() -> Result> { + let fixture = |message: &str| runtime_error(RuntimeRejection::Fixture, message); + let manifest: toml::Table = toml::from_str(EDGEZERO_MANIFEST) + .map_err(|error| fixture(&format!("edgezero.toml does not parse: {error}")))?; + let id = manifest + .get("stores") + .and_then(|stores| stores.get("config")) + .and_then(|config| config.get("default")) + .and_then(toml::Value::as_str) + .ok_or_else(|| fixture("edgezero.toml declares no default config store"))?; + let plain = !id.is_empty() + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); + if !plain { + return Err(fixture("default config store ID is not a plain identifier")); + } + Ok(id.to_owned()) +} + +/// Converts a TOML value into JSON, rejecting floats and datetimes. +fn toml_to_json(value: &toml::Value) -> Result> { + Ok(match value { + toml::Value::String(text) => Value::String(text.clone()), + toml::Value::Integer(number) => Value::from(*number), + toml::Value::Boolean(flag) => Value::Bool(*flag), + toml::Value::Array(items) => { + Value::Array(items.iter().map(toml_to_json).collect::>()?) + } + toml::Value::Table(table) => table_to_json(table)?, + toml::Value::Float(_) | toml::Value::Datetime(_) => { + return Err(runtime_error( + RuntimeRejection::Fixture, + "app config fixture holds a float or datetime", + )); + } + }) +} + +fn table_to_json(table: &toml::Table) -> Result> { + let mut map = Map::new(); + for (key, value) in table { + map.insert(key.clone(), toml_to_json(value)?); + } + Ok(Value::Object(map)) +} + +/// Returns the integration app config as JSON with `origin_url` replaced. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] when the fixture does not parse, +/// holds a float or datetime, or lacks a string `publisher.origin_url`. +pub(in crate::recipe) fn app_config_data(origin_url: &str) -> Result> { + let table: toml::Table = toml::from_str(APP_CONFIG).map_err(|error| { + runtime_error( + RuntimeRejection::Fixture, + format!("app config fixture does not parse: {error}"), + ) + })?; + let mut data = table_to_json(&table)?; + let origin = data + .pointer_mut("/publisher/origin_url") + .filter(|value| value.is_string()) + .ok_or_else(|| { + runtime_error( + RuntimeRejection::Fixture, + "app config fixture lacks publisher.origin_url", + ) + })?; + *origin = Value::String(origin_url.to_owned()); + Ok(data) +} + +/// Writes `value` in the envelope's canonical form. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] for a non-integer number. +pub(in crate::recipe) fn write_canonical( + out: &mut String, + value: &Value, +) -> Result<(), Report> { + match value { + Value::Null => out.push_str("null"), + Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }), + Value::Number(number) => { + if let Some(integer) = number.as_i64() { + let _ = write!(out, "{integer}"); + } else if let Some(integer) = number.as_u64() { + let _ = write!(out, "{integer}"); + } else { + return Err(runtime_error( + RuntimeRejection::Fixture, + "canonical form rejects non-integer numbers", + )); + } + } + Value::String(text) => write_string(out, text), + Value::Array(items) => { + out.push('['); + for (index, item) in items.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_canonical(out, item)?; + } + out.push(']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_unstable_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + out.push('{'); + for (index, key) in keys.into_iter().enumerate() { + if index > 0 { + out.push(','); + } + write_string(out, key); + out.push(':'); + write_canonical(out, &map[key])?; + } + out.push('}'); + } + } + Ok(()) +} + +/// Writes a JSON string with the envelope's fixed escape table. +fn write_string(out: &mut String, text: &str) { + out.push('"'); + for character in text.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{0008}' => out.push_str("\\b"), + '\u{000C}' => out.push_str("\\f"), + control if u32::from(control) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(control)); + } + other => out.push(other), + } + } + out.push('"'); +} + +/// Returns the canonical form of `value`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] for a non-integer number. +pub(in crate::recipe) fn canonical(value: &Value) -> Result> { + let mut out = String::new(); + write_canonical(&mut out, value)?; + Ok(out) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Returns the blob envelope JSON wrapping `data`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] for a non-integer number. +pub(in crate::recipe) fn envelope_json(data: &Value) -> Result> { + let sha256 = sha256_hex(canonical(data)?.as_bytes()); + canonical(&serde_json::json!({ + "data": data, + "generated_at": GENERATED_AT, + "sha256": sha256, + "version": ENVELOPE_VERSION, + })) +} + +/// Builds the complete Viceroy configuration for `origin_url`. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Fixture`] when the template does not hold +/// exactly one generated-store marker, the store ID or app config is +/// unusable, or the envelope cannot be embedded as a TOML literal string. +pub(in crate::recipe) fn viceroy_config( + origin_url: &str, +) -> Result> { + let markers = VICEROY_TEMPLATE.matches(GENERATED_STORES_MARKER).count(); + if markers != 1 { + return Err(runtime_error( + RuntimeRejection::Fixture, + format!("Viceroy template holds {markers} generated-store markers, not one"), + )); + } + let store = config_store_id()?; + let envelope = envelope_json(&app_config_data(origin_url)?)?; + if envelope.contains("'''") { + return Err(runtime_error( + RuntimeRejection::Fixture, + "envelope cannot be embedded as a TOML literal string", + )); + } + let block = format!( + " [local_server.config_stores.{store}]\n format = \"inline-toml\"\n \ + [local_server.config_stores.{store}.contents]\n {store} = '''{envelope}'''" + ); + let text = VICEROY_TEMPLATE.replace(GENERATED_STORES_MARKER, &block); + let fixture_sha256 = sha256_hex( + format!( + "viceroy-template.toml {}\ntrusted-server.integration.toml {}\nconfig-store {store}\n", + sha256_hex(VICEROY_TEMPLATE.as_bytes()), + sha256_hex(APP_CONFIG.as_bytes()), + ) + .as_bytes(), + ); + Ok(GeneratedConfig { + sha256: sha256_hex(text.as_bytes()), + text, + fixture_sha256, + }) +} diff --git a/crates/trusted-server-release/src/recipe/runtime/exchange.rs b/crates/trusted-server-release/src/recipe/runtime/exchange.rs new file mode 100644 index 0000000000..32db26fa19 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/exchange.rs @@ -0,0 +1,461 @@ +//! Loopback HTTP exchanges of the runtime gate: the publisher origin fixture, +//! a minimal HTTP/1.1 client and the pure response checks. +//! +//! The client only speaks to loopback addresses the gate chose itself. It +//! requests `identity` encoding with `Connection: close`, reads at most +//! [`RESPONSE_LIMIT`] bytes and decodes `Content-Length` or chunked bodies +//! strictly. + +use std::io::{ErrorKind, Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::{RuntimeRejection, runtime_error}; +use crate::error::ReleaseError; + +/// Maximum bytes read from one response, head and body together. +pub(in crate::recipe) const RESPONSE_LIMIT: u64 = 4 * 1024 * 1024; +/// Maximum bytes the origin fixture reads from one request head. +const REQUEST_HEAD_LIMIT: u64 = 64 * 1024; +/// The attribute that identifies the injected Trusted Server script. +const SCRIPT_ID: &str = "id=\"trustedserver-js\""; +/// The required prefix of the injected script's `src`. +const SCRIPT_SOURCE_PREFIX: &str = "/static/tsjs="; +/// Interval at which the origin fixture checks its stop flag. +const ACCEPT_POLL: Duration = Duration::from_millis(20); + +/// A parsed HTTP response with a fully decoded body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::recipe) struct HttpResponse { + pub(in crate::recipe) status: u16, + /// Header names in lowercase, in received order. + pub(in crate::recipe) headers: Vec<(String, String)>, + pub(in crate::recipe) body: Vec, +} + +impl HttpResponse { + /// Returns the first value of `name`, compared case-insensitively. + pub(in crate::recipe) fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(seen, _)| seen.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } +} + +/// What the accepted publisher response showed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PublisherObservation { + pub(crate) status: u16, + pub(crate) body_size: usize, + pub(crate) body_sha256: String, + pub(crate) script_src: String, +} + +fn exchange_error(message: impl std::fmt::Display) -> Report { + runtime_error(RuntimeRejection::Exchange, message) +} + +/// Sends one `GET` to a loopback `address` and parses the response. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Exchange`] when the connection, write or read +/// fails or times out, the response exceeds [`RESPONSE_LIMIT`], or it does +/// not parse. +pub(in crate::recipe) fn get( + address: SocketAddr, + path: &str, + headers: &[(&str, &str)], + timeout: Duration, +) -> Result> { + if !address.ip().is_loopback() { + return Err(exchange_error("the gate only contacts loopback addresses")); + } + let mut stream = TcpStream::connect_timeout(&address, timeout) + .map_err(|error| exchange_error(format!("cannot connect to {address}: {error}")))?; + stream + .set_read_timeout(Some(timeout)) + .and_then(|()| stream.set_write_timeout(Some(timeout))) + .map_err(|error| exchange_error(format!("cannot set socket timeouts: {error}")))?; + let mut request = format!( + "GET {path} HTTP/1.1\r\nHost: {address}\r\nAccept-Encoding: identity\r\nConnection: close\r\n" + ); + for (name, value) in headers { + request.push_str(&format!("{name}: {value}\r\n")); + } + request.push_str("\r\n"); + stream + .write_all(request.as_bytes()) + .map_err(|error| exchange_error(format!("cannot send the request: {error}")))?; + let mut raw = Vec::new(); + stream + .take(RESPONSE_LIMIT + 1) + .read_to_end(&mut raw) + .map_err(|error| exchange_error(format!("cannot read the response: {error}")))?; + if raw.len() as u64 > RESPONSE_LIMIT { + return Err(exchange_error("response exceeds the byte limit")); + } + parse_response(&raw) +} + +/// Parses a complete HTTP/1.x response read until the connection closed. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Exchange`] for a malformed head or header +/// name, a non-identity content encoding, a transfer encoding other than +/// exactly `chunked`, duplicate or conflicting framing headers, a body +/// shorter or longer than its `Content-Length`, or a malformed chunked body. +pub(in crate::recipe) fn parse_response(raw: &[u8]) -> Result> { + let split = raw + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| exchange_error("response head is incomplete"))?; + let head = std::str::from_utf8(&raw[..split]) + .ok() + .filter(|head| head.is_ascii()) + .ok_or_else(|| exchange_error("response head is not ASCII"))?; + let rest = &raw[split + 4..]; + let mut lines = head.split("\r\n"); + let status = parse_status_line(lines.next().unwrap_or_default())?; + let headers = lines + .map(parse_header_line) + .collect::, _>>()?; + let mut response = HttpResponse { + status, + headers, + body: Vec::new(), + }; + if let Some(encoding) = response.header("content-encoding") + && !encoding.eq_ignore_ascii_case("identity") + { + return Err(exchange_error(format!( + "unexpected content encoding {encoding:?}" + ))); + } + let count = |name: &str| { + response + .headers + .iter() + .filter(|(seen, _)| seen == name) + .count() + }; + let transfer_encodings = count("transfer-encoding"); + let content_lengths = count("content-length"); + if transfer_encodings > 1 || content_lengths > 1 { + return Err(exchange_error("duplicate framing header")); + } + if transfer_encodings == 1 && content_lengths == 1 { + return Err(exchange_error( + "transfer encoding and content length are both present", + )); + } + response.body = if let Some(encoding) = response.header("transfer-encoding") { + if !encoding.eq_ignore_ascii_case("chunked") { + return Err(exchange_error(format!( + "unsupported transfer encoding {encoding:?}" + ))); + } + decode_chunked(rest)? + } else if let Some(length) = response.header("content-length") { + let length: usize = length + .bytes() + .all(|byte| byte.is_ascii_digit()) + .then(|| length.parse().ok()) + .flatten() + .ok_or_else(|| exchange_error(format!("malformed content length {length:?}")))?; + if rest.len() != length { + return Err(exchange_error(format!( + "body holds {} bytes, content length says {length}", + rest.len() + ))); + } + rest.to_vec() + } else { + rest.to_vec() + }; + Ok(response) +} + +/// Whether `byte` may appear in an HTTP header name (an RFC 9110 token). +fn is_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte) +} + +/// Parses one `name: value` header line, lowercasing the name. +fn parse_header_line(line: &str) -> Result<(String, String), Report> { + line.split_once(':') + .filter(|(name, _)| !name.is_empty() && name.bytes().all(is_token_byte)) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned())) + .ok_or_else(|| exchange_error(format!("malformed header line {line:?}"))) +} + +/// Parses `HTTP/1.x NNN reason` into its status code. +fn parse_status_line(line: &str) -> Result> { + let mut parts = line.splitn(3, ' '); + let version = parts.next().unwrap_or_default(); + let code = parts.next().unwrap_or_default(); + if !matches!(version, "HTTP/1.1" | "HTTP/1.0") + || code.len() != 3 + || !code.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(exchange_error(format!("malformed status line {line:?}"))); + } + code.parse() + .map_err(|_| exchange_error(format!("malformed status line {line:?}"))) +} + +/// Decodes a complete chunked body, ignoring chunk extensions. +/// +/// The last chunk must be exactly `0\r\n\r\n` with nothing after it, so +/// trailers and trailing bytes are rejected. +fn decode_chunked(mut rest: &[u8]) -> Result, Report> { + let mut body = Vec::new(); + loop { + let line_end = rest + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| exchange_error("chunk size line is incomplete"))?; + let size_text = std::str::from_utf8(&rest[..line_end]) + .ok() + .map(|line| line.split(';').next().unwrap_or_default()) + .filter(|size| { + !size.is_empty() && size.len() <= 8 && size.bytes().all(|b| b.is_ascii_hexdigit()) + }) + .ok_or_else(|| exchange_error("malformed chunk size"))?; + let size = usize::from_str_radix(size_text, 16) + .map_err(|_| exchange_error(format!("malformed chunk size {size_text:?}")))?; + rest = &rest[line_end + 2..]; + if size == 0 { + if rest != b"\r\n" { + return Err(exchange_error( + "last chunk is not followed by exactly one empty line", + )); + } + return Ok(body); + } + let data = rest + .get(..size) + .filter(|_| rest.get(size..size + 2) == Some(b"\r\n")) + .ok_or_else(|| exchange_error("chunk data is truncated"))?; + body.extend_from_slice(data); + rest = &rest[size + 2..]; + } +} + +/// Returns whether `response` is the settings-free health answer. +pub(in crate::recipe) fn is_health_ok(response: &HttpResponse) -> bool { + response.status == 200 && response.body.trim_ascii() == b"ok" +} + +/// Requires a successful publisher page proxied from this run's origin with +/// exactly one injected Trusted Server script ahead of the origin content. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Status`] for any status but 200, +/// [`RuntimeRejection::Injection`] for a non-HTML or non-UTF-8 page or a +/// missing, duplicated, malformed or misplaced script, and +/// [`RuntimeRejection::OriginMarker`] when the page does not hold this run's +/// origin marker exactly once. +pub(in crate::recipe) fn check_publisher_response( + response: &HttpResponse, + marker: &str, +) -> Result> { + if response.status != 200 { + return Err(runtime_error( + RuntimeRejection::Status, + format!("publisher page answered {}", response.status), + )); + } + let injection = |message: &str| runtime_error(RuntimeRejection::Injection, message); + if !response + .header("content-type") + .is_some_and(|value| value.to_ascii_lowercase().starts_with("text/html")) + { + return Err(injection("publisher page is not text/html")); + } + let body = std::str::from_utf8(&response.body) + .map_err(|_| injection("publisher page is not UTF-8"))?; + if body.matches(marker).count() != 1 { + return Err(runtime_error( + RuntimeRejection::OriginMarker, + "publisher page does not hold this run's origin marker exactly once", + )); + } + let scripts = body.matches(SCRIPT_ID).count(); + if scripts != 1 { + return Err(injection(&format!( + "publisher page holds {scripts} Trusted Server scripts, not one" + ))); + } + let id_at = body.find(SCRIPT_ID).unwrap_or_default(); + let open = body[..id_at] + .rfind("')) + .ok_or_else(|| injection("Trusted Server script id is not on a script tag"))?; + let close = body[id_at..] + .find('>') + .map(|offset| id_at + offset) + .ok_or_else(|| injection("Trusted Server script tag is unterminated"))?; + let source = body[open..close] + .split_once("src=\"") + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(source, _)| source) + .filter(|source| source.starts_with(SCRIPT_SOURCE_PREFIX)) + .ok_or_else(|| injection("Trusted Server script does not load the tsjs bundle"))?; + if body.find(marker).is_some_and(|marker_at| marker_at < open) { + return Err(injection( + "Trusted Server script follows the origin content", + )); + } + Ok(PublisherObservation { + status: response.status, + body_size: response.body.len(), + body_sha256: hex::encode(Sha256::digest(&response.body)), + script_src: source.to_owned(), + }) +} + +/// The publisher page the origin fixture serves for `marker`. +pub(in crate::recipe) fn origin_page(marker: &str) -> String { + format!( + "\n\ + Example publisher\n

Example publisher

\ +

{marker}

\n" + ) +} + +/// A loopback publisher origin that serves [`origin_page`] for `GET /` and +/// records every request line it receives. +pub(in crate::recipe) struct OriginFixture { + address: SocketAddr, + stop: Arc, + requests: Arc>>, + thread: Option>, +} + +impl OriginFixture { + /// Binds an ephemeral loopback port and starts serving. + /// + /// # Errors + /// + /// Returns [`RuntimeRejection::Fixture`] when the listener cannot be + /// bound or configured. + pub(in crate::recipe) fn start(marker: &str) -> Result> { + let fixture = |error: std::io::Error| { + runtime_error( + RuntimeRejection::Fixture, + format!("cannot start the origin fixture: {error}"), + ) + }; + let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(fixture)?; + listener.set_nonblocking(true).map_err(fixture)?; + let address = listener.local_addr().map_err(fixture)?; + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(Mutex::new(Vec::new())); + let page = origin_page(marker); + let thread = { + let stop = Arc::clone(&stop); + let requests = Arc::clone(&requests); + std::thread::spawn(move || serve(&listener, &stop, &requests, &page)) + }; + Ok(Self { + address, + stop, + requests, + thread: Some(thread), + }) + } + + /// The bound loopback address. + pub(in crate::recipe) fn address(&self) -> SocketAddr { + self.address + } + + /// Stops serving and returns every request line received. + pub(in crate::recipe) fn stop(mut self) -> Vec { + self.halt(); + self.requests + .lock() + .map(|requests| requests.clone()) + .unwrap_or_default() + } + + fn halt(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +impl Drop for OriginFixture { + fn drop(&mut self) { + self.halt(); + } +} + +/// Accepts connections until `stop` is set, answering each in turn. +fn serve(listener: &TcpListener, stop: &AtomicBool, requests: &Mutex>, page: &str) { + while !stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + if let Some(line) = answer(stream, page) + && let Ok(mut requests) = requests.lock() + { + requests.push(line); + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(ACCEPT_POLL); + } + Err(_) => std::thread::sleep(ACCEPT_POLL), + } + } +} + +/// Reads one request head and answers it; returns its request line. +fn answer(mut stream: TcpStream, page: &str) -> Option { + stream.set_nonblocking(false).ok()?; + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .ok()?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .ok()?; + let mut head = Vec::new(); + let mut limited = (&stream).take(REQUEST_HEAD_LIMIT); + let mut byte = [0_u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + match limited.read(&mut byte) { + Ok(1) => head.push(byte[0]), + _ => return None, + } + } + let line = String::from_utf8_lossy(&head) + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let (status, content_type, body) = if line.starts_with("GET / ") { + ("200 OK", "text/html; charset=utf-8", page) + } else { + ("404 Not Found", "text/plain; charset=utf-8", "not found\n") + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + Some(line) +} diff --git a/crates/trusted-server-release/src/recipe/runtime/logs.rs b/crates/trusted-server-release/src/recipe/runtime/logs.rs new file mode 100644 index 0000000000..4a02258da2 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/logs.rs @@ -0,0 +1,98 @@ +//! Scan of the captured Viceroy output for guest failures. +//! +//! A guest trap or panic can still leave a valid-looking response on the +//! wire, so after the run the gate searches both captured streams (each +//! bounded by the command's output cap) for failure markers that Viceroy or +//! the Rust runtime print. Matching ignores ASCII case. The streams are +//! retained only as digests plus a short sanitized stderr tail. + +use error_stack::Report; +use sha2::{Digest as _, Sha256}; + +use super::{RuntimeRejection, runtime_error}; +use crate::error::ReleaseError; + +/// Lowercase markers of a guest trap, panic or failed request execution. +const GUEST_FAILURE_MARKERS: [&str; 7] = [ + "wasm trap", + "wasm backtrace", + "panicked at", + "error while executing", + "there was an error handling the request", + "error handling request", + "guest exited with", +]; +/// Bytes of stderr kept in the summary. +const TAIL_BYTES: usize = 1024; + +/// Digests and a short tail of the captured Viceroy output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ViceroyOutput { + pub(crate) stdout_sha256: String, + pub(crate) stderr_sha256: String, + /// The last stderr bytes, lossily decoded with control characters other + /// than newline replaced by `?`. + pub(crate) stderr_tail: String, +} + +/// Returns the last [`TAIL_BYTES`] of `stderr` as printable text. +pub(in crate::recipe) fn sanitized_tail(stderr: &[u8]) -> String { + let start = stderr.len().saturating_sub(TAIL_BYTES); + String::from_utf8_lossy(&stderr[start..]) + .chars() + .map(|character| { + if character.is_control() && character != '\n' { + '?' + } else { + character + } + }) + .collect() +} + +/// Rejects output carrying a guest failure marker and summarizes the rest. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::GuestFailure`] for a stderr line beginning +/// with `Error:` or, naming the stream and marker, for the first failure +/// marker found in either stream. +pub(in crate::recipe) fn scan_viceroy_output( + stdout: &[u8], + stderr: &[u8], +) -> Result> { + // A clean run leaves stderr empty; Viceroy reports a failed guest request + // there as an `Error: ...` line. + if String::from_utf8_lossy(stderr) + .lines() + .any(|line| line.trim_start().to_ascii_lowercase().starts_with("error:")) + { + return Err(runtime_error( + RuntimeRejection::GuestFailure, + format!( + "Viceroy stderr reports an error line; stderr tail: {}", + sanitized_tail(stderr) + ), + )); + } + for (stream, bytes) in [("stdout", stdout), ("stderr", stderr)] { + let text = String::from_utf8_lossy(bytes).to_ascii_lowercase(); + if let Some(marker) = GUEST_FAILURE_MARKERS + .iter() + .find(|marker| text.contains(*marker)) + { + return Err(runtime_error( + RuntimeRejection::GuestFailure, + format!( + "Viceroy {stream} reports {marker:?}; stderr tail: {}", + sanitized_tail(stderr) + ), + )); + } + } + Ok(ViceroyOutput { + stdout_sha256: hex::encode(Sha256::digest(stdout)), + stderr_sha256: hex::encode(Sha256::digest(stderr)), + stderr_tail: sanitized_tail(stderr), + }) +} diff --git a/crates/trusted-server-release/src/recipe/runtime/viceroy.rs b/crates/trusted-server-release/src/recipe/runtime/viceroy.rs new file mode 100644 index 0000000000..18b83abd13 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/runtime/viceroy.rs @@ -0,0 +1,160 @@ +//! Explicit selection of the pinned Viceroy executable. +//! +//! The gate never searches `PATH`: the caller names one absolute regular +//! file. Selection hashes it, requires `--version` to report exactly +//! [`PINNED_VICEROY_VERSION`], and rehashes it so the reported version and +//! the recorded digest describe the same bytes. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use error_stack::Report; + +use super::{RuntimeRejection, runtime_error}; +use crate::documents::hash_bounded_with_check; +use crate::error::ReleaseError; +use crate::process::{BoundedCommand, ProcessLimits}; + +/// The Viceroy version pinned by `.tool-versions`. +pub(crate) const PINNED_VICEROY_VERSION: &str = "0.17.0"; +/// Upper bound on the Viceroy executable size. +const VICEROY_LIMIT: u64 = 512 * 1024 * 1024; + +/// A selected, version-checked and hashed Viceroy executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SelectedViceroy { + path: PathBuf, + version: String, + sha256: String, +} + +fn selection_error(message: impl std::fmt::Display) -> Report { + runtime_error(RuntimeRejection::Viceroy, message) +} + +impl SelectedViceroy { + /// Selects the Viceroy executable at the absolute `path`. + /// + /// # Errors + /// + /// Returns [`RuntimeRejection::Viceroy`] when `path` is not absolute, is + /// not a regular file, cannot be hashed, does not report exactly the + /// pinned version, or changes while it is checked. + pub(crate) fn select(path: &Path) -> Result> { + if !path.is_absolute() { + return Err(selection_error("Viceroy path is not absolute")); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| selection_error(format!("cannot inspect Viceroy: {error}")))?; + if !metadata.is_file() { + return Err(selection_error("Viceroy is not a regular file")); + } + let sha256 = digest(path)?; + let working_directory = path + .parent() + .ok_or_else(|| selection_error("Viceroy path has no parent directory"))?; + let stdout = BoundedCommand::new( + path, + working_directory, + ProcessLimits { + wall: Duration::from_secs(60), + stdout_max: 4096, + stderr_max: 4096, + }, + ) + .map_err(|error| error.change_context(ReleaseError::Runtime))? + .arguments([OsString::from("--version")]) + .run() + .and_then(crate::process::ProcessResult::require_success) + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Viceroy) + })?; + let version = parse_version_output(&stdout)?; + let selected = Self { + path: path.to_owned(), + version, + sha256, + }; + selected.reverify()?; + Ok(selected) + } + + /// The selected absolute path. + pub(crate) fn path(&self) -> &Path { + &self.path + } + + /// The reported version, equal to [`PINNED_VICEROY_VERSION`]. + pub(crate) fn version(&self) -> &str { + &self.version + } + + /// SHA-256 of the selected executable. + pub(crate) fn sha256(&self) -> &str { + &self.sha256 + } + + /// Requires the executable to still hash to the recorded digest. + /// + /// # Errors + /// + /// Returns [`RuntimeRejection::Viceroy`] when it cannot be hashed or its + /// bytes changed. + pub(crate) fn reverify(&self) -> Result<(), Report> { + if digest(&self.path)? != self.sha256 { + return Err(selection_error( + "Viceroy executable changed after selection", + )); + } + Ok(()) + } +} + +#[cfg(test)] +impl SelectedViceroy { + /// A selection that was never version-checked, for tests that must be + /// rejected before Viceroy would run. + pub(crate) fn unverified_for_tests(path: &Path, sha256: &str) -> Self { + Self { + path: path.to_owned(), + version: PINNED_VICEROY_VERSION.to_owned(), + sha256: sha256.to_owned(), + } + } +} + +fn digest(path: &Path) -> Result> { + hash_bounded_with_check(path, VICEROY_LIMIT, &|| Ok(())) + .map(|measured| measured.sha256) + .map_err(|error| { + error + .change_context(ReleaseError::Runtime) + .attach(RuntimeRejection::Viceroy) + }) +} + +/// Parses `viceroy \n` and requires the pinned version. +/// +/// # Errors +/// +/// Returns [`RuntimeRejection::Viceroy`] for any other output, including a +/// different, prefixed or suffixed version. +pub(in crate::recipe) fn parse_version_output( + stdout: &[u8], +) -> Result> { + let version = std::str::from_utf8(stdout) + .ok() + .and_then(|text| text.strip_suffix('\n')) + .and_then(|line| line.strip_prefix("viceroy ")) + .filter(|version| !version.contains(char::is_whitespace)) + .ok_or_else(|| selection_error("unrecognized Viceroy version output"))?; + if version != PINNED_VICEROY_VERSION { + return Err(selection_error(format!( + "Viceroy reports {version:?}, not the pinned {PINNED_VICEROY_VERSION}" + ))); + } + Ok(version.to_owned()) +} diff --git a/crates/trusted-server-release/src/recipe/tests.rs b/crates/trusted-server-release/src/recipe/tests.rs index c1b2761d55..cb126aa2bd 100644 --- a/crates/trusted-server-release/src/recipe/tests.rs +++ b/crates/trusted-server-release/src/recipe/tests.rs @@ -340,6 +340,7 @@ mod fixture; mod full; mod plan; mod remap; +mod runtime; mod sources; mod tools; diff --git a/crates/trusted-server-release/src/recipe/tests/candidate.rs b/crates/trusted-server-release/src/recipe/tests/candidate.rs index 1e52ede29e..0d2021699e 100644 --- a/crates/trusted-server-release/src/recipe/tests/candidate.rs +++ b/crates/trusted-server-release/src/recipe/tests/candidate.rs @@ -18,7 +18,7 @@ use crate::boundary::DockerSettings; use crate::process::ProcessLimits; /// Schema version of `fixture.json` and of every measurement. -const SCHEMA_VERSION: u64 = 1; +pub(super) const SCHEMA_VERSION: u64 = 1; /// Kind of the Cargo fixture description. const FIXTURE_KIND: &str = "ordinary-ci-cargo-fixture"; /// How the fixture inputs were acquired. diff --git a/crates/trusted-server-release/src/recipe/tests/full.rs b/crates/trusted-server-release/src/recipe/tests/full.rs index f5bd54fee1..220ccfab17 100644 --- a/crates/trusted-server-release/src/recipe/tests/full.rs +++ b/crates/trusted-server-release/src/recipe/tests/full.rs @@ -12,9 +12,14 @@ use std::time::{Duration, Instant}; use super::super::ambient::ObservedAmbient; use super::super::compare::{FASTLY_ASSET, check_fastly_equality}; +use super::super::compare::{FastlyEqualityChecked, read_artifact}; use super::super::remap::{RemapScan, scan_remapped_bytes}; +use super::super::runtime::{ + FastlyRuntimeAccepted, HealthOnlySuccess, OriginPresence, RuntimeRejection, SelectedViceroy, + accept_fastly_runtime, run_gate, +}; use super::candidate::{ - CargoFixture, cargo_docker_settings, cargo_measurement, denial_measurement, + CargoFixture, SCHEMA_VERSION, cargo_docker_settings, cargo_measurement, denial_measurement, equality_measurement, reported_errno, }; use super::*; @@ -473,7 +478,7 @@ fn scan_final_bytes(fixture: &CargoFixture, builds: [&FastlyBuild; 2]) -> RemapS } #[test] -#[ignore = "requires explicit JS and Cargo ordinary CI fixtures and Linux Docker; run separately in producer CI"] +#[ignore = "requires explicit JS and Cargo ordinary CI fixtures, Linux Docker and the pinned Viceroy; run separately in producer CI"] fn fastly_canonical_and_reproduction_builds_are_byte_identical() { let fixture = cargo_fixture(); let js = run_two_fresh_js_builds(); @@ -517,4 +522,118 @@ fn fastly_canonical_and_reproduction_builds_are_byte_identical() { text, ) .expect("should retain the canonical/reproduction equality measurement"); + run_runtime_gate(&RuntimeGateInputs { + fixture: &fixture, + source: &js.source, + canonical: &canonical, + reproduction: &reproduction, + checked: &checked, + }); +} + +/// Everything the chained runtime gate needs from the equality run. +struct RuntimeGateInputs<'a> { + fixture: &'a CargoFixture, + source: &'a ApprovedSource, + canonical: &'a FastlyBuild, + reproduction: &'a FastlyBuild, + checked: &'a FastlyEqualityChecked, +} + +/// Selects the pinned Viceroy named by `TS_RELEASE_VICEROY`. +fn pinned_viceroy() -> SelectedViceroy { + let path = std::env::var_os("TS_RELEASE_VICEROY") + .map(PathBuf::from) + .expect("should explicitly supply the pinned Viceroy executable"); + SelectedViceroy::select(&path).expect("should select the pinned Viceroy 0.17.0") +} + +/// The ordinary CI measurement of one accepted runtime gate run. +fn accepted_measurement(accepted: &FastlyRuntimeAccepted) -> serde_json::Value { + let source_commit = accepted.source_commit().as_str(); + let accepted = accepted.observed(); + let publisher = accepted.publisher(); + let output = accepted.viceroy_output(); + serde_json::json!({ + "source_commit": source_commit, + "viceroy_output": { + "stdout_sha256": output.stdout_sha256, + "stderr_sha256": output.stderr_sha256, + "stderr_tail": output.stderr_tail, + }, + "artifact": { + "sha256": accepted.artifact_sha256(), + "size": accepted.artifact_size(), + }, + "fixture_sha256": accepted.fixture_sha256(), + "config_sha256": accepted.config_sha256(), + "health_status": accepted.health_status(), + "publisher": { + "status": publisher.status, + "body_size": publisher.body_size, + "body_sha256": publisher.body_sha256, + "script_src": publisher.script_src, + }, + "origin_requests": accepted.origin_requests(), + "startup_seconds": accepted.startup().as_secs_f64(), + "viceroy_outcome": accepted.viceroy_outcome().to_string(), + }) +} + +/// Runs the final-byte runtime gate on both equal outputs, then proves that +/// a run with a closed origin is rejected although its health check passes. +fn run_runtime_gate(inputs: &RuntimeGateInputs<'_>) { + let RuntimeGateInputs { + fixture, + source, + canonical, + reproduction, + checked, + } = *inputs; + let viceroy = pinned_viceroy(); + let mut builds = serde_json::Map::new(); + for (role, build) in [("canonical", canonical), ("reproduction", reproduction)] { + let accepted = accept_fastly_runtime(source, &build.completed, checked, &viceroy) + .unwrap_or_else(|error| { + panic!("should accept the {role} final bytes through Viceroy: {error:?}") + }); + assert_eq!( + accepted.observed().artifact_sha256(), + checked.sha256(), + "should execute the exact accepted {role} bytes" + ); + builds.insert(role.to_owned(), accepted_measurement(&accepted)); + } + let bytes = read_artifact(&canonical.completed).expect("should reread the canonical bytes"); + let rejected = run_gate(&bytes, &viceroy, OriginPresence::Closed) + .expect_err("should reject a run whose origin is unreachable"); + assert!( + rejected.downcast_ref::().is_some(), + "should reject a health-only success: {rejected:?}" + ); + canonical + .completed + .rehash_for_handoff() + .expect("should leave the canonical artifact unchanged"); + let measurement = serde_json::json!({ + "schema_version": SCHEMA_VERSION, + "kind": "ordinary-ci-fastly-runtime-gate", + "asset": checked.asset().artifact().name, + "viceroy": { + "version": viceroy.version(), + "sha256": viceroy.sha256(), + }, + "builds": builds, + "health_only_rejection": rejected + .downcast_ref::() + .map(ToString::to_string), + "release_qualification": false, + }); + let text = serde_json::to_string_pretty(&measurement).expect("should encode the measurement"); + println!("{text}"); + fs::write( + fixture.root.join("fastly-runtime-gate-measurement.json"), + text, + ) + .expect("should retain the runtime gate measurement"); } diff --git a/crates/trusted-server-release/src/recipe/tests/runtime.rs b/crates/trusted-server-release/src/recipe/tests/runtime.rs new file mode 100644 index 0000000000..d5032938d9 --- /dev/null +++ b/crates/trusted-server-release/src/recipe/tests/runtime.rs @@ -0,0 +1,619 @@ +//! Pure tests of the final-byte Fastly runtime gate. +//! +//! None of these start Viceroy. The real gate runs only in the ordinary CI +//! canonical and reproduction job (see `full.rs`). + +use std::net::SocketAddr; +use std::time::Duration; + +use error_stack::Report; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::super::runtime::tests_support::{ + HttpResponse, OriginFixture, app_config_data, canonical, check_publisher_response, + config_store_id, envelope_json, get, is_health_ok, origin_page, parse_response, + parse_version_output, require_built_source_fixtures, sanitized_tail, scan_viceroy_output, + viceroy_config, +}; +use super::super::runtime::{ + OriginPresence, PINNED_VICEROY_VERSION, RuntimeRejection, SelectedViceroy, run_gate, +}; +use crate::error::ReleaseError; +use crate::inspect::Violation; +use crate::inspect::tests::builders::{Entry, append_customs, command_module}; + +const TOOL_VERSIONS: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.tool-versions")); +const FASTLY_TOML: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../fastly.toml")); +const ORIGIN: &str = "http://127.0.0.1:9999"; +const MARKER: &str = "trusted-server-release-origin-0123456789abcdef01234567"; +const SCRIPT: &str = + ""; + +/// Returns the attached [`RuntimeRejection`] of a runtime rejection. +fn rejection(result: Result>) -> RuntimeRejection { + let report = result.expect_err("should reject"); + assert!( + matches!(report.current_context(), ReleaseError::Runtime), + "should report a runtime rejection: {report:?}" + ); + *report + .downcast_ref::() + .expect("should attach the runtime rejection") +} + +/// Returns the attached inspection [`Violation`]. +fn violation(result: Result>) -> Violation { + let report = result.expect_err("should reject"); + assert!( + matches!(report.current_context(), ReleaseError::Inspect), + "should report an inspection rejection: {report:?}" + ); + *report + .downcast_ref::() + .expect("should attach the violated rule") +} + +#[test] +fn pinned_viceroy_version_matches_tool_versions() { + let pinned = TOOL_VERSIONS + .lines() + .find_map(|line| line.strip_prefix("viceroy")) + .map(str::trim) + .expect("should pin Viceroy in .tool-versions"); + assert_eq!( + pinned, PINNED_VICEROY_VERSION, + "should gate on the tracked pin" + ); +} + +#[test] +fn viceroy_version_output_must_be_exactly_the_pin() { + assert_eq!( + parse_version_output(b"viceroy 0.17.0\n").expect("should accept the pinned output"), + "0.17.0" + ); + for output in [ + &b""[..], + b"viceroy 0.17.0", + b"viceroy 0.17.1\n", + b"viceroy 0.16.0\n", + b"viceroy 0.17.0-rc.1\n", + b"Viceroy 0.17.0\n", + b"viceroy 0.17.0\n", + b"viceroy 0.17.0\nextra\n", + b"fastly 0.17.0\n", + b"viceroy \xff\n", + ] { + assert_eq!( + rejection(parse_version_output(output)), + RuntimeRejection::Viceroy, + "should reject version output {:?}", + String::from_utf8_lossy(output) + ); + } +} + +#[test] +fn viceroy_selection_requires_an_absolute_regular_file() { + let directory = tempfile::tempdir().expect("should create a directory"); + for path in [ + std::path::Path::new("viceroy"), + directory.path(), + &directory.path().join("missing"), + ] { + assert_eq!( + rejection(SelectedViceroy::select(path)), + RuntimeRejection::Viceroy, + "should reject Viceroy at {path:?} without running it" + ); + } +} + +#[test] +fn viceroy_selection_rejects_a_symlink() { + let directory = tempfile::tempdir().expect("should create a directory"); + let target = directory.path().join("viceroy-target"); + std::fs::write(&target, b"example executable").expect("should write a stand-in"); + let link = directory.path().join("viceroy"); + std::os::unix::fs::symlink(&target, &link).expect("should create a symlink"); + + assert_eq!( + rejection(SelectedViceroy::select(&link)), + RuntimeRejection::Viceroy, + "should reject a symlinked Viceroy without following or running it" + ); +} + +/// Returns the SHA-256 of a repository file, as the built source tracks it. +fn repository_sha256(path: &str) -> Option { + let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).ok()?; + Some(hex::encode(Sha256::digest(bytes))) +} + +#[test] +fn embedded_fixtures_must_match_the_built_source() { + require_built_source_fixtures(&repository_sha256) + .expect("should accept a built source tracking the embedded fixtures"); + + let changed = |path: &str| { + if path == "edgezero.toml" { + Some("0".repeat(64)) + } else { + repository_sha256(path) + } + }; + let untracked = |path: &str| { + if path.ends_with("viceroy-template.toml") { + None + } else { + repository_sha256(path) + } + }; + for (label, tracked) in [ + ( + "changed manifest", + &changed as &dyn Fn(&str) -> Option, + ), + ("untracked template", &untracked), + ] { + let report = require_built_source_fixtures(tracked).expect_err("should fail closed"); + assert_eq!( + report.downcast_ref::().copied(), + Some(RuntimeRejection::Fixture), + "should reject a {label}" + ); + assert!( + format!("{report:?}").contains("built source"), + "should name the built source difference for a {label}: {report:?}" + ); + } +} + +#[test] +fn clean_viceroy_output_is_summarized() { + let output = scan_viceroy_output(b"Listening on http://127.0.0.1:7676\n", b"") + .expect("should accept clean output"); + assert_eq!( + output.stderr_sha256, + hex::encode(Sha256::digest(b"")), + "should digest the empty stderr" + ); + assert_eq!(output.stderr_tail, ""); +} + +#[test] +fn guest_failures_in_viceroy_output_are_rejected() { + let cases: [(&str, &[u8], &[u8]); 5] = [ + ( + "wasm trap", + b"", + b"2026-10-16T00:00:00Z ERROR request failed: wasm trap: wasm `unreachable` instruction executed\n", + ), + ( + "guest panic", + b"thread '' panicked at src/example.rs:1:1:\nexample\n", + b"", + ), + ( + "error line", + b"", + b"Error: hyper::Error(Connect, \"example\")\n", + ), + ( + "request handling error", + b"", + b"WARN There was an error handling the request: example\n", + ), + ("backtrace", b"wasm backtrace:\n 0: example\n", b""), + ]; + for (label, stdout, stderr) in cases { + assert_eq!( + rejection(scan_viceroy_output(stdout, stderr)), + RuntimeRejection::GuestFailure, + "should reject a {label}" + ); + } +} + +#[test] +fn stderr_tail_is_bounded_and_printable() { + let mut stderr = vec![b'x'; 4096]; + stderr.extend_from_slice(b"\x1b[31mred\x07\nend"); + let tail = sanitized_tail(&stderr); + assert!(tail.len() <= 1024, "should keep a bounded tail"); + assert!( + tail.ends_with("?[31mred?\nend"), + "should replace control characters but keep newlines: {tail:?}" + ); +} + +#[test] +fn config_store_id_matches_the_fastly_local_store() { + let id = config_store_id().expect("should read the default config store"); + let fastly: toml::Table = toml::from_str(FASTLY_TOML).expect("should parse fastly.toml"); + assert!( + fastly["local_server"]["config_stores"].get(&id).is_some(), + "should name a config store the Fastly local server declares: {id}" + ); +} + +#[test] +fn canonical_form_sorts_keys_escapes_and_rejects_floats() { + let value = json!({"b": 1, "a": [true, null, "x\n\"\u{1}"], "B": {"z": -2, "y": {}}}); + assert_eq!( + canonical(&value).expect("should encode integers"), + r#"{"B":{"y":{},"z":-2},"a":[true,null,"x\n\"\u0001"],"b":1}"#, + "should sort keys by bytes and use the fixed escape table" + ); + assert_eq!( + rejection(canonical(&json!({"ratio": 1.5}))), + RuntimeRejection::Fixture, + "should reject floats" + ); +} + +#[test] +fn app_config_keeps_secret_names_and_replaces_only_the_origin() { + let data = app_config_data(ORIGIN).expect("should convert the integration fixture"); + assert_eq!( + data["publisher"]["origin_url"], ORIGIN, + "should override the origin" + ); + assert_eq!( + data["publisher"]["proxy_secret"], "integration_proxy_secret", + "should keep secret store key names, not values" + ); + assert_eq!(data["publisher"]["domain"], "localhost"); +} + +#[test] +fn envelope_binds_the_canonical_data_digest() { + let data = app_config_data(ORIGIN).expect("should convert the fixture"); + let envelope: Value = serde_json::from_str(&envelope_json(&data).expect("should wrap")) + .expect("should parse the envelope"); + let expected = hex::encode(Sha256::digest( + canonical(&data).expect("should encode").as_bytes(), + )); + assert_eq!(envelope["sha256"], Value::String(expected)); + assert_eq!(envelope["version"], 1); + assert_eq!(envelope["generated_at"], "2026-06-23T00:00:00Z"); + assert_eq!(envelope["data"], data, "should carry the data unchanged"); +} + +#[test] +fn viceroy_config_is_exact_and_deterministic() { + let generated = viceroy_config(ORIGIN).expect("should generate the config"); + let again = viceroy_config(ORIGIN).expect("should generate the config again"); + assert_eq!(generated, again, "should be deterministic"); + assert_eq!( + generated.sha256, + hex::encode(Sha256::digest(generated.text.as_bytes())), + "should digest the exact text" + ); + let parsed: toml::Table = toml::from_str(&generated.text).expect("should parse as TOML"); + let id = config_store_id().expect("should read the store ID"); + let store = &parsed["local_server"]["config_stores"][id.as_str()]; + assert_eq!(store["format"].as_str(), Some("inline-toml")); + let envelope: Value = serde_json::from_str( + store["contents"][id.as_str()] + .as_str() + .expect("should store the envelope under the store ID"), + ) + .expect("should parse the stored envelope"); + assert_eq!(envelope["data"]["publisher"]["origin_url"], ORIGIN); + assert!( + parsed["local_server"]["config_stores"] + .get("jwks_store") + .is_some() + && parsed["local_server"]["secret_stores"] + .get("trusted_server_secrets") + .is_some(), + "should keep the rest of the integration template" + ); + assert!( + !generated + .text + .contains("GENERATED_TRUSTED_SERVER_CONFIG_STORES"), + "should replace the marker" + ); + + let other = viceroy_config("http://127.0.0.1:9998").expect("should generate another"); + assert_ne!(other.sha256, generated.sha256, "should bind the origin"); + assert_eq!( + other.fixture_sha256, generated.fixture_sha256, + "should identify the same fixed fixture" + ); +} + +fn html_response(status: u16, body: &str) -> HttpResponse { + HttpResponse { + status, + headers: vec![( + "content-type".to_owned(), + "text/html; charset=utf-8".to_owned(), + )], + body: body.as_bytes().to_vec(), + } +} + +fn injected_page() -> String { + origin_page(MARKER).replacen("", &format!("{SCRIPT}"), 1) +} + +#[test] +fn injected_origin_page_is_accepted() { + let page = injected_page(); + let observed = + check_publisher_response(&html_response(200, &page), MARKER).expect("should accept"); + assert_eq!( + observed.script_src, + "/static/tsjs=tsjs-unified.min.js?v=0123abcd" + ); + assert_eq!(observed.body_size, page.len()); + assert_eq!(observed.status, 200); +} + +#[test] +fn publisher_response_negatives_are_rejected() { + let page = injected_page(); + let health = HttpResponse { + status: 200, + headers: vec![("content-type".to_owned(), "text/plain".to_owned())], + body: b"ok".to_vec(), + }; + let cases: [(&str, HttpResponse, RuntimeRejection); 9] = [ + ( + "bad gateway", + html_response(502, &page), + RuntimeRejection::Status, + ), + ("health-only answer", health, RuntimeRejection::Injection), + ( + "uninjected origin page", + html_response(200, &origin_page(MARKER)), + RuntimeRejection::Injection, + ), + ( + "duplicated script", + html_response(200, &page.replacen("", &format!("{SCRIPT}"), 1)), + RuntimeRejection::Injection, + ), + ( + "wrong script source", + html_response(200, &page.replace("/static/tsjs=", "/static/other=")), + RuntimeRejection::Injection, + ), + ( + "id on a non-script element", + html_response(200, &page.replace("