lib

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

supply_chain_qualification.rs (30301B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 use std::fs;
      3 use std::path::{Path, PathBuf};
      4 use std::process::Command;
      5 
      6 use serde::{Deserialize, Serialize};
      7 use serde_json::{Value, json};
      8 use sha2::{Digest, Sha256};
      9 
     10 const CONTRACT_PATH: &str = "contracts/releases/supply_chain.toml";
     11 const DENY_PATH: &str = "deny.toml";
     12 const SBOM_FILENAME: &str = "radroots-release-v1-sbom.json";
     13 
     14 #[derive(Debug, Deserialize)]
     15 struct Contract {
     16     schema_version: u16,
     17     spec_id: String,
     18     package_version: String,
     19     tools: Tools,
     20     sbom: Sbom,
     21     advisory_exception: Vec<AdvisoryException>,
     22     git_source: Vec<GitSource>,
     23     package: Vec<Package>,
     24 }
     25 
     26 #[derive(Debug, Deserialize)]
     27 struct Tools {
     28     cargo_deny: String,
     29     cargo_cyclonedx: String,
     30     cargo_vet: String,
     31 }
     32 
     33 #[derive(Debug, Deserialize)]
     34 struct Sbom {
     35     format: String,
     36     spec_version: String,
     37     target: String,
     38     all_features: bool,
     39     source_date_epoch: u64,
     40 }
     41 
     42 #[derive(Clone, Debug, Deserialize, Serialize)]
     43 struct AdvisoryException {
     44     id: String,
     45     package: String,
     46     affected_version: String,
     47     introduced_by: String,
     48     classification: String,
     49     mitigation: String,
     50     remove_when: String,
     51 }
     52 
     53 #[derive(Debug, Deserialize)]
     54 struct Package {
     55     name: String,
     56     manifest_path: String,
     57 }
     58 
     59 #[derive(Debug, Deserialize)]
     60 struct GitSource {
     61     url: String,
     62     revision: String,
     63     packages: Vec<String>,
     64     removal_when: String,
     65 }
     66 
     67 #[derive(Debug, Deserialize)]
     68 struct Metadata {
     69     packages: Vec<MetadataPackage>,
     70 }
     71 
     72 #[derive(Debug, Deserialize)]
     73 struct MetadataPackage {
     74     name: String,
     75     version: String,
     76     manifest_path: PathBuf,
     77 }
     78 
     79 struct GeneratedSboms(Vec<PathBuf>);
     80 
     81 impl Drop for GeneratedSboms {
     82     fn drop(&mut self) {
     83         for path in &self.0 {
     84             let _ = fs::remove_file(path);
     85         }
     86     }
     87 }
     88 
     89 pub fn run(root: &Path) -> Result<(), String> {
     90     let contract = load(root)?;
     91     validate(root, &contract, 19)?;
     92     verify_tools(&contract)?;
     93     let metadata = load_metadata(root)?;
     94     qualify_git_sources(root, &contract)?;
     95     qualify_dependencies(root, &contract)?;
     96     let sbom_hashes = qualify_sboms(root, &contract, &metadata)?;
     97     let provenance = build_provenance(root, &contract, &sbom_hashes)?;
     98     validate_provenance(&provenance, &contract)?;
     99     eprintln!(
    100         "qualified {} package supply chains; provenance sha256={}",
    101         contract.package.len(),
    102         sha256(&serde_json::to_vec(&provenance).map_err(|error| error.to_string())?)
    103     );
    104     Ok(())
    105 }
    106 
    107 fn load(root: &Path) -> Result<Contract, String> {
    108     let path = root.join(CONTRACT_PATH);
    109     let raw = fs::read_to_string(&path)
    110         .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    111     toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display()))
    112 }
    113 
    114 fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Result<(), String> {
    115     if contract.schema_version != 1
    116         || contract.spec_id != "radroots.crates.release.v1"
    117         || contract.package_version != "0.1.0-alpha"
    118         || contract.tools.cargo_deny != "0.19.8"
    119         || contract.tools.cargo_cyclonedx != "0.5.9"
    120         || contract.tools.cargo_vet != "0.10.2"
    121         || contract.sbom.format != "json"
    122         || contract.sbom.spec_version != "1.5"
    123         || contract.sbom.target != "all"
    124         || !contract.sbom.all_features
    125         || contract.sbom.source_date_epoch != 0
    126     {
    127         return Err("invalid supply-chain qualification contract".to_owned());
    128     }
    129 
    130     if contract.git_source.len() != 1 {
    131         return Err("supply-chain contract requires exactly one retained Git source".to_owned());
    132     }
    133     let source = &contract.git_source[0];
    134     if source.url != "https://github.com/rust-nostr/nostr.git"
    135         || source.revision != "5bba5163eb77107f82c4a8262cf29d7f33a73219"
    136         || source.packages != ["nostr", "nostr-relay-builder", "nostr-sdk"]
    137         || source.removal_when != "nostr 0.45 stable satisfies Studio compatibility tests"
    138     {
    139         return Err("retained Git source authority drifted".to_owned());
    140     }
    141 
    142     let names = contract
    143         .package
    144         .iter()
    145         .map(|package| package.name.as_str())
    146         .collect::<BTreeSet<_>>();
    147     let manifests = contract
    148         .package
    149         .iter()
    150         .map(|package| package.manifest_path.as_str())
    151         .collect::<BTreeSet<_>>();
    152     if names.len() != expected_packages
    153         || names.len() != contract.package.len()
    154         || manifests.len() != contract.package.len()
    155     {
    156         return Err(format!(
    157             "supply-chain contract requires exactly {expected_packages} unique packages and manifests"
    158         ));
    159     }
    160     for package in &contract.package {
    161         let path = root.join(&package.manifest_path);
    162         let raw = fs::read_to_string(&path)
    163             .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    164         let manifest: toml::Value = toml::from_str(&raw)
    165             .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
    166         let package_table = manifest
    167             .get("package")
    168             .and_then(toml::Value::as_table)
    169             .ok_or_else(|| format!("{} has no package table", path.display()))?;
    170         let name = package_table.get("name").and_then(toml::Value::as_str);
    171         let version = package_table.get("version").and_then(toml::Value::as_str);
    172         if name != Some(package.name.as_str()) || version != Some(contract.package_version.as_str())
    173         {
    174             return Err(format!(
    175                 "{} does not declare {} {}",
    176                 path.display(),
    177                 package.name,
    178                 contract.package_version
    179             ));
    180         }
    181     }
    182 
    183     validate_exceptions(root, contract)
    184 }
    185 
    186 fn qualify_git_sources(root: &Path, contract: &Contract) -> Result<(), String> {
    187     let approved = contract
    188         .git_source
    189         .iter()
    190         .map(|source| (source.url.clone(), source.revision.clone()))
    191         .collect::<BTreeSet<_>>();
    192     let mut seen = BTreeSet::new();
    193     for entry in walkdir::WalkDir::new(root.join("crates")) {
    194         let entry = entry.map_err(|error| format!("failed to walk manifests: {error}"))?;
    195         if entry.file_name() != "Cargo.toml" {
    196             continue;
    197         }
    198         let raw = fs::read_to_string(entry.path())
    199             .map_err(|error| format!("failed to read {}: {error}", entry.path().display()))?;
    200         let manifest: toml::Value = toml::from_str(&raw)
    201             .map_err(|error| format!("failed to parse {}: {error}", entry.path().display()))?;
    202         inspect_git_dependencies(&manifest, entry.path(), &approved, &mut seen)?;
    203     }
    204     if seen != approved {
    205         return Err("approved Git source set is stale or incomplete".to_owned());
    206     }
    207     let deny_raw = fs::read_to_string(root.join(DENY_PATH))
    208         .map_err(|error| format!("failed to read {DENY_PATH}: {error}"))?;
    209     let deny = toml::from_str::<toml::Value>(&deny_raw)
    210         .map_err(|error| format!("failed to parse {DENY_PATH}: {error}"))?;
    211     let deny_git_sources = deny
    212         .get("sources")
    213         .and_then(|sources| sources.get("allow-git"))
    214         .and_then(toml::Value::as_array)
    215         .ok_or_else(|| "deny.toml sources.allow-git is missing".to_owned())?
    216         .iter()
    217         .filter_map(toml::Value::as_str)
    218         .collect::<BTreeSet<_>>();
    219     let approved_urls = contract
    220         .git_source
    221         .iter()
    222         .map(|source| source.url.as_str())
    223         .collect::<BTreeSet<_>>();
    224     if deny_git_sources != approved_urls {
    225         return Err(
    226             "cargo-deny Git source authority differs from the exact-revision policy".to_owned(),
    227         );
    228     }
    229     let lock = fs::read_to_string(root.join("Cargo.lock"))
    230         .map_err(|error| format!("failed to read Cargo.lock: {error}"))?;
    231     for source in lock.lines().filter_map(|line| {
    232         line.trim()
    233             .strip_prefix("source = \"")
    234             .and_then(|value| value.strip_suffix('"'))
    235             .filter(|value| value.starts_with("git+"))
    236     }) {
    237         let (url_and_query, commit) = source
    238             .rsplit_once('#')
    239             .ok_or_else(|| format!("Git lock source has no commit: {source}"))?;
    240         let (url, revision) = url_and_query
    241             .strip_prefix("git+")
    242             .and_then(|value| value.split_once("?rev="))
    243             .ok_or_else(|| format!("Git lock source is not exact-rev pinned: {source}"))?;
    244         if commit != revision || !approved.contains(&(url.to_owned(), revision.to_owned())) {
    245             return Err(format!(
    246                 "Git lock source is not approved and immutable: {source}"
    247             ));
    248         }
    249     }
    250     Ok(())
    251 }
    252 
    253 fn inspect_git_dependencies(
    254     value: &toml::Value,
    255     path: &Path,
    256     approved: &BTreeSet<(String, String)>,
    257     seen: &mut BTreeSet<(String, String)>,
    258 ) -> Result<(), String> {
    259     match value {
    260         toml::Value::Table(table) => {
    261             if let Some(url) = table.get("git").and_then(toml::Value::as_str) {
    262                 let revision = table.get("rev").and_then(toml::Value::as_str);
    263                 if table.contains_key("branch")
    264                     || table.contains_key("tag")
    265                     || revision.is_none_or(|revision| {
    266                         revision.len() != 40
    267                             || !revision.bytes().all(|byte| byte.is_ascii_hexdigit())
    268                     })
    269                 {
    270                     return Err(format!(
    271                         "{} contains a branch, tag, or non-full Git revision",
    272                         path.display()
    273                     ));
    274                 }
    275                 let authority = (
    276                     url.to_owned(),
    277                     revision.expect("checked revision").to_owned(),
    278                 );
    279                 if !approved.contains(&authority) {
    280                     return Err(format!(
    281                         "{} contains unapproved Git source {url}",
    282                         path.display()
    283                     ));
    284                 }
    285                 seen.insert(authority);
    286             }
    287             for child in table.values() {
    288                 inspect_git_dependencies(child, path, approved, seen)?;
    289             }
    290         }
    291         toml::Value::Array(values) => {
    292             for child in values {
    293                 inspect_git_dependencies(child, path, approved, seen)?;
    294             }
    295         }
    296         _ => {}
    297     }
    298     Ok(())
    299 }
    300 
    301 fn validate_exceptions(root: &Path, contract: &Contract) -> Result<(), String> {
    302     let expected = BTreeSet::from([
    303         "CARGO-YANKED-SPIN-0.9.8".to_owned(),
    304         "RUSTSEC-2024-0384".to_owned(),
    305         "RUSTSEC-2024-0421".to_owned(),
    306         "RUSTSEC-2026-0243".to_owned(),
    307     ]);
    308     let actual = contract
    309         .advisory_exception
    310         .iter()
    311         .map(|exception| exception.id.clone())
    312         .collect::<BTreeSet<_>>();
    313     if actual != expected || actual.len() != contract.advisory_exception.len() {
    314         return Err("supply-chain advisory exceptions are not the exact approved set".to_owned());
    315     }
    316     for exception in &contract.advisory_exception {
    317         if exception.package.is_empty()
    318             || exception.affected_version.is_empty()
    319             || exception.introduced_by.is_empty()
    320             || exception.classification.is_empty()
    321             || exception.mitigation.is_empty()
    322             || exception.remove_when.is_empty()
    323         {
    324             return Err(format!("advisory exception {} is incomplete", exception.id));
    325         }
    326         let exact = match exception.id.as_str() {
    327             "RUSTSEC-2024-0384" => {
    328                 exception.package == "instant"
    329                     && exception.affected_version == "0.1.13"
    330                     && exception.classification == "unmaintained"
    331                     && exception.remove_when == "nostr >=0.45.0 stable"
    332             }
    333             "RUSTSEC-2024-0421" => {
    334                 exception.package == "idna"
    335                     && exception.affected_version == "0.5.0"
    336                     && exception.classification == "vulnerability"
    337                     && exception.remove_when == "nostr >=0.45.0 stable"
    338             }
    339             "RUSTSEC-2026-0243" => {
    340                 exception.package == "nostr-relay-pool"
    341                     && exception.affected_version == "0.44.3"
    342                     && exception.classification == "unmaintained"
    343                     && exception.remove_when == "nostr >=0.45.0 stable"
    344             }
    345             "CARGO-YANKED-SPIN-0.9.8" => {
    346                 exception.package == "spin"
    347                     && exception.affected_version == "0.9.8"
    348                     && exception.classification == "yanked"
    349                     && exception.remove_when
    350                         == "sqlx no longer resolves flume 0.12.0 with spin 0.9.8"
    351             }
    352             _ => false,
    353         };
    354         if !exact {
    355             return Err(format!(
    356                 "advisory exception {} differs from its exact approved policy",
    357                 exception.id
    358             ));
    359         }
    360     }
    361 
    362     let lock_raw = fs::read_to_string(root.join("Cargo.lock"))
    363         .map_err(|error| format!("failed to read Cargo.lock: {error}"))?;
    364     let lock: toml::Value = toml::from_str(&lock_raw)
    365         .map_err(|error| format!("failed to parse Cargo.lock: {error}"))?;
    366     let locked_packages = lock
    367         .get("package")
    368         .and_then(toml::Value::as_array)
    369         .ok_or_else(|| "Cargo.lock contains no packages".to_owned())?;
    370     for exception in &contract.advisory_exception {
    371         let present = locked_packages.iter().any(|package| {
    372             package.get("name").and_then(toml::Value::as_str) == Some(exception.package.as_str())
    373                 && package.get("version").and_then(toml::Value::as_str)
    374                     == Some(exception.affected_version.as_str())
    375         });
    376         if !present {
    377             return Err(format!(
    378                 "advisory exception {} is stale because {} {} is absent from Cargo.lock",
    379                 exception.id, exception.package, exception.affected_version
    380             ));
    381         }
    382     }
    383     let patched_url_present = locked_packages.iter().any(|package| {
    384         package.get("name").and_then(toml::Value::as_str) == Some("url")
    385             && package
    386                 .get("version")
    387                 .and_then(toml::Value::as_str)
    388                 .and_then(|version| semver::Version::parse(version).ok())
    389                 .is_some_and(|version| version >= semver::Version::new(2, 5, 4))
    390     });
    391     if !patched_url_present {
    392         return Err("the IDNA exception requires url 2.5.4 or newer in Cargo.lock".to_owned());
    393     }
    394 
    395     let deny_raw = fs::read_to_string(root.join(DENY_PATH))
    396         .map_err(|error| format!("failed to read {DENY_PATH}: {error}"))?;
    397     let deny: toml::Value = toml::from_str(&deny_raw)
    398         .map_err(|error| format!("failed to parse {DENY_PATH}: {error}"))?;
    399     let ignored = deny
    400         .get("advisories")
    401         .and_then(|value| value.get("ignore"))
    402         .and_then(toml::Value::as_array)
    403         .ok_or_else(|| "deny.toml advisories.ignore is missing".to_owned())?
    404         .iter()
    405         .filter_map(toml::Value::as_str)
    406         .map(str::to_owned)
    407         .collect::<BTreeSet<_>>();
    408     let expected_ignored = expected
    409         .iter()
    410         .filter(|id| id.starts_with("RUSTSEC-"))
    411         .cloned()
    412         .collect::<BTreeSet<_>>();
    413     if ignored != expected_ignored {
    414         return Err("deny.toml advisory ignores differ from the governed exceptions".to_owned());
    415     }
    416 
    417     let relay_source = root.join("crates/transport_nostr/src/relay.rs");
    418     if relay_source.exists() {
    419         let source = fs::read_to_string(&relay_source)
    420             .map_err(|error| format!("failed to read {}: {error}", relay_source.display()))?;
    421         if !source.contains("Url::parse(canonical)") {
    422             return Err(
    423                 "the IDNA exception requires patched relay URL canonicalization".to_owned(),
    424             );
    425         }
    426     }
    427     Ok(())
    428 }
    429 
    430 fn verify_tools(contract: &Contract) -> Result<(), String> {
    431     verify_tool(
    432         &["deny", "--version"],
    433         "cargo-deny",
    434         &contract.tools.cargo_deny,
    435     )?;
    436     verify_tool(
    437         &["cyclonedx", "--version"],
    438         "cargo-cyclonedx",
    439         &contract.tools.cargo_cyclonedx,
    440     )?;
    441     verify_tool(
    442         &["vet", "--version"],
    443         "cargo-vet",
    444         &contract.tools.cargo_vet,
    445     )
    446 }
    447 
    448 fn verify_tool(args: &[&str], name: &str, expected: &str) -> Result<(), String> {
    449     let output = Command::new("cargo")
    450         .args(args)
    451         .output()
    452         .map_err(|error| format!("failed to start {name}: {error}"))?;
    453     if !output.status.success() {
    454         return Err(format!("{name} {expected} is required"));
    455     }
    456     let stdout = String::from_utf8_lossy(&output.stdout);
    457     let installed = stdout
    458         .split_whitespace()
    459         .find_map(|value| semver::Version::parse(value).ok())
    460         .ok_or_else(|| format!("could not parse {name} version: {stdout}"))?;
    461     let expected = semver::Version::parse(expected)
    462         .map_err(|error| format!("invalid governed {name} version: {error}"))?;
    463     if installed != expected {
    464         return Err(format!("{name} {expected} is required, found {installed}"));
    465     }
    466     Ok(())
    467 }
    468 
    469 fn load_metadata(root: &Path) -> Result<Metadata, String> {
    470     let output = Command::new("cargo")
    471         .args(["metadata", "--format-version", "1", "--locked", "--no-deps"])
    472         .current_dir(root)
    473         .output()
    474         .map_err(|error| format!("failed to start cargo metadata: {error}"))?;
    475     if !output.status.success() {
    476         return Err("locked cargo metadata failed".to_owned());
    477     }
    478     serde_json::from_slice(&output.stdout)
    479         .map_err(|error| format!("failed to parse cargo metadata: {error}"))
    480 }
    481 
    482 fn qualify_dependencies(root: &Path, contract: &Contract) -> Result<(), String> {
    483     run_command(
    484         root,
    485         "cargo",
    486         vec!["vet", "--locked"],
    487         "cargo-vet policy failed",
    488     )?;
    489     let mut saw_governed_yank = false;
    490     for package in &contract.package {
    491         let manifest = root.join(&package.manifest_path);
    492         let common = [
    493             "deny",
    494             "-L",
    495             "error",
    496             "--manifest-path",
    497             manifest
    498                 .to_str()
    499                 .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?,
    500             "--all-features",
    501             "--locked",
    502             "check",
    503         ];
    504         saw_governed_yank |= qualify_advisories(root, &manifest, &package.name)?;
    505         run_command(
    506             root,
    507             "cargo",
    508             common
    509                 .iter()
    510                 .copied()
    511                 .chain(["bans", "licenses", "sources"])
    512                 .collect::<Vec<_>>(),
    513             &format!("dependency policy failed for {}", package.name),
    514         )?;
    515     }
    516     if !saw_governed_yank {
    517         return Err("the governed spin 0.9.8 yank is stale and must be removed".to_owned());
    518     }
    519     Ok(())
    520 }
    521 
    522 fn qualify_advisories(root: &Path, manifest: &Path, package: &str) -> Result<bool, String> {
    523     let output = Command::new("cargo")
    524         .args([
    525             "deny",
    526             "--format",
    527             "json",
    528             "--log-level",
    529             "warn",
    530             "--manifest-path",
    531             manifest
    532                 .to_str()
    533                 .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?,
    534             "--all-features",
    535             "--locked",
    536             "check",
    537             "--allow",
    538             "advisory-not-detected",
    539             "advisories",
    540         ])
    541         .current_dir(root)
    542         .output()
    543         .map_err(|error| format!("failed to start cargo-deny: {error}"))?;
    544     let stdout = String::from_utf8_lossy(&output.stdout);
    545     let stderr = String::from_utf8_lossy(&output.stderr);
    546     if !output.status.success() {
    547         return Err(format!(
    548             "advisory qualification failed for {package}: {stdout}{stderr}"
    549         ));
    550     }
    551 
    552     let mut saw_governed_yank = false;
    553     for line in stdout.lines().chain(stderr.lines()) {
    554         let Ok(message) = serde_json::from_str::<Value>(line) else {
    555             continue;
    556         };
    557         if message.get("type").and_then(Value::as_str) != Some("diagnostic") {
    558             continue;
    559         }
    560         let fields = message
    561             .get("fields")
    562             .and_then(Value::as_object)
    563             .ok_or_else(|| "cargo-deny emitted a malformed diagnostic".to_owned())?;
    564         let code = fields
    565             .get("code")
    566             .and_then(Value::as_str)
    567             .unwrap_or_default();
    568         let krate = fields
    569             .get("graphs")
    570             .and_then(Value::as_array)
    571             .and_then(|graphs| graphs.first())
    572             .and_then(|graph| graph.get("Krate"));
    573         let exact_governed_yank = code == "yanked"
    574             && krate
    575                 .and_then(|krate| krate.get("name"))
    576                 .and_then(Value::as_str)
    577                 == Some("spin")
    578             && krate
    579                 .and_then(|krate| krate.get("version"))
    580                 .and_then(Value::as_str)
    581                 == Some("0.9.8");
    582         if exact_governed_yank {
    583             saw_governed_yank = true;
    584         } else {
    585             return Err(format!(
    586                 "unapproved cargo-deny diagnostic for {package}: {line}"
    587             ));
    588         }
    589     }
    590     Ok(saw_governed_yank)
    591 }
    592 
    593 fn qualify_sboms(
    594     root: &Path,
    595     contract: &Contract,
    596     metadata: &Metadata,
    597 ) -> Result<BTreeMap<String, String>, String> {
    598     let mut paths = metadata
    599         .packages
    600         .iter()
    601         .map(|package| {
    602             package
    603                 .manifest_path
    604                 .parent()
    605                 .expect("manifest has parent")
    606                 .join(SBOM_FILENAME)
    607         })
    608         .collect::<Vec<_>>();
    609     paths.sort();
    610     paths.dedup();
    611     if let Some(path) = paths.iter().find(|path| path.exists()) {
    612         return Err(format!(
    613             "refusing to overwrite pre-existing SBOM {}",
    614             path.display()
    615         ));
    616     }
    617     let _generated = GeneratedSboms(paths);
    618 
    619     let status = Command::new("cargo")
    620         .args([
    621             "cyclonedx",
    622             "--quiet",
    623             "--manifest-path",
    624             "Cargo.toml",
    625             "--format",
    626             &contract.sbom.format,
    627             "--all-features",
    628             "--target",
    629             &contract.sbom.target,
    630             "--spec-version",
    631             &contract.sbom.spec_version,
    632             "--license-strict",
    633             "--license-accept-named",
    634             "MIT/Apache-2.0",
    635             "--license-accept-named",
    636             "Apache-2.0/MIT",
    637             "--license-accept-named",
    638             "Apache-2.0 / MIT",
    639             "--override-filename",
    640             "radroots-release-v1-sbom",
    641         ])
    642         .env(
    643             "SOURCE_DATE_EPOCH",
    644             contract.sbom.source_date_epoch.to_string(),
    645         )
    646         .current_dir(root)
    647         .status()
    648         .map_err(|error| format!("failed to start cargo-cyclonedx: {error}"))?;
    649     if !status.success() {
    650         return Err("CycloneDX SBOM generation failed".to_owned());
    651     }
    652 
    653     let by_name = metadata
    654         .packages
    655         .iter()
    656         .map(|package| (package.name.as_str(), package))
    657         .collect::<BTreeMap<_, _>>();
    658     let mut hashes = BTreeMap::new();
    659     for package in &contract.package {
    660         let metadata_package = by_name
    661             .get(package.name.as_str())
    662             .ok_or_else(|| format!("cargo metadata omitted {}", package.name))?;
    663         if metadata_package.version != contract.package_version {
    664             return Err(format!("{} metadata version drifted", package.name));
    665         }
    666         let path = metadata_package
    667             .manifest_path
    668             .parent()
    669             .expect("manifest has parent")
    670             .join(SBOM_FILENAME);
    671         let raw = fs::read(&path)
    672             .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
    673         let mut sbom: Value = serde_json::from_slice(&raw)
    674             .map_err(|error| format!("invalid SBOM {}: {error}", path.display()))?;
    675         validate_sbom(&sbom, &package.name, &contract.package_version)?;
    676         normalize_sbom(&mut sbom, root);
    677         hashes.insert(
    678             package.name.clone(),
    679             sha256(&serde_json::to_vec(&sbom).map_err(|error| error.to_string())?),
    680         );
    681     }
    682     Ok(hashes)
    683 }
    684 
    685 fn validate_sbom(sbom: &Value, name: &str, version: &str) -> Result<(), String> {
    686     let component = sbom
    687         .pointer("/metadata/component")
    688         .and_then(Value::as_object)
    689         .ok_or_else(|| format!("{name} SBOM has no root component"))?;
    690     if sbom.get("bomFormat").and_then(Value::as_str) != Some("CycloneDX")
    691         || sbom.get("specVersion").and_then(Value::as_str) != Some("1.5")
    692         || component.get("name").and_then(Value::as_str) != Some(name)
    693         || component.get("version").and_then(Value::as_str) != Some(version)
    694         || sbom
    695             .get("components")
    696             .and_then(Value::as_array)
    697             .is_none_or(Vec::is_empty)
    698         || sbom
    699             .get("dependencies")
    700             .and_then(Value::as_array)
    701             .is_none_or(Vec::is_empty)
    702     {
    703         return Err(format!(
    704             "{name} SBOM is incomplete or identifies the wrong package"
    705         ));
    706     }
    707     Ok(())
    708 }
    709 
    710 fn normalize_sbom(value: &mut Value, root: &Path) {
    711     normalize_sbom_value(value, root.to_string_lossy().as_ref());
    712 }
    713 
    714 fn normalize_sbom_value(value: &mut Value, root: &str) {
    715     match value {
    716         Value::Object(object) => {
    717             object.remove("serialNumber");
    718             for value in object.values_mut() {
    719                 normalize_sbom_value(value, root);
    720             }
    721         }
    722         Value::Array(values) => {
    723             for value in values {
    724                 normalize_sbom_value(value, root);
    725             }
    726         }
    727         Value::String(string) if string.contains(root) => {
    728             *string = string.replace(root, "$REPOSITORY");
    729         }
    730         _ => {}
    731     }
    732 }
    733 
    734 fn build_provenance(
    735     root: &Path,
    736     contract: &Contract,
    737     sbom_hashes: &BTreeMap<String, String>,
    738 ) -> Result<Value, String> {
    739     let revision = command_stdout(root, "git", &["rev-parse", "HEAD"])?;
    740     if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
    741         return Err("Git provenance revision is not a full commit ID".to_owned());
    742     }
    743     let lock = fs::read(root.join("Cargo.lock"))
    744         .map_err(|error| format!("failed to read Cargo.lock: {error}"))?;
    745     let packages = contract
    746         .package
    747         .iter()
    748         .map(|package| {
    749             json!({
    750                 "name": package.name,
    751                 "version": contract.package_version,
    752                 "manifestPath": package.manifest_path,
    753                 "sbomSha256": sbom_hashes.get(&package.name),
    754             })
    755         })
    756         .collect::<Vec<_>>();
    757     Ok(json!({
    758         "schemaVersion": 1,
    759         "specId": contract.spec_id,
    760         "source": {
    761             "gitCommit": revision,
    762             "cargoLockSha256": sha256(&lock),
    763         },
    764         "tools": {
    765             "cargoDeny": contract.tools.cargo_deny,
    766             "cargoCyclonedx": contract.tools.cargo_cyclonedx,
    767         },
    768         "packages": packages,
    769         "advisoryExceptions": contract.advisory_exception,
    770     }))
    771 }
    772 
    773 fn validate_provenance(provenance: &Value, contract: &Contract) -> Result<(), String> {
    774     if provenance.get("schemaVersion").and_then(Value::as_u64) != Some(1)
    775         || provenance.get("specId").and_then(Value::as_str) != Some(contract.spec_id.as_str())
    776         || provenance
    777             .get("packages")
    778             .and_then(Value::as_array)
    779             .is_none_or(|packages| packages.len() != contract.package.len())
    780         || provenance
    781             .pointer("/source/cargoLockSha256")
    782             .and_then(Value::as_str)
    783             .is_none_or(|hash| hash.len() != 64)
    784     {
    785         return Err("generated supply-chain provenance is incomplete".to_owned());
    786     }
    787     Ok(())
    788 }
    789 
    790 fn run_command(root: &Path, program: &str, args: Vec<&str>, failure: &str) -> Result<(), String> {
    791     let status = Command::new(program)
    792         .args(args)
    793         .current_dir(root)
    794         .status()
    795         .map_err(|error| format!("failed to start {program}: {error}"))?;
    796     if status.success() {
    797         Ok(())
    798     } else {
    799         Err(failure.to_owned())
    800     }
    801 }
    802 
    803 fn command_stdout(root: &Path, program: &str, args: &[&str]) -> Result<String, String> {
    804     let output = Command::new(program)
    805         .args(args)
    806         .current_dir(root)
    807         .output()
    808         .map_err(|error| format!("failed to start {program}: {error}"))?;
    809     if !output.status.success() {
    810         return Err(format!("{program} {} failed", args.join(" ")));
    811     }
    812     String::from_utf8(output.stdout)
    813         .map(|value| value.trim().to_owned())
    814         .map_err(|error| format!("{program} emitted non-UTF-8 output: {error}"))
    815 }
    816 
    817 fn sha256(bytes: &[u8]) -> String {
    818     format!("{:x}", Sha256::digest(bytes))
    819 }
    820 
    821 #[cfg(test)]
    822 mod tests {
    823     use super::*;
    824 
    825     fn root() -> PathBuf {
    826         Path::new(env!("CARGO_MANIFEST_DIR"))
    827             .parent()
    828             .and_then(Path::parent)
    829             .expect("workspace root")
    830             .to_path_buf()
    831     }
    832 
    833     #[test]
    834     fn current_contract_is_exact_and_exception_bound() {
    835         let root = root();
    836         let contract = load(&root).expect("contract");
    837         validate(&root, &contract, 19).expect("valid contract");
    838     }
    839 
    840     #[test]
    841     fn sbom_validation_rejects_wrong_identity() {
    842         let sbom = json!({
    843             "bomFormat": "CycloneDX",
    844             "specVersion": "1.5",
    845             "metadata": {"component": {"name": "wrong", "version": "0.1.0-alpha"}},
    846             "components": [{}],
    847             "dependencies": [{}],
    848         });
    849         assert!(validate_sbom(&sbom, "radroots_core", "0.1.0-alpha").is_err());
    850     }
    851 
    852     #[test]
    853     fn normalization_removes_random_and_absolute_identity() {
    854         let mut sbom = json!({
    855             "serialNumber": "urn:uuid:random",
    856             "path": "/workspace/crate",
    857         });
    858         normalize_sbom(&mut sbom, Path::new("/workspace"));
    859         assert!(sbom.get("serialNumber").is_none());
    860         assert_eq!(
    861             sbom.get("path").and_then(Value::as_str),
    862             Some("$REPOSITORY/crate")
    863         );
    864     }
    865 }