lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

service_build_qualification.rs (26147B)


      1 use std::{fmt, fs, io::Read as _, path::Path};
      2 
      3 use serde::Deserialize;
      4 use sha2::{Digest as _, Sha256};
      5 
      6 use crate::service_source_lock::{
      7     LOCK_FILENAME, NixMaterialState, PREDECESSOR_LOCK_FILENAME, ServiceSourceLockV2,
      8 };
      9 
     10 const CONTRACT_RELATIVE: &str =
     11     "contracts/architecture/decisions/services_hardening_build_qualification.v3.json";
     12 const FIXTURE_RELATIVE: &str = "tools/xtask/fixtures/service-build-qualification";
     13 const MAX_CONTRACT_BYTES: usize = 32_768;
     14 const MAX_FIXTURE_FILE_BYTES: usize = 1_048_576;
     15 const SOURCE_LOCK_V3_SHA256: &str =
     16     "3bc32c8ca2cecb06c8f8239ab1fe1fcfba93fe3ef0d60e9b078390347d08f817";
     17 const ARTIFACT_CONTRACT_V3_SHA256: &str =
     18     "11d8fa7bc96191919a3d20189061aadcee105eefe546290032c2f2bab91b2f77";
     19 const NIX_SYSTEMS_RELATIVE: &str = "build/nix/service/systems.nix";
     20 const NIX_SYSTEMS_BYTES: &[u8] = b"[\n  \"aarch64-darwin\"\n  \"x86_64-linux\"\n]\n";
     21 
     22 const SUPPORTED_RUST_TARGETS: [&str; 2] = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"];
     23 const SUPPORTED_NIX_SYSTEMS: [&str; 2] = ["aarch64-darwin", "x86_64-linux"];
     24 const EXCLUDED_NIX_SYSTEMS: [&str; 2] = ["x86_64-darwin", "aarch64-linux"];
     25 const REQUIRED_XTASK_COMMANDS: [&str; 5] = [
     26     "cargo test --locked -p xtask service_source_lock::tests",
     27     "cargo test --locked -p xtask service_release_artifacts::tests",
     28     "cargo test --locked -p xtask service_build_qualification::tests",
     29     "cargo run --locked -q -p xtask -- contract validate",
     30     "cargo run --locked -q -p xtask -- release preflight",
     31 ];
     32 const REQUIRED_NATIVE_COMMANDS: [&str; 6] = [
     33     "cargo build --locked --release",
     34     "cargo fmt --all --check",
     35     "cargo check --workspace --all-targets --locked",
     36     "cargo test --workspace --all-targets --locked",
     37     "cargo clippy --workspace --all-targets --locked -- -D warnings",
     38     "RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --locked",
     39 ];
     40 const REQUIRED_EVIDENCE: [&str; 11] = [
     41     "cargo_lock",
     42     "source_lock",
     43     "package_metadata",
     44     "release_metadata",
     45     "binary_archive",
     46     "oci_source_artifact",
     47     "cyclonedx_sbom",
     48     "notices",
     49     "artifact_manifest",
     50     "unsigned_provenance_input",
     51     "checksums",
     52 ];
     53 const DEFERRED_OUTPUTS: [&str; 6] = [
     54     "nix_packages",
     55     "nix_apps",
     56     "nix_checks",
     57     "nix_development_shells",
     58     "nixos_modules",
     59     "nix_produced_oci",
     60 ];
     61 
     62 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     63 enum BuildQualificationError {
     64     InvalidContract,
     65     InvalidFixture,
     66 }
     67 
     68 impl fmt::Display for BuildQualificationError {
     69     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     70         formatter.write_str(match self {
     71             Self::InvalidContract => "service build qualification contract is invalid",
     72             Self::InvalidFixture => "service build qualification fixture is invalid",
     73         })
     74     }
     75 }
     76 
     77 impl std::error::Error for BuildQualificationError {}
     78 
     79 #[derive(Debug, Deserialize)]
     80 #[serde(deny_unknown_fields)]
     81 struct BuildQualificationDecision {
     82     schema: String,
     83     contract_version: u32,
     84     decision_state: String,
     85     predecessor: PredecessorDecision,
     86     qualification_scope: String,
     87     fixture_root: String,
     88     supported_rust_targets: Vec<String>,
     89     supported_nix_systems: Vec<String>,
     90     excluded_nix_systems: Vec<String>,
     91     required_native_commands: Vec<String>,
     92     required_xtask_commands: Vec<String>,
     93     required_evidence: Vec<String>,
     94     fixture_source_lock: String,
     95     fixture_contract: String,
     96     release_artifact_command: String,
     97     source_lock_command: String,
     98     source_lock_v3_definition: String,
     99     artifact_contract_v3: String,
    100     nix_output_implementation_owner_step: u32,
    101     signing_authority: String,
    102     deferred_outputs: Vec<String>,
    103 }
    104 
    105 #[derive(Debug, Deserialize)]
    106 #[serde(deny_unknown_fields)]
    107 struct PredecessorDecision {
    108     schema: String,
    109     filename: String,
    110     transition: String,
    111 }
    112 
    113 pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> {
    114     validate_contract_inner(workspace_root).map_err(|error| error.to_string())
    115 }
    116 
    117 fn validate_contract_inner(workspace_root: &Path) -> Result<(), BuildQualificationError> {
    118     let bytes = read_bounded(
    119         &workspace_root.join(CONTRACT_RELATIVE),
    120         MAX_CONTRACT_BYTES,
    121         BuildQualificationError::InvalidContract,
    122     )?;
    123     let decision = serde_json::from_slice::<BuildQualificationDecision>(&bytes)
    124         .map_err(|_| BuildQualificationError::InvalidContract)?;
    125     validate_decision(&decision)?;
    126     validate_bound_contract(
    127         workspace_root,
    128         &decision.source_lock_v3_definition,
    129         SOURCE_LOCK_V3_SHA256,
    130     )?;
    131     validate_bound_contract(
    132         workspace_root,
    133         &decision.artifact_contract_v3,
    134         ARTIFACT_CONTRACT_V3_SHA256,
    135     )?;
    136     if read_bounded(
    137         &workspace_root.join(NIX_SYSTEMS_RELATIVE),
    138         1_024,
    139         BuildQualificationError::InvalidContract,
    140     )? != NIX_SYSTEMS_BYTES
    141     {
    142         return Err(BuildQualificationError::InvalidContract);
    143     }
    144     validate_fixture(workspace_root)
    145 }
    146 
    147 fn validate_bound_contract(
    148     workspace_root: &Path,
    149     relative: &str,
    150     expected_sha256: &str,
    151 ) -> Result<(), BuildQualificationError> {
    152     let bytes = read_bounded(
    153         &workspace_root.join(relative),
    154         MAX_CONTRACT_BYTES,
    155         BuildQualificationError::InvalidContract,
    156     )?;
    157     let _: serde_json::Value =
    158         serde_json::from_slice(&bytes).map_err(|_| BuildQualificationError::InvalidContract)?;
    159     if digest(&bytes) == expected_sha256 {
    160         Ok(())
    161     } else {
    162         Err(BuildQualificationError::InvalidContract)
    163     }
    164 }
    165 
    166 fn validate_decision(decision: &BuildQualificationDecision) -> Result<(), BuildQualificationError> {
    167     let exact = decision.schema == "radroots.services-hardening.build-qualification-decisions.v3"
    168         && decision.contract_version == 3
    169         && decision.decision_state == "active"
    170         && decision.predecessor.schema
    171             == "radroots.services-hardening.build-qualification-decisions.v2"
    172         && decision.predecessor.filename == "services_hardening_build_qualification.v2.json"
    173         && decision.predecessor.transition == "forward_only_replace"
    174         && decision.qualification_scope == "native_and_nix_release_foundation"
    175         && decision.fixture_root == FIXTURE_RELATIVE
    176         && decision.supported_rust_targets == SUPPORTED_RUST_TARGETS
    177         && decision.supported_nix_systems == SUPPORTED_NIX_SYSTEMS
    178         && decision.excluded_nix_systems == EXCLUDED_NIX_SYSTEMS
    179         && decision.required_native_commands == REQUIRED_NATIVE_COMMANDS
    180         && decision.required_xtask_commands == REQUIRED_XTASK_COMMANDS
    181         && decision.required_evidence == REQUIRED_EVIDENCE
    182         && decision.fixture_source_lock
    183             == "tools/xtask/fixtures/service-build-qualification/radroots.service.source-lock.v2.toml"
    184         && decision.fixture_contract
    185             == "historical_v2_fixture_until_v3_service_instances_are_owned"
    186         && decision.release_artifact_command == "cargo xtask service-release-artifacts"
    187         && decision.source_lock_command == "cargo xtask service-source-lock"
    188         && decision.source_lock_v3_definition
    189             == "contracts/architecture/decisions/services_hardening_source_lock.v3.json"
    190         && decision.artifact_contract_v3 == "contracts/release/lib-artifact-contract.v3.json"
    191         && decision.nix_output_implementation_owner_step == 299
    192         && decision.signing_authority == "external_only"
    193         && decision.deferred_outputs == DEFERRED_OUTPUTS;
    194     if exact {
    195         Ok(())
    196     } else {
    197         Err(BuildQualificationError::InvalidContract)
    198     }
    199 }
    200 
    201 fn validate_fixture(workspace_root: &Path) -> Result<(), BuildQualificationError> {
    202     let fixture = workspace_root.join(FIXTURE_RELATIVE);
    203     let lock_bytes = read_bounded(
    204         &fixture.join(LOCK_FILENAME),
    205         4_096,
    206         BuildQualificationError::InvalidFixture,
    207     )?;
    208     let lock = ServiceSourceLockV2::from_canonical_bytes(&lock_bytes)
    209         .map_err(|_| BuildQualificationError::InvalidFixture)?;
    210     let cargo_lock = read_bounded(
    211         &fixture.join("Cargo.lock"),
    212         MAX_FIXTURE_FILE_BYTES,
    213         BuildQualificationError::InvalidFixture,
    214     )?;
    215     let manifest = read_bounded(
    216         &fixture.join("Cargo.toml"),
    217         MAX_FIXTURE_FILE_BYTES,
    218         BuildQualificationError::InvalidFixture,
    219     )?;
    220     let manifest =
    221         std::str::from_utf8(&manifest).map_err(|_| BuildQualificationError::InvalidFixture)?;
    222     let manifest = toml::from_str::<toml::Value>(manifest)
    223         .map_err(|_| BuildQualificationError::InvalidFixture)?;
    224     let source_metadata = manifest
    225         .get("workspace")
    226         .and_then(|value| value.get("metadata"))
    227         .and_then(|value| value.get("radroots"))
    228         .and_then(|value| value.get("service_source_lock"))
    229         .and_then(toml::Value::as_table)
    230         .ok_or(BuildQualificationError::InvalidFixture)?;
    231     let release_metadata = manifest
    232         .get("workspace")
    233         .and_then(|value| value.get("metadata"))
    234         .and_then(|value| value.get("radroots"))
    235         .and_then(|value| value.get("service_release"))
    236         .and_then(toml::Value::as_table)
    237         .ok_or(BuildQualificationError::InvalidFixture)?;
    238     let versions = lock.contract_versions();
    239     let exact = lock.service() == "fixture_service"
    240         && lock.revision() == "2222222222222222222222222222222222222222"
    241         && lock.cargo_lock_sha256() == digest(&cargo_lock)
    242         && lock.nix_material_state() == NixMaterialState::Absent
    243         && lock.nix_lib_revision().is_none()
    244         && lock.flake_lock_sha256().is_none()
    245         && ["flake.nix", "flake.lock", PREDECESSOR_LOCK_FILENAME]
    246             .into_iter()
    247             .all(|name| {
    248                 fs::symlink_metadata(fixture.join(name))
    249                     .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound)
    250             })
    251         && versions.config() == 1
    252         && versions.state() == 2
    253         && versions.admin() == 3
    254         && versions.status() == 4
    255         && versions.provider() == 5
    256         && manifest
    257             .get("package")
    258             .and_then(|value| value.get("version"))
    259             .and_then(toml::Value::as_str)
    260             == Some("0.1.0-alpha")
    261         && source_metadata.len() == 8
    262         && source_metadata.get("service").and_then(toml::Value::as_str) == Some("fixture_service")
    263         && source_metadata
    264             .get("host_feature_profile")
    265             .and_then(toml::Value::as_str)
    266             == Some("service-host")
    267         && source_metadata
    268             .get("nix_material")
    269             .and_then(toml::Value::as_str)
    270             == Some("absent")
    271         && source_metadata
    272             .get("config_contract_version")
    273             .and_then(toml::Value::as_integer)
    274             == Some(i64::from(versions.config()))
    275         && source_metadata
    276             .get("state_contract_version")
    277             .and_then(toml::Value::as_integer)
    278             == Some(i64::from(versions.state()))
    279         && source_metadata
    280             .get("admin_contract_version")
    281             .and_then(toml::Value::as_integer)
    282             == Some(i64::from(versions.admin()))
    283         && source_metadata
    284             .get("status_contract_version")
    285             .and_then(toml::Value::as_integer)
    286             == Some(i64::from(versions.status()))
    287         && source_metadata
    288             .get("provider_contract_version")
    289             .and_then(toml::Value::as_integer)
    290             == Some(i64::from(versions.provider()))
    291         && release_metadata.len() == 4
    292         && release_metadata
    293             .get("service")
    294             .and_then(toml::Value::as_str)
    295             == Some("fixture_service")
    296         && release_metadata
    297             .get("service_package")
    298             .and_then(toml::Value::as_str)
    299             == Some("fixture-service")
    300         && release_metadata
    301             .get("binary_name")
    302             .and_then(toml::Value::as_str)
    303             == Some("fixture-service")
    304         && release_metadata
    305             .get("version")
    306             .and_then(toml::Value::as_str)
    307             == Some("0.1.0-alpha");
    308     if exact {
    309         Ok(())
    310     } else {
    311         Err(BuildQualificationError::InvalidFixture)
    312     }
    313 }
    314 
    315 fn read_bounded(
    316     path: &Path,
    317     maximum: usize,
    318     error: BuildQualificationError,
    319 ) -> Result<Vec<u8>, BuildQualificationError> {
    320     let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
    321     if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum as u64 {
    322         return Err(error);
    323     }
    324     let mut bytes = Vec::with_capacity(metadata.len() as usize);
    325     fs::File::open(path)
    326         .map_err(|_| error)?
    327         .take(maximum as u64 + 1)
    328         .read_to_end(&mut bytes)
    329         .map_err(|_| error)?;
    330     if bytes.len() > maximum {
    331         Err(error)
    332     } else {
    333         Ok(bytes)
    334     }
    335 }
    336 
    337 fn digest(bytes: &[u8]) -> String {
    338     hex::encode(Sha256::digest(bytes))
    339 }
    340 
    341 #[cfg(test)]
    342 mod tests {
    343     use std::error::Error as _;
    344 
    345     use tempfile::TempDir;
    346 
    347     use super::*;
    348 
    349     #[test]
    350     fn checked_in_contract_and_fixture_are_exact() {
    351         let root = workspace_root();
    352         validate_contract_inner(root).expect("build qualification");
    353     }
    354 
    355     #[test]
    356     fn contract_rejects_every_independent_governed_field_drift() {
    357         let bytes = fs::read(workspace_root().join(CONTRACT_RELATIVE)).expect("decision");
    358         let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json");
    359         for (pointer, replacement) in [
    360             ("/schema", serde_json::json!("other")),
    361             ("/contract_version", serde_json::json!(1)),
    362             ("/decision_state", serde_json::json!("draft")),
    363             ("/predecessor/schema", serde_json::json!("other")),
    364             ("/predecessor/filename", serde_json::json!("other")),
    365             ("/predecessor/transition", serde_json::json!("other")),
    366             ("/qualification_scope", serde_json::json!("other")),
    367             ("/fixture_root", serde_json::json!("other")),
    368             ("/supported_rust_targets", serde_json::json!([])),
    369             ("/supported_nix_systems", serde_json::json!([])),
    370             ("/excluded_nix_systems", serde_json::json!([])),
    371             ("/required_native_commands", serde_json::json!([])),
    372             ("/required_xtask_commands", serde_json::json!([])),
    373             ("/required_evidence", serde_json::json!([])),
    374             ("/fixture_source_lock", serde_json::json!("other")),
    375             ("/fixture_contract", serde_json::json!("other")),
    376             ("/release_artifact_command", serde_json::json!("other")),
    377             ("/source_lock_command", serde_json::json!("other")),
    378             ("/source_lock_v3_definition", serde_json::json!("other")),
    379             ("/artifact_contract_v3", serde_json::json!("other")),
    380             (
    381                 "/nix_output_implementation_owner_step",
    382                 serde_json::json!(1),
    383             ),
    384             ("/signing_authority", serde_json::json!("internal")),
    385             ("/deferred_outputs", serde_json::json!([])),
    386         ] {
    387             let mut drifted = canonical.clone();
    388             *drifted.pointer_mut(pointer).expect("governed field") = replacement;
    389             let decision = serde_json::from_value::<BuildQualificationDecision>(drifted)
    390                 .expect("structurally valid drift");
    391             assert_eq!(
    392                 validate_decision(&decision),
    393                 Err(BuildQualificationError::InvalidContract),
    394                 "accepted drift at {pointer}"
    395             );
    396         }
    397     }
    398 
    399     #[test]
    400     fn fixture_rejects_every_identity_and_lockfile_drift() {
    401         for (name, from, to) in [
    402             (
    403                 "Cargo.toml",
    404                 "version = \"0.1.0-alpha\"",
    405                 "version = \"0.1.1\"",
    406             ),
    407             (
    408                 "Cargo.toml",
    409                 "config_contract_version = 1",
    410                 "config_contract_version = 9",
    411             ),
    412             (
    413                 "Cargo.toml",
    414                 "service_package = \"fixture-service\"",
    415                 "service_package = \"other-service\"",
    416             ),
    417             (
    418                 "radroots.service.source-lock.v2.toml",
    419                 "revision = \"2222222222222222222222222222222222222222\"",
    420                 "revision = \"3333333333333333333333333333333333333333\"",
    421             ),
    422             ("Cargo.lock", "version = 4", "version = 3"),
    423         ] {
    424             let root = copied_fixture();
    425             let path = root.path().join(FIXTURE_RELATIVE).join(name);
    426             let current = fs::read_to_string(&path).expect("fixture text");
    427             assert!(current.contains(from), "missing mutation anchor {from}");
    428             fs::write(&path, current.replacen(from, to, 1)).expect("mutated fixture");
    429             assert_eq!(
    430                 validate_fixture(root.path()),
    431                 Err(BuildQualificationError::InvalidFixture)
    432             );
    433         }
    434 
    435         let root = copied_fixture();
    436         let manifest = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml");
    437         let mut current = fs::read_to_string(&manifest).expect("fixture manifest");
    438         current.push_str("\n[workspace.metadata.radroots.service_release.extra]\nvalue = 1\n");
    439         fs::write(manifest, current).expect("extra fixture metadata");
    440         assert_eq!(
    441             validate_fixture(root.path()),
    442             Err(BuildQualificationError::InvalidFixture)
    443         );
    444 
    445         for name in ["flake.nix", "flake.lock", PREDECESSOR_LOCK_FILENAME] {
    446             let root = copied_fixture();
    447             fs::write(root.path().join(FIXTURE_RELATIVE).join(name), b"unexpected")
    448                 .expect("unexpected Nix material");
    449             assert_eq!(
    450                 validate_fixture(root.path()),
    451                 Err(BuildQualificationError::InvalidFixture),
    452                 "accepted absent-state fixture with {name}"
    453             );
    454         }
    455     }
    456 
    457     #[test]
    458     fn canonical_fixture_locks_bind_service_and_each_positive_contract_version() {
    459         for (from, to) in [
    460             (
    461                 "service = \"fixture_service\"",
    462                 "service = \"other_service\"",
    463             ),
    464             ("config = 1", "config = 9"),
    465             ("state = 2", "state = 9"),
    466             ("admin = 3", "admin = 9"),
    467             ("status = 4", "status = 9"),
    468             ("provider = 5", "provider = 9"),
    469             (
    470                 "material = \"absent\"",
    471                 "material = \"deferred\"\nlib_revision = \"1111111111111111111111111111111111111111\"\nflake_lock_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"",
    472             ),
    473         ] {
    474             let root = copied_fixture();
    475             validate_fixture(root.path()).unwrap();
    476             let path = root.path().join(FIXTURE_RELATIVE).join(LOCK_FILENAME);
    477             let original = fs::read_to_string(&path).unwrap();
    478             assert!(original.contains(from));
    479             let changed = original.replacen(from, to, 1);
    480             ServiceSourceLockV2::from_canonical_bytes(changed.as_bytes())
    481                 .expect("valid canonical lock with substituted identity");
    482             fs::write(&path, changed).unwrap();
    483             assert_eq!(
    484                 validate_fixture(root.path()),
    485                 Err(BuildQualificationError::InvalidFixture),
    486                 "{from}"
    487             );
    488         }
    489         let root = copied_fixture();
    490         let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml");
    491         let original = fs::read_to_string(&path).unwrap();
    492         let changed = original.replace("nix_material = \"absent\"", "nix_material = \"deferred\"");
    493         assert_ne!(changed, original);
    494         fs::write(path, changed).unwrap();
    495         assert_eq!(
    496             validate_fixture(root.path()),
    497             Err(BuildQualificationError::InvalidFixture)
    498         );
    499     }
    500 
    501     #[test]
    502     fn bound_contracts_reject_digest_substitution_and_unsafe_file_inputs() {
    503         let root = TempDir::new().unwrap();
    504         let path = root.path().join("input");
    505         fs::write(&path, b"{}").unwrap();
    506         validate_bound_contract(root.path(), "input", &digest(b"{}")).unwrap();
    507         assert!(validate_bound_contract(root.path(), "input", &"1".repeat(64)).is_err());
    508         assert!(read_bounded(&path, 1, BuildQualificationError::InvalidContract).is_err());
    509         assert!(read_bounded(root.path(), 10, BuildQualificationError::InvalidContract).is_err());
    510         #[cfg(unix)]
    511         {
    512             let link = root.path().join("link");
    513             std::os::unix::fs::symlink(&path, &link).unwrap();
    514             assert!(read_bounded(&link, 10, BuildQualificationError::InvalidContract).is_err());
    515         }
    516     }
    517 
    518     #[test]
    519     fn fixture_rejects_every_independent_metadata_drift() {
    520         for (section, field, replacement) in [
    521             (
    522                 "service_source_lock",
    523                 "service",
    524                 toml::Value::String("other".into()),
    525             ),
    526             (
    527                 "service_source_lock",
    528                 "host_feature_profile",
    529                 toml::Value::String("other".into()),
    530             ),
    531             (
    532                 "service_source_lock",
    533                 "config_contract_version",
    534                 toml::Value::Integer(9),
    535             ),
    536             (
    537                 "service_source_lock",
    538                 "state_contract_version",
    539                 toml::Value::Integer(9),
    540             ),
    541             (
    542                 "service_source_lock",
    543                 "admin_contract_version",
    544                 toml::Value::Integer(9),
    545             ),
    546             (
    547                 "service_source_lock",
    548                 "status_contract_version",
    549                 toml::Value::Integer(9),
    550             ),
    551             (
    552                 "service_source_lock",
    553                 "provider_contract_version",
    554                 toml::Value::Integer(9),
    555             ),
    556             (
    557                 "service_release",
    558                 "service",
    559                 toml::Value::String("other".into()),
    560             ),
    561             (
    562                 "service_release",
    563                 "service_package",
    564                 toml::Value::String("other".into()),
    565             ),
    566             (
    567                 "service_release",
    568                 "binary_name",
    569                 toml::Value::String("other".into()),
    570             ),
    571             (
    572                 "service_release",
    573                 "version",
    574                 toml::Value::String("0.2.0".into()),
    575             ),
    576         ] {
    577             let root = copied_fixture();
    578             let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml");
    579             let current = fs::read_to_string(&path).expect("fixture manifest");
    580             let mut manifest = toml::from_str::<toml::Value>(&current).expect("fixture toml");
    581             manifest["workspace"]["metadata"]["radroots"][section][field] = replacement;
    582             fs::write(&path, toml::to_string(&manifest).expect("render fixture"))
    583                 .expect("mutated fixture");
    584             assert_eq!(
    585                 validate_fixture(root.path()),
    586                 Err(BuildQualificationError::InvalidFixture),
    587                 "accepted {section}.{field} drift"
    588             );
    589         }
    590 
    591         for section in ["service_source_lock", "service_release"] {
    592             let root = copied_fixture();
    593             let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml");
    594             let current = fs::read_to_string(&path).expect("fixture manifest");
    595             let mut manifest = toml::from_str::<toml::Value>(&current).expect("fixture toml");
    596             manifest["workspace"]["metadata"]["radroots"][section]
    597                 .as_table_mut()
    598                 .expect("metadata section")
    599                 .insert("extra".into(), toml::Value::Integer(1));
    600             fs::write(&path, toml::to_string(&manifest).expect("render fixture"))
    601                 .expect("mutated fixture");
    602             assert_eq!(
    603                 validate_fixture(root.path()),
    604                 Err(BuildQualificationError::InvalidFixture),
    605                 "accepted {section} field-count drift"
    606             );
    607         }
    608 
    609         let root = copied_fixture();
    610         let path = root.path().join(FIXTURE_RELATIVE).join("Cargo.toml");
    611         let current = fs::read_to_string(&path).expect("fixture manifest");
    612         let mut manifest = toml::from_str::<toml::Value>(&current).expect("fixture toml");
    613         manifest["package"]["version"] = toml::Value::String("0.2.0".into());
    614         fs::write(&path, toml::to_string(&manifest).expect("render fixture"))
    615             .expect("mutated fixture");
    616         assert_eq!(
    617             validate_fixture(root.path()),
    618             Err(BuildQualificationError::InvalidFixture)
    619         );
    620     }
    621 
    622     #[test]
    623     fn contract_inventory_is_literal_and_complete() {
    624         assert_eq!(SUPPORTED_RUST_TARGETS.len(), 2);
    625         assert_eq!(SUPPORTED_NIX_SYSTEMS.len(), 2);
    626         assert_eq!(EXCLUDED_NIX_SYSTEMS.len(), 2);
    627         assert_eq!(REQUIRED_NATIVE_COMMANDS.len(), 6);
    628         assert_eq!(REQUIRED_XTASK_COMMANDS.len(), 5);
    629         assert_eq!(REQUIRED_EVIDENCE.len(), 11);
    630         assert_eq!(DEFERRED_OUTPUTS.len(), 6);
    631     }
    632 
    633     #[test]
    634     fn errors_are_fixed_and_source_free() {
    635         for error in [
    636             BuildQualificationError::InvalidContract,
    637             BuildQualificationError::InvalidFixture,
    638         ] {
    639             assert!(!error.to_string().contains("fixture_service"));
    640             assert!(error.source().is_none());
    641         }
    642     }
    643 
    644     fn workspace_root() -> &'static Path {
    645         Path::new(env!("CARGO_MANIFEST_DIR"))
    646             .parent()
    647             .and_then(Path::parent)
    648             .expect("workspace root")
    649     }
    650 
    651     fn copied_fixture() -> TempDir {
    652         let root = TempDir::new().expect("fixture root");
    653         let destination = root.path().join(FIXTURE_RELATIVE);
    654         fs::create_dir_all(&destination).expect("fixture directory");
    655         let source = workspace_root().join(FIXTURE_RELATIVE);
    656         for name in ["Cargo.toml", "Cargo.lock", LOCK_FILENAME] {
    657             fs::copy(source.join(name), destination.join(name)).expect("fixture file");
    658         }
    659         root
    660     }
    661 }