commit b44119fbac5985be8127ad1bf56d2950e6399427 parent 37990e397ea6836ea83e38aa88acff9f97173b2b Author: triesap <tyson@radroots.org> Date: Fri, 21 Aug 2026 08:41:19 +0000 build: establish native service release foundation - freeze the native build qualification contract and exact fixture metadata - bind source locks to Cargo, release artifacts, SBOM, provenance, and checksums - route governed qualification through extbuild while deferring Nix outputs - verify the exact tree with workspace, coverage, release, target, and API gates Diffstat:
15 files changed, 794 insertions(+), 50 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md @@ -61,6 +61,13 @@ This file exists for compatibility with tools that look for AGENTS.md. archive, OCI/source metadata, CycloneDX SBOM, notices, manifest, unsigned provenance signing input, and checksums. Signing credentials and signatures remain external; generated artifacts must contain no protected material. +- The native shared-build qualification contract is + `contracts/architecture/decisions/services_hardening_build_qualification.v1.json`. + It freezes the supported Rust targets, standalone Cargo and xtask commands, + native release evidence, and the fixture agreement among Cargo metadata, + the source lock, and release metadata. Nix package/app/check, development + shell, NixOS-module, and Nix-produced OCI outputs are explicitly deferred + and are not qualified by that contract. - Current source and tests are implementation evidence. They do not silently override `radroots.crates.release.v1`. - Record any evidence-based plan deviation in @@ -74,7 +81,11 @@ This file exists for compatibility with tools that look for AGENTS.md. ## 3. Repository operating model - This is a public open-source library workspace; optimize for durable library design, portability, determinism, and explicit contracts. -- Keep release and validation automation forge-agnostic; repo-owned xtask commands, Nix apps, tags, and contract metadata are canonical, while committed provider-specific workflow automation is not. +- Keep release and validation automation forge-agnostic; repo-owned xtask + commands, native Cargo lanes, tags, and contract metadata are canonical, + while committed provider-specific workflow automation is not. Checked-in + Nix surfaces are deferred compatibility inputs, not current qualification + authority. - Do not add or retain tracked `docs/**`, `.github/**`, or `.act/**` content. Keep validation forge-agnostic. Any required monorepo orchestration belongs exclusively to the parent repository's root `.act/**` authority and must not @@ -92,8 +103,12 @@ This file exists for compatibility with tools that look for AGENTS.md. Before editing code: - Read this file, `AGENT_INSTRUCTIONS.md`, and `README`. -- When touching Nix behavior, read `flake.nix` and the active Nix implementation files under `build/nix/`. -- Enter the canonical environment with `nix develop` or `direnv allow` before targeted cargo work. +- When preserving deferred Nix behavior, read `flake.nix` and the relevant + implementation files under `build/nix/`, but do not install, invoke, or + require Nix as part of current qualification. +- Run `cargo extbuild doctor` before the first governed build, test, check, + generation, package, artifact, or release-preflight command, then route the + command through `cargo extbuild run --`. - Discover commands from checked-in repo surfaces; do not invent ad hoc workflows. - Read the current implementation and nearby tests before designing a change. - Inspect `git status --short` before broad edits or refactors. @@ -101,15 +116,17 @@ Before editing code: ## 5. Canonical command surface -- `nix flake check` -- `nix run .#contract` -- `nix run .#release-preflight` -- `cargo xtask architecture` for controlled deviation records and local spec +- `cargo extbuild run -- cargo check --workspace --all-targets --locked` +- `cargo extbuild run -- cargo test --workspace --all-targets --locked` +- `cargo extbuild run -- cargo xtask contract validate` +- `cargo extbuild run -- cargo xtask release preflight` +- `cargo extbuild run -- cargo xtask architecture` for controlled deviation records and local spec anchors - Public API baselines live in `contracts/api_baselines/**`. Regenerate one with `cargo-public-api` `0.52.0` and rustdoc JSON from `nightly-2026-07-16`, writing the reviewed output back to that directory. -- targeted `cargo check -p <crate>` and `cargo test -p <crate>` only inside the Nix shell +- targeted `cargo check -p <crate>` and `cargo test -p <crate>` through + `cargo extbuild run --` - `cargo xtask dto-roots --write` after changing configured DTO exports and `cargo xtask dto-roots --check` for exact generated-root freshness - targeted `cargo xtask contract ...`, `cargo xtask coverage ...`, `cargo xtask release ...`, or `cargo xtask hygiene ...` only when narrowing a repo-owned workflow @@ -149,7 +166,9 @@ Before editing code: adding commit from repository history and verifies its immutable tree. Do not rewrite that digest for later source changes. - Behavior changes that affect public surfaces must update the relevant contract metadata, conformance vectors, export rules, or validation flows in the same change. -- Keep pure flake checks and repo-aware command apps aligned with the documented Nix command map. +- Preserve deferred flake expressions as unqualified compatibility inputs; + do not use their evaluation or outputs as evidence until an accepted + contract explicitly reactivates them. - This repository owns packages 1-17 in `radroots.crates.release.v1`, from `radroots_core` through `radroots_geonames`. `radroots_sdk` and `radroots` remain owned by the standalone SDK repository. diff --git a/AGENT_INSTRUCTIONS.md b/AGENT_INSTRUCTIONS.md @@ -32,7 +32,10 @@ Stay disciplined: - do not leave dead paths, temporary adapters, or silent fallback behavior behind This repo is a library workspace, not an app monolith. The right default is small, durable changes that preserve clean crate boundaries. -Release automation should stay forge-agnostic. Keep release truth in repo-owned xtask commands, Nix apps, tags, and contract metadata rather than committed provider-specific workflow files. +Release automation should stay forge-agnostic. Keep release truth in repo-owned +xtask commands, native Cargo lanes, tags, and contract metadata rather than +committed provider-specific workflow files. Checked-in Nix surfaces are +deferred compatibility inputs and are not current qualification authority. ## 3. Preflight workflow @@ -41,14 +44,17 @@ Before editing code: - Read `AGENTS.md`. - Read this file. - Read `README` when the change touches workflow or public surfaces. -- When touching Nix behavior, read `flake.nix` and the active Nix implementation files under `build/nix/`. +- When preserving deferred Nix behavior, read `flake.nix` and the relevant + implementation files under `build/nix/`, but do not install, invoke, or + require Nix as part of current qualification. - Read the relevant crate manifest, implementation files, and nearby tests before proposing a new structure. - Check `git status --short`. -Before running cargo commands: +Before running governed build, test, check, generation, package, artifact, or +release-preflight commands: -- Prefer `nix develop` or `direnv allow`. -- Treat Nix as the canonical environment contract. +- Run `cargo extbuild doctor` once for the working session. +- Route the command through `cargo extbuild run --`. - Prefer the documented repo-owned command surface over improvised local commands. Fail early when: @@ -75,7 +81,8 @@ Use this mental model: - `contracts/conformance/` - cross-language and cross-surface vector expectations - `build/nix/`, `flake.nix`, `treefmt.nix` - - canonical environment and CI contract + - deferred compatibility surfaces whose evaluation and outputs are not + current qualification evidence - `tools/xtask/` - typed repo-owned automation used by canonical lanes @@ -194,20 +201,21 @@ authority to expand it. Use the smallest authoritative lane that proves the change green. -Repo-wide canonical lanes: +Repo-wide canonical lanes, all routed through `cargo extbuild run --`: -- `nix flake check` -- `nix run .#contract` -- `nix run .#release-preflight` +- `cargo check --workspace --all-targets --locked` +- `cargo test --workspace --all-targets --locked` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo doc --workspace --no-deps` +- `cargo xtask contract validate` +- `cargo xtask release preflight` -Targeted iteration inside the Nix shell: +Targeted iteration, also routed through `cargo extbuild run --`: - `cargo check -p <crate>` - `cargo test -p <crate>` -- `cargo xtask contract validate` - `cargo xtask dto-roots --check` - `cargo xtask dto-roots --write` after changing configured DTO exports -- `cargo xtask release preflight` - `cargo xtask hygiene forbidden-identifiers` - `cargo xtask hygiene prototype-contracts` for the deterministic report-only service-prototype census; strict mode is enabled only after the owning @@ -216,7 +224,10 @@ Targeted iteration inside the Nix shell: Validation rules: - crate-local changes may iterate with targeted cargo commands -- contract, export, conformance, flake, release, or multi-crate changes should close on a canonical Nix lane +- contract, export, conformance, release, or multi-crate changes should close + on the applicable extbuild-routed repository-wide lanes +- Nix evaluation and Nix-derived package, app, check, development-shell, + NixOS-module, and OCI outputs remain explicitly deferred and unclaimed - deterministic tests are required for new behavior and edge cases - do not rely on wall-clock time, random order, external network access, or ambient machine state in unit tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -26,10 +26,12 @@ repository owns its first 17 public packages, from `radroots_core` through 5. Review the staged diff for API leakage, private dependencies, generated drift, secrets, hidden side effects, and unrelated changes. -Canonical repository-wide lanes are `nix flake check`, -`nix run .#contract`, and `nix run .#release-preflight`. Targeted Rust work is -performed in the repository's Nix environment with the applicable format, -check, test, Clippy, contract, coverage, and generated-freshness commands. +Run `cargo extbuild doctor` before governed verification. Canonical +repository-wide lanes are the extbuild-routed workspace format, check, test, +Clippy, Rustdoc, contract, release-preflight, coverage, and +generated-freshness commands. Nix evaluation and Nix-derived outputs are +currently deferred and unclaimed; they are not prerequisites for native +qualification. ## Commits and deviations diff --git a/README.md b/README.md @@ -53,19 +53,25 @@ println!("Published: {}", receipt.address()); > **Status:** Alpha (`0.1.0-alpha`) > Still a `draft`. Not recommended for use before `0.1.0` is published. -The Radroots libraries are a Rust workspace with a small set of native build dependencies. Nix is recommended; the manual setup below covers the minimum requirements on macOS and Linux. See [`BUILD.md`](BUILD.md) for the complete build guide. +The Radroots libraries are a Rust workspace with a small set of native build +dependencies. Governed repository commands use `cargo extbuild`; see +[`BUILD.md`](BUILD.md) for the complete native setup guide. -#### Nix (*recommended*) +#### Governed development -The Nix development shell provides a configured environment on macOS and Linux: +After installing the native dependencies below and configuring extbuild: ```sh git clone https://radroots.dev/git/lib.git && cd lib -nix develop -cargo check --workspace --locked +cargo extbuild doctor +cargo extbuild run -- cargo check --workspace --locked ``` +Checked-in Nix expressions are deferred compatibility surfaces. Nix +evaluation and Nix-derived packages, apps, checks, development shells, NixOS +modules, and OCI outputs are not current qualification prerequisites. + #### macOS and Linux To setup manually, install [Rust](https://rustup.rs/), Git, LLVM/Clang with `libclang`, `pkg-config`, and `libsodium`. diff --git a/contracts/architecture/decisions/services_hardening_build_qualification.v1.json b/contracts/architecture/decisions/services_hardening_build_qualification.v1.json @@ -0,0 +1,54 @@ +{ + "schema": "radroots.services-hardening.build-qualification-decisions.v1", + "contract_version": 1, + "decision_state": "active", + "qualification_scope": "native_release_foundation", + "fixture_root": "tools/xtask/fixtures/service-build-qualification", + "supported_rust_targets": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu" + ], + "required_native_commands": [ + "cargo build --locked --release", + "cargo fmt --all --check", + "cargo check --workspace --all-targets --locked", + "cargo test --workspace --all-targets --locked", + "cargo clippy --workspace --all-targets --locked -- -D warnings", + "RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --locked" + ], + "required_xtask_commands": [ + "cargo test --locked -p xtask service_source_lock::tests", + "cargo test --locked -p xtask service_release_artifacts::tests", + "cargo test --locked -p xtask service_build_qualification::tests", + "cargo run --locked -q -p xtask -- contract validate", + "cargo run --locked -q -p xtask -- release preflight" + ], + "required_evidence": [ + "cargo_lock", + "source_lock", + "package_metadata", + "release_metadata", + "binary_archive", + "oci_source_artifact", + "cyclonedx_sbom", + "notices", + "artifact_manifest", + "unsigned_provenance_input", + "checksums" + ], + "fixture_source_lock": "tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml", + "fixture_contract": "source_lock_package_and_release_metadata_exact_agreement", + "release_artifact_command": "cargo xtask service-release-artifacts", + "source_lock_command": "cargo xtask service-source-lock", + "signing_authority": "external_only", + "deferred_outputs": [ + "nix_packages", + "nix_apps", + "nix_checks", + "nix_development_shells", + "nixos_modules", + "nix_produced_oci" + ] +} diff --git a/contracts/architecture/decisions/services_hardening_source_lock.v1.json b/contracts/architecture/decisions/services_hardening_source_lock.v1.json @@ -57,8 +57,8 @@ "too_large" ], "canonical_vector": { - "toml": "schema = \"radroots.service.source-lock.v1\"\ncontract_version = 1\nservice = \"fixture_service\"\nrepository = \"https://github.com/radrootslabs/lib\"\nrevision = \"1111111111111111111111111111111111111111\"\narchitecture = \"radroots.crates.release.v2\"\nworkspace_catalog_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\nversion = \"0.1.0-alpha\"\nsource_archive_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\ncargo_lock_sha256 = \"4444444444444444444444444444444444444444444444444444444444444444\"\nflake_lock_sha256 = \"5555555555555555555555555555555555555555555555555555555555555555\"\nrust_version = \"1.97.1\"\nhost_feature_profile = \"service-host\"\n\n[contract_versions]\nconfig = 1\nstate = 2\nadmin = 3\nstatus = 4\nprovider = 5\n", - "sha256": "2257efc8fb3ff4ee8e429e326effdfe622c5e898b429ee1a8ea3aac38f9810cc" + "toml": "schema = \"radroots.service.source-lock.v1\"\ncontract_version = 1\nservice = \"fixture_service\"\nrepository = \"https://github.com/radrootslabs/lib\"\nrevision = \"2222222222222222222222222222222222222222\"\narchitecture = \"radroots.crates.release.v2\"\nworkspace_catalog_sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\nversion = \"0.1.0-alpha\"\nsource_archive_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\ncargo_lock_sha256 = \"3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0\"\nflake_lock_sha256 = \"13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353\"\nrust_version = \"1.97.1\"\nhost_feature_profile = \"service-host\"\n\n[contract_versions]\nconfig = 1\nstate = 2\nadmin = 3\nstatus = 4\nprovider = 5\n", + "sha256": "7251222df95da414d8cb073b8907f4a53c9ac4c89354bb2d895ac78fab79d81a" }, "operations": { "command": "cargo xtask service-source-lock", @@ -94,7 +94,6 @@ "maximum_source_archive_bytes": 1073741824 }, "deferred_operations": [ - "embedded_build_information_agreement", - "service_fixture_release_graph" + "embedded_build_information_agreement" ] } diff --git a/tools/xtask/README b/tools/xtask/README @@ -22,6 +22,10 @@ tasks for the `radroots` core libraries. source-bundle, SBOM, notice, manifest, provenance-input, and checksum inventory, with signing credentials kept external and operator runbooks deliberately excluded from this standalone build input set; + * exact native service-build qualification validation that binds the Rust + target and standalone command inventories, fixture Cargo/release metadata, + source lock, and release-artifact evidence while keeping Nix outputs + explicitly deferred; * command-dispatch code used for contract, coverage, hygiene, and release paths inside the workspace; * a non-published binary crate used as tooling rather than as a library diff --git a/tools/xtask/fixtures/service-build-qualification/Cargo.lock b/tools/xtask/fixtures/service-build-qualification/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "fixture-service" +version = "0.1.0-alpha" diff --git a/tools/xtask/fixtures/service-build-qualification/Cargo.toml b/tools/xtask/fixtures/service-build-qualification/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "fixture-service" +version = "0.1.0-alpha" +edition = "2024" +publish = false + +[[bin]] +name = "fixture-service" +path = "src/main.rs" + +[workspace] +resolver = "3" + +[workspace.metadata.radroots.service_source_lock] +service = "fixture_service" +host_feature_profile = "service-host" +config_contract_version = 1 +state_contract_version = 2 +admin_contract_version = 3 +status_contract_version = 4 +provider_contract_version = 5 + +[workspace.metadata.radroots.service_release] +service = "fixture_service" +service_package = "fixture-service" +binary_name = "fixture-service" +version = "0.1.0-alpha" diff --git a/tools/xtask/fixtures/service-build-qualification/flake.lock b/tools/xtask/fixtures/service-build-qualification/flake.lock @@ -0,0 +1 @@ +{"nodes":{},"root":"root","version":7} diff --git a/tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml b/tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml @@ -0,0 +1,20 @@ +schema = "radroots.service.source-lock.v1" +contract_version = 1 +service = "fixture_service" +repository = "https://github.com/radrootslabs/lib" +revision = "2222222222222222222222222222222222222222" +architecture = "radroots.crates.release.v2" +workspace_catalog_sha256 = "2222222222222222222222222222222222222222222222222222222222222222" +version = "0.1.0-alpha" +source_archive_sha256 = "3333333333333333333333333333333333333333333333333333333333333333" +cargo_lock_sha256 = "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0" +flake_lock_sha256 = "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353" +rust_version = "1.97.1" +host_feature_profile = "service-host" + +[contract_versions] +config = 1 +state = 2 +admin = 3 +status = 4 +provider = 5 diff --git a/tools/xtask/fixtures/service-build-qualification/src/main.rs b/tools/xtask/fixtures/service-build-qualification/src/main.rs @@ -0,0 +1,14 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + match std::env::args().nth(1).as_deref() { + Some("--help") => { + println!("fixture-service"); + ExitCode::SUCCESS + } + _ => { + eprintln!("usage: fixture-service --help"); + ExitCode::from(2) + } + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -38,6 +38,7 @@ mod release_qualification; mod safety_qualification; #[cfg_attr(coverage_nightly, coverage(off))] mod sdk_generation; +mod service_build_qualification; mod service_release_artifacts; mod service_source_lock; mod service_source_lock_command; @@ -391,6 +392,7 @@ fn validate_contract() -> Result<(), String> { validate_protocol_contracts()?; let root = workspace_root(); service_source_lock::validate_contract(&root)?; + service_build_qualification::validate_contract(&root)?; service_release_artifacts::validate_contract(&root)?; dto_roots::check(&root)?; generate::protocol::check(&root)?; @@ -409,6 +411,7 @@ fn release_preflight() -> Result<(), String> { fn release_preflight_at(root: &Path) -> Result<(), String> { catalog::check(root)?; service_source_lock::validate_contract(root)?; + service_build_qualification::validate_contract(root)?; service_release_artifacts::validate_contract(root)?; for group in ["public_native", "preview", "tools"] { build_control::group_plan(root, group, build_control::Operation::Check, false)?; diff --git a/tools/xtask/src/service_build_qualification.rs b/tools/xtask/src/service_build_qualification.rs @@ -0,0 +1,503 @@ +use std::{fmt, fs, io::Read as _, path::Path}; + +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; + +use crate::service_source_lock::{LOCK_FILENAME, ServiceSourceLockV1}; + +const CONTRACT_RELATIVE: &str = + "contracts/architecture/decisions/services_hardening_build_qualification.v1.json"; +const FIXTURE_RELATIVE: &str = "tools/xtask/fixtures/service-build-qualification"; +const MAX_CONTRACT_BYTES: usize = 32_768; +const MAX_FIXTURE_FILE_BYTES: usize = 1_048_576; + +const SUPPORTED_RUST_TARGETS: [&str; 4] = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +]; +const REQUIRED_XTASK_COMMANDS: [&str; 5] = [ + "cargo test --locked -p xtask service_source_lock::tests", + "cargo test --locked -p xtask service_release_artifacts::tests", + "cargo test --locked -p xtask service_build_qualification::tests", + "cargo run --locked -q -p xtask -- contract validate", + "cargo run --locked -q -p xtask -- release preflight", +]; +const REQUIRED_NATIVE_COMMANDS: [&str; 6] = [ + "cargo build --locked --release", + "cargo fmt --all --check", + "cargo check --workspace --all-targets --locked", + "cargo test --workspace --all-targets --locked", + "cargo clippy --workspace --all-targets --locked -- -D warnings", + "RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --locked", +]; +const REQUIRED_EVIDENCE: [&str; 11] = [ + "cargo_lock", + "source_lock", + "package_metadata", + "release_metadata", + "binary_archive", + "oci_source_artifact", + "cyclonedx_sbom", + "notices", + "artifact_manifest", + "unsigned_provenance_input", + "checksums", +]; +const DEFERRED_OUTPUTS: [&str; 6] = [ + "nix_packages", + "nix_apps", + "nix_checks", + "nix_development_shells", + "nixos_modules", + "nix_produced_oci", +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BuildQualificationError { + InvalidContract, + InvalidFixture, +} + +impl fmt::Display for BuildQualificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidContract => "service build qualification contract is invalid", + Self::InvalidFixture => "service build qualification fixture is invalid", + }) + } +} + +impl std::error::Error for BuildQualificationError {} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BuildQualificationDecision { + schema: String, + contract_version: u32, + decision_state: String, + qualification_scope: String, + fixture_root: String, + supported_rust_targets: Vec<String>, + required_native_commands: Vec<String>, + required_xtask_commands: Vec<String>, + required_evidence: Vec<String>, + fixture_source_lock: String, + fixture_contract: String, + release_artifact_command: String, + source_lock_command: String, + signing_authority: String, + deferred_outputs: Vec<String>, +} + +pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { + validate_contract_inner(workspace_root).map_err(|error| error.to_string()) +} + +fn validate_contract_inner(workspace_root: &Path) -> Result<(), BuildQualificationError> { + let bytes = read_bounded( + &workspace_root.join(CONTRACT_RELATIVE), + MAX_CONTRACT_BYTES, + BuildQualificationError::InvalidContract, + )?; + let decision = serde_json::from_slice::<BuildQualificationDecision>(&bytes) + .map_err(|_| BuildQualificationError::InvalidContract)?; + validate_decision(&decision)?; + validate_fixture(workspace_root) +} + +fn validate_decision(decision: &BuildQualificationDecision) -> Result<(), BuildQualificationError> { + let exact = decision.schema == "radroots.services-hardening.build-qualification-decisions.v1" + && decision.contract_version == 1 + && decision.decision_state == "active" + && decision.qualification_scope == "native_release_foundation" + && decision.fixture_root == FIXTURE_RELATIVE + && decision.supported_rust_targets == SUPPORTED_RUST_TARGETS + && decision.required_native_commands == REQUIRED_NATIVE_COMMANDS + && decision.required_xtask_commands == REQUIRED_XTASK_COMMANDS + && decision.required_evidence == REQUIRED_EVIDENCE + && decision.fixture_source_lock + == "tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v1.toml" + && decision.fixture_contract == "source_lock_package_and_release_metadata_exact_agreement" + && decision.release_artifact_command == "cargo xtask service-release-artifacts" + && decision.source_lock_command == "cargo xtask service-source-lock" + && decision.signing_authority == "external_only" + && decision.deferred_outputs == DEFERRED_OUTPUTS; + if exact { + Ok(()) + } else { + Err(BuildQualificationError::InvalidContract) + } +} + +fn validate_fixture(workspace_root: &Path) -> Result<(), BuildQualificationError> { + let fixture = workspace_root.join(FIXTURE_RELATIVE); + let lock_bytes = read_bounded( + &fixture.join(LOCK_FILENAME), + 4_096, + BuildQualificationError::InvalidFixture, + )?; + let lock = ServiceSourceLockV1::from_canonical_bytes(&lock_bytes) + .map_err(|_| BuildQualificationError::InvalidFixture)?; + let cargo_lock = read_bounded( + &fixture.join("Cargo.lock"), + MAX_FIXTURE_FILE_BYTES, + BuildQualificationError::InvalidFixture, + )?; + let flake_lock = read_bounded( + &fixture.join("flake.lock"), + MAX_FIXTURE_FILE_BYTES, + BuildQualificationError::InvalidFixture, + )?; + let manifest = read_bounded( + &fixture.join("Cargo.toml"), + MAX_FIXTURE_FILE_BYTES, + BuildQualificationError::InvalidFixture, + )?; + let manifest = + std::str::from_utf8(&manifest).map_err(|_| BuildQualificationError::InvalidFixture)?; + let manifest = toml::from_str::<toml::Value>(manifest) + .map_err(|_| BuildQualificationError::InvalidFixture)?; + let source_metadata = manifest + .get("workspace") + .and_then(|value| value.get("metadata")) + .and_then(|value| value.get("radroots")) + .and_then(|value| value.get("service_source_lock")) + .and_then(toml::Value::as_table) + .ok_or(BuildQualificationError::InvalidFixture)?; + let release_metadata = manifest + .get("workspace") + .and_then(|value| value.get("metadata")) + .and_then(|value| value.get("radroots")) + .and_then(|value| value.get("service_release")) + .and_then(toml::Value::as_table) + .ok_or(BuildQualificationError::InvalidFixture)?; + let versions = lock.contract_versions(); + let exact = lock.service() == "fixture_service" + && lock.revision() == "2222222222222222222222222222222222222222" + && lock.cargo_lock_sha256() == digest(&cargo_lock) + && lock.flake_lock_sha256() == digest(&flake_lock) + && versions.config() == 1 + && versions.state() == 2 + && versions.admin() == 3 + && versions.status() == 4 + && versions.provider() == 5 + && manifest + .get("package") + .and_then(|value| value.get("version")) + .and_then(toml::Value::as_str) + == Some("0.1.0-alpha") + && source_metadata.len() == 7 + && source_metadata.get("service").and_then(toml::Value::as_str) == Some("fixture_service") + && source_metadata + .get("host_feature_profile") + .and_then(toml::Value::as_str) + == Some("service-host") + && source_metadata + .get("config_contract_version") + .and_then(toml::Value::as_integer) + == Some(i64::from(versions.config())) + && source_metadata + .get("state_contract_version") + .and_then(toml::Value::as_integer) + == Some(i64::from(versions.state())) + && source_metadata + .get("admin_contract_version") + .and_then(toml::Value::as_integer) + == Some(i64::from(versions.admin())) + && source_metadata + .get("status_contract_version") + .and_then(toml::Value::as_integer) + == Some(i64::from(versions.status())) + && source_metadata + .get("provider_contract_version") + .and_then(toml::Value::as_integer) + == Some(i64::from(versions.provider())) + && release_metadata.len() == 4 + && release_metadata + .get("service") + .and_then(toml::Value::as_str) + == Some("fixture_service") + && release_metadata + .get("service_package") + .and_then(toml::Value::as_str) + == Some("fixture-service") + && release_metadata + .get("binary_name") + .and_then(toml::Value::as_str) + == Some("fixture-service") + && release_metadata + .get("version") + .and_then(toml::Value::as_str) + == Some("0.1.0-alpha"); + if exact { + Ok(()) + } else { + Err(BuildQualificationError::InvalidFixture) + } +} + +fn read_bounded( + path: &Path, + maximum: usize, + error: BuildQualificationError, +) -> Result<Vec<u8>, BuildQualificationError> { + let metadata = fs::symlink_metadata(path).map_err(|_| error)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum as u64 { + return Err(error); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + fs::File::open(path) + .map_err(|_| error)? + .take(maximum as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| error)?; + if bytes.len() > maximum { + Err(error) + } else { + Ok(bytes) + } +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn checked_in_contract_and_fixture_are_exact() { + let root = workspace_root(); + validate_contract_inner(root).expect("build qualification"); + } + + #[test] + fn contract_rejects_every_independent_governed_field_drift() { + let bytes = fs::read(workspace_root().join(CONTRACT_RELATIVE)).expect("decision"); + let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); + for (pointer, replacement) in [ + ("/schema", serde_json::json!("other")), + ("/contract_version", serde_json::json!(2)), + ("/decision_state", serde_json::json!("draft")), + ("/qualification_scope", serde_json::json!("other")), + ("/fixture_root", serde_json::json!("other")), + ("/supported_rust_targets", serde_json::json!([])), + ("/required_native_commands", serde_json::json!([])), + ("/required_xtask_commands", serde_json::json!([])), + ("/required_evidence", serde_json::json!([])), + ("/fixture_source_lock", serde_json::json!("other")), + ("/fixture_contract", serde_json::json!("other")), + ("/release_artifact_command", serde_json::json!("other")), + ("/source_lock_command", serde_json::json!("other")), + ("/signing_authority", serde_json::json!("internal")), + ("/deferred_outputs", serde_json::json!([])), + ] { + let mut drifted = canonical.clone(); + *drifted.pointer_mut(pointer).expect("governed field") = replacement; + let decision = serde_json::from_value::<BuildQualificationDecision>(drifted) + .expect("structurally valid drift"); + assert_eq!( + validate_decision(&decision), + Err(BuildQualificationError::InvalidContract), + "accepted drift at {pointer}" + ); + } + } + + #[test] + fn fixture_rejects_every_identity_and_lockfile_drift() { + for (name, from, to) in [ + ( + "Cargo.toml", + "version = \"0.1.0-alpha\"", + "version = \"0.1.1\"", + ), + ( + "Cargo.toml", + "config_contract_version = 1", + "config_contract_version = 9", + ), + ( + "Cargo.toml", + "service_package = \"fixture-service\"", + "service_package = \"other-service\"", + ), + ( + "radroots.service.source-lock.v1.toml", + "revision = \"2222222222222222222222222222222222222222\"", + "revision = \"3333333333333333333333333333333333333333\"", + ), + ("Cargo.lock", "version = 4", "version = 3"), + ("flake.lock", "\"version\":7", "\"version\":8"), + ] { + let root = copied_fixture(); + let path = root.path().join(FIXTURE_RELATIVE).join(name); + let current = fs::read_to_string(&path).expect("fixture text"); + assert!(current.contains(from), "missing mutation anchor {from}"); + fs::write(&path, current.replacen(from, to, 1)).expect("mutated fixture"); + assert_eq!( + validate_fixture(root.path()), + Err(BuildQualificationError::InvalidFixture) + ); + } + + let root = copied_fixture(); + let manifest = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml"); + let mut current = fs::read_to_string(&manifest).expect("fixture manifest"); + current.push_str("\n[workspace.metadata.radroots.service_release.extra]\nvalue = 1\n"); + fs::write(manifest, current).expect("extra fixture metadata"); + assert_eq!( + validate_fixture(root.path()), + Err(BuildQualificationError::InvalidFixture) + ); + } + + #[test] + fn fixture_rejects_every_independent_metadata_drift() { + for (section, field, replacement) in [ + ( + "service_source_lock", + "service", + toml::Value::String("other".into()), + ), + ( + "service_source_lock", + "host_feature_profile", + toml::Value::String("other".into()), + ), + ( + "service_source_lock", + "config_contract_version", + toml::Value::Integer(9), + ), + ( + "service_source_lock", + "state_contract_version", + toml::Value::Integer(9), + ), + ( + "service_source_lock", + "admin_contract_version", + toml::Value::Integer(9), + ), + ( + "service_source_lock", + "status_contract_version", + toml::Value::Integer(9), + ), + ( + "service_source_lock", + "provider_contract_version", + toml::Value::Integer(9), + ), + ( + "service_release", + "service", + toml::Value::String("other".into()), + ), + ( + "service_release", + "service_package", + toml::Value::String("other".into()), + ), + ( + "service_release", + "binary_name", + toml::Value::String("other".into()), + ), + ( + "service_release", + "version", + toml::Value::String("0.2.0".into()), + ), + ] { + let root = copied_fixture(); + let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml"); + let current = fs::read_to_string(&path).expect("fixture manifest"); + let mut manifest = toml::from_str::<toml::Value>(¤t).expect("fixture toml"); + manifest["workspace"]["metadata"]["radroots"][section][field] = replacement; + fs::write(&path, toml::to_string(&manifest).expect("render fixture")) + .expect("mutated fixture"); + assert_eq!( + validate_fixture(root.path()), + Err(BuildQualificationError::InvalidFixture), + "accepted {section}.{field} drift" + ); + } + + for section in ["service_source_lock", "service_release"] { + let root = copied_fixture(); + let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml"); + let current = fs::read_to_string(&path).expect("fixture manifest"); + let mut manifest = toml::from_str::<toml::Value>(¤t).expect("fixture toml"); + manifest["workspace"]["metadata"]["radroots"][section] + .as_table_mut() + .expect("metadata section") + .insert("extra".into(), toml::Value::Integer(1)); + fs::write(&path, toml::to_string(&manifest).expect("render fixture")) + .expect("mutated fixture"); + assert_eq!( + validate_fixture(root.path()), + Err(BuildQualificationError::InvalidFixture), + "accepted {section} field-count drift" + ); + } + + let root = copied_fixture(); + let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml"); + let current = fs::read_to_string(&path).expect("fixture manifest"); + let mut manifest = toml::from_str::<toml::Value>(¤t).expect("fixture toml"); + manifest["package"]["version"] = toml::Value::String("0.2.0".into()); + fs::write(&path, toml::to_string(&manifest).expect("render fixture")) + .expect("mutated fixture"); + assert_eq!( + validate_fixture(root.path()), + Err(BuildQualificationError::InvalidFixture) + ); + } + + #[test] + fn contract_inventory_is_literal_and_complete() { + assert_eq!(SUPPORTED_RUST_TARGETS.len(), 4); + assert_eq!(REQUIRED_NATIVE_COMMANDS.len(), 6); + assert_eq!(REQUIRED_XTASK_COMMANDS.len(), 5); + assert_eq!(REQUIRED_EVIDENCE.len(), 11); + assert_eq!(DEFERRED_OUTPUTS.len(), 6); + } + + #[test] + fn errors_are_fixed_and_source_free() { + for error in [ + BuildQualificationError::InvalidContract, + BuildQualificationError::InvalidFixture, + ] { + assert!(!error.to_string().contains("fixture_service")); + assert!(error.source().is_none()); + } + } + + fn workspace_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + } + + fn copied_fixture() -> TempDir { + let root = TempDir::new().expect("fixture root"); + let destination = root.path().join(FIXTURE_RELATIVE); + fs::create_dir_all(&destination).expect("fixture directory"); + let source = workspace_root().join(FIXTURE_RELATIVE); + for name in ["Cargo.toml", "Cargo.lock", "flake.lock", LOCK_FILENAME] { + fs::copy(source.join(name), destination.join(name)).expect("fixture file"); + } + root + } +} diff --git a/tools/xtask/src/service_source_lock.rs b/tools/xtask/src/service_source_lock.rs @@ -445,11 +445,7 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL "canonical_public_remote", ] && decision.operations.maximum_source_archive_bytes == 1_073_741_824 - && decision.deferred_operations - == [ - "embedded_build_information_agreement", - "service_fixture_release_graph", - ]; + && decision.deferred_operations == ["embedded_build_information_agreement"]; if exact { Ok(()) } else { @@ -460,11 +456,11 @@ fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceL fn canonical_vector() -> ServiceSourceLockV1 { ServiceSourceLockV1::new(ServiceSourceLockParts { service: "fixture_service", - revision: "1111111111111111111111111111111111111111", + revision: "2222222222222222222222222222222222222222", workspace_catalog_sha256: "2222222222222222222222222222222222222222222222222222222222222222", source_archive_sha256: "3333333333333333333333333333333333333333333333333333333333333333", - cargo_lock_sha256: "4444444444444444444444444444444444444444444444444444444444444444", - flake_lock_sha256: "5555555555555555555555555555555555555555555555555555555555555555", + cargo_lock_sha256: "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", + flake_lock_sha256: "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", contract_versions: ContractVersions::new(1, 2, 3, 4, 5), }) .expect("the governed source-lock vector is valid") @@ -584,6 +580,8 @@ fn render(raw: &RawServiceSourceLock) -> String { mod tests { use std::error::Error as _; + use crate::workspace_root; + use super::*; #[test] @@ -591,7 +589,7 @@ mod tests { let lock = canonical_vector(); assert_eq!( hex::encode(Sha256::digest(lock.canonical_bytes())), - "2257efc8fb3ff4ee8e429e326effdfe622c5e898b429ee1a8ea3aac38f9810cc" + "7251222df95da414d8cb073b8907f4a53c9ac4c89354bb2d895ac78fab79d81a" ); assert_eq!( ServiceSourceLockV1::from_canonical_bytes(lock.canonical_bytes()), @@ -600,6 +598,82 @@ mod tests { } #[test] + fn decision_rejects_every_independent_governed_field_drift() { + let bytes = fs::read(workspace_root().join(CONTRACT_RELATIVE)).expect("decision"); + let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); + for (pointer, replacement) in [ + ("/schema", serde_json::json!("other")), + ("/contract_version", serde_json::json!(2)), + ("/decision_state", serde_json::json!("draft")), + ("/lock_filename", serde_json::json!("other")), + ("/lock_schema", serde_json::json!("other")), + ("/canonical_encoding", serde_json::json!("other")), + ("/maximum_lock_utf8_bytes", serde_json::json!(1)), + ("/maximum_service_utf8_bytes", serde_json::json!(1)), + ("/canonical_field_order", serde_json::json!([])), + ("/fixed/repository", serde_json::json!("other")), + ("/fixed/architecture", serde_json::json!("other")), + ("/fixed/version", serde_json::json!("other")), + ("/fixed/rust_version", serde_json::json!("other")), + ("/fixed/host_feature_profile", serde_json::json!("other")), + ("/revision_encoding", serde_json::json!("other")), + ("/digest_encoding", serde_json::json!("other")), + ( + "/digest_subjects/workspace_catalog_sha256", + serde_json::json!("other"), + ), + ( + "/digest_subjects/source_archive_sha256", + serde_json::json!("other"), + ), + ( + "/digest_subjects/cargo_lock_sha256", + serde_json::json!("other"), + ), + ( + "/digest_subjects/flake_lock_sha256", + serde_json::json!("other"), + ), + ("/service_identifier", serde_json::json!("other")), + ("/contract_version_rule", serde_json::json!("other")), + ("/negative_error_codes", serde_json::json!([])), + ("/operations/command", serde_json::json!("other")), + ("/operations/modes", serde_json::json!([])), + ("/operations/required_arguments", serde_json::json!([])), + ( + "/operations/service_metadata_path", + serde_json::json!("other"), + ), + ("/operations/service_metadata_fields", serde_json::json!([])), + ( + "/operations/lib_dependency_inventory", + serde_json::json!("other"), + ), + ("/operations/source_cleanliness", serde_json::json!("other")), + ( + "/operations/service_revision_stability", + serde_json::json!("other"), + ), + ("/operations/revision_agreement", serde_json::json!([])), + ( + "/operations/maximum_source_archive_bytes", + serde_json::json!(1), + ), + ("/deferred_operations", serde_json::json!(["future"])), + ] { + let mut drifted = canonical.clone(); + *drifted.pointer_mut(pointer).expect("governed field") = replacement; + let decision = serde_json::from_value::<SourceLockDecision>(drifted) + .expect("structurally valid drift"); + assert_eq!( + validate_decision(&decision), + Err(ServiceSourceLockError::InvalidFixedIdentity), + "accepted drift at {pointer}" + ); + } + } + + #[test] fn parser_rejects_noncanonical_and_ambiguous_toml() { let canonical = String::from_utf8(canonical_vector().canonical_bytes().to_vec()) .expect("canonical UTF-8"); @@ -671,13 +745,13 @@ mod tests { ServiceSourceLockError::InvalidService, ), ( - "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", ServiceSourceLockError::InvalidRevision, ), ( - "1111111111111111111111111111111111111111", - "111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222", + "222222222222222222222222222222222222222", ServiceSourceLockError::InvalidRevision, ), ( @@ -691,12 +765,12 @@ mod tests { ServiceSourceLockError::InvalidDigest, ), ( - "4444444444444444444444444444444444444444444444444444444444444444", + "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", ServiceSourceLockError::InvalidDigest, ), ( - "5555555555555555555555555555555555555555555555555555555555555555", + "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", ServiceSourceLockError::InvalidDigest, ),