lib

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

commit b9306511817a8abf8d560c30db4d1e7cac8866cd
parent c46b39be5ee7517f238ad8de29853fd3296eae3b
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 00:06:25 +0000

release: qualify front-door supply chains

- refresh vulnerable and yanked dependency resolutions
- enforce scoped advisory license and source policy
- validate normalized CycloneDX front-door inventories
- bind exceptions source identities and provenance

Diffstat:
Mtools/sdk_xtask_import/src/main.rs | 19++++++++++++++++++-
Atools/sdk_xtask_import/src/supply_chain_qualification.rs | 715+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 733 insertions(+), 1 deletion(-)

diff --git a/tools/sdk_xtask_import/src/main.rs b/tools/sdk_xtask_import/src/main.rs @@ -17,6 +17,7 @@ mod package_metadata; mod portable_qualification; mod release_qualification; mod smoke; +mod supply_chain_qualification; mod target_qualification; mod ts; mod wasm; @@ -36,6 +37,7 @@ enum CommandAction<'a> { QualifyFeatures, QualifyApi, QualifyPortable, + QualifySupplyChain, QualifyTargets, Check, Smoke(&'a [String]), @@ -74,6 +76,9 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { } CommandAction::QualifyApi => api_qualification::run(&fs::workspace_root()?), CommandAction::QualifyPortable => portable_qualification::run(&fs::workspace_root()?), + CommandAction::QualifySupplyChain => { + supply_chain_qualification::run(&fs::workspace_root()?) + } CommandAction::QualifyTargets => target_qualification::run(&fs::workspace_root()?), CommandAction::Check => check::check(), CommandAction::Smoke(rest) => smoke::run(rest), @@ -111,6 +116,9 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { [command, target] if command == "release" && target == "qualify-portable" => { Ok(CommandAction::QualifyPortable) } + [command, target] if command == "release" && target == "qualify-supply-chain" => { + Ok(CommandAction::QualifySupplyChain) + } [command, target] if command == "release" && target == "qualify-targets" => { Ok(CommandAction::QualifyTargets) } @@ -122,7 +130,7 @@ fn command_action(args: &[String]) -> Result<CommandAction<'_>, String> { } fn usage() -> String { - "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run | cargo xtask release qualify-features | cargo xtask release qualify-api | cargo xtask release qualify-portable | cargo xtask release qualify-targets" + "usage: cargo xtask architecture | cargo xtask architecture-ci | cargo xtask check-api-boundaries | cargo xtask check-dependency-boundaries | cargo xtask generate | cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask generate bindings <swift|kotlin> | cargo xtask generate package-metadata | cargo xtask check | cargo xtask smoke facade-rust-local | sdk-rust-local | front-doors-rust-local | cargo xtask coverage run | cargo xtask release qualify-features | cargo xtask release qualify-api | cargo xtask release qualify-portable | cargo xtask release qualify-supply-chain | cargo xtask release qualify-targets" .to_owned() } @@ -243,6 +251,15 @@ mod tests { } #[test] + fn accepts_supply_chain_qualification() { + let args = ["release".to_owned(), "qualify-supply-chain".to_owned()]; + assert!(matches!( + command_action(&args).expect("action"), + CommandAction::QualifySupplyChain + )); + } + + #[test] fn rejects_unknown_command() { let args = ["generate".to_owned(), "swift".to_owned()]; assert!(command_action(&args).is_err()); diff --git a/tools/sdk_xtask_import/src/supply_chain_qualification.rs b/tools/sdk_xtask_import/src/supply_chain_qualification.rs @@ -0,0 +1,715 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const CONTRACT_PATH: &str = "contracts/releases/supply_chain.toml"; +const DENY_PATH: &str = "deny.toml"; +const SBOM_FILENAME: &str = "radroots-release-v1-sbom.json"; + +#[derive(Debug, Deserialize)] +struct Contract { + schema_version: u16, + spec_id: String, + package_version: String, + tools: Tools, + sbom: Sbom, + advisory_exception: Vec<AdvisoryException>, + package: Vec<Package>, +} + +#[derive(Debug, Deserialize)] +struct Tools { + cargo_deny: String, + cargo_cyclonedx: String, +} + +#[derive(Debug, Deserialize)] +struct Sbom { + format: String, + spec_version: String, + target: String, + all_features: bool, + source_date_epoch: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct AdvisoryException { + id: String, + package: String, + affected_version: String, + introduced_by: String, + classification: String, + mitigation: String, + remove_when: String, +} + +#[derive(Debug, Deserialize)] +struct Package { + name: String, + manifest_path: String, +} + +#[derive(Debug, Deserialize)] +struct Metadata { + packages: Vec<MetadataPackage>, +} + +#[derive(Debug, Deserialize)] +struct MetadataPackage { + name: String, + version: String, + manifest_path: PathBuf, +} + +struct GeneratedSboms(Vec<PathBuf>); + +impl Drop for GeneratedSboms { + fn drop(&mut self) { + for path in &self.0 { + let _ = fs::remove_file(path); + } + } +} + +pub fn run(root: &Path) -> Result<(), String> { + let contract = load(root)?; + validate(root, &contract, 2)?; + verify_tools(&contract)?; + let metadata = load_metadata(root)?; + qualify_dependencies(root, &contract)?; + let sbom_hashes = qualify_sboms(root, &contract, &metadata)?; + let provenance = build_provenance(root, &contract, &sbom_hashes)?; + validate_provenance(&provenance, &contract)?; + eprintln!( + "qualified {} package supply chains; provenance sha256={}", + contract.package.len(), + sha256(&serde_json::to_vec(&provenance).map_err(|error| error.to_string())?) + ); + Ok(()) +} + +fn load(root: &Path) -> Result<Contract, String> { + let path = root.join(CONTRACT_PATH); + let raw = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Result<(), String> { + if contract.schema_version != 1 + || contract.spec_id != "radroots.crates.release.v1" + || contract.package_version != "0.1.0-alpha" + || contract.tools.cargo_deny != "0.19.8" + || contract.tools.cargo_cyclonedx != "0.5.9" + || contract.sbom.format != "json" + || contract.sbom.spec_version != "1.5" + || contract.sbom.target != "all" + || !contract.sbom.all_features + || contract.sbom.source_date_epoch != 0 + { + return Err("invalid supply-chain qualification contract".to_owned()); + } + + let names = contract + .package + .iter() + .map(|package| package.name.as_str()) + .collect::<BTreeSet<_>>(); + let manifests = contract + .package + .iter() + .map(|package| package.manifest_path.as_str()) + .collect::<BTreeSet<_>>(); + if names.len() != expected_packages + || names.len() != contract.package.len() + || manifests.len() != contract.package.len() + { + return Err(format!( + "supply-chain contract requires exactly {expected_packages} unique packages and manifests" + )); + } + for package in &contract.package { + let path = root.join(&package.manifest_path); + let raw = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let manifest: toml::Value = toml::from_str(&raw) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + let package_table = manifest + .get("package") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("{} has no package table", path.display()))?; + let name = package_table.get("name").and_then(toml::Value::as_str); + let version = package_table.get("version").and_then(toml::Value::as_str); + let inherits_workspace_version = package_table + .get("version") + .and_then(toml::Value::as_table) + .and_then(|version| version.get("workspace")) + .and_then(toml::Value::as_bool) + == Some(true); + if name != Some(package.name.as_str()) + || version != Some(contract.package_version.as_str()) && !inherits_workspace_version + { + return Err(format!( + "{} does not declare {} {}", + path.display(), + package.name, + contract.package_version + )); + } + } + + validate_exceptions(root, contract) +} + +fn validate_exceptions(root: &Path, contract: &Contract) -> Result<(), String> { + let expected = BTreeSet::from([ + "CARGO-YANKED-SPIN-0.9.8".to_owned(), + "RUSTSEC-2024-0384".to_owned(), + "RUSTSEC-2024-0421".to_owned(), + ]); + let actual = contract + .advisory_exception + .iter() + .map(|exception| exception.id.clone()) + .collect::<BTreeSet<_>>(); + if actual != expected || actual.len() != contract.advisory_exception.len() { + return Err("supply-chain advisory exceptions are not the exact approved set".to_owned()); + } + for exception in &contract.advisory_exception { + if exception.package.is_empty() + || exception.affected_version.is_empty() + || exception.introduced_by.is_empty() + || exception.classification.is_empty() + || exception.mitigation.is_empty() + || exception.remove_when.is_empty() + { + return Err(format!("advisory exception {} is incomplete", exception.id)); + } + let exact = match exception.id.as_str() { + "RUSTSEC-2024-0384" => { + exception.package == "instant" + && exception.affected_version == "0.1.13" + && exception.classification == "unmaintained" + && exception.remove_when == "nostr >=0.45.0 stable" + } + "RUSTSEC-2024-0421" => { + exception.package == "idna" + && exception.affected_version == "0.5.0" + && exception.classification == "vulnerability" + && exception.remove_when == "nostr >=0.45.0 stable" + } + "CARGO-YANKED-SPIN-0.9.8" => { + exception.package == "spin" + && exception.affected_version == "0.9.8" + && exception.classification == "yanked" + && exception.remove_when + == "sqlx no longer resolves flume 0.12.0 with spin 0.9.8" + } + _ => false, + }; + if !exact { + return Err(format!( + "advisory exception {} differs from its exact approved policy", + exception.id + )); + } + } + + let lock_raw = fs::read_to_string(root.join("Cargo.lock")) + .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; + let lock: toml::Value = toml::from_str(&lock_raw) + .map_err(|error| format!("failed to parse Cargo.lock: {error}"))?; + let locked_packages = lock + .get("package") + .and_then(toml::Value::as_array) + .ok_or_else(|| "Cargo.lock contains no packages".to_owned())?; + for exception in &contract.advisory_exception { + let present = locked_packages.iter().any(|package| { + package.get("name").and_then(toml::Value::as_str) == Some(exception.package.as_str()) + && package.get("version").and_then(toml::Value::as_str) + == Some(exception.affected_version.as_str()) + }); + if !present { + return Err(format!( + "advisory exception {} is stale because {} {} is absent from Cargo.lock", + exception.id, exception.package, exception.affected_version + )); + } + } + let patched_url_present = locked_packages.iter().any(|package| { + package.get("name").and_then(toml::Value::as_str) == Some("url") + && package + .get("version") + .and_then(toml::Value::as_str) + .and_then(|version| semver::Version::parse(version).ok()) + .is_some_and(|version| version >= semver::Version::new(2, 5, 4)) + }); + if !patched_url_present { + return Err("the IDNA exception requires url 2.5.4 or newer in Cargo.lock".to_owned()); + } + + let deny_raw = fs::read_to_string(root.join(DENY_PATH)) + .map_err(|error| format!("failed to read {DENY_PATH}: {error}"))?; + let deny: toml::Value = toml::from_str(&deny_raw) + .map_err(|error| format!("failed to parse {DENY_PATH}: {error}"))?; + let ignored = deny + .get("advisories") + .and_then(|value| value.get("ignore")) + .and_then(toml::Value::as_array) + .ok_or_else(|| "deny.toml advisories.ignore is missing".to_owned())? + .iter() + .filter_map(toml::Value::as_str) + .map(str::to_owned) + .collect::<BTreeSet<_>>(); + let expected_ignored = expected + .iter() + .filter(|id| id.starts_with("RUSTSEC-")) + .cloned() + .collect::<BTreeSet<_>>(); + if ignored != expected_ignored { + return Err("deny.toml advisory ignores differ from the governed exceptions".to_owned()); + } + + let relay_source = root.join("crates/transport_nostr/src/relay.rs"); + if relay_source.exists() { + let source = fs::read_to_string(&relay_source) + .map_err(|error| format!("failed to read {}: {error}", relay_source.display()))?; + if !source.contains("Url::parse(canonical)") { + return Err( + "the IDNA exception requires patched relay URL canonicalization".to_owned(), + ); + } + } + Ok(()) +} + +fn verify_tools(contract: &Contract) -> Result<(), String> { + verify_tool( + &["deny", "--version"], + "cargo-deny", + &contract.tools.cargo_deny, + )?; + verify_tool( + &["cyclonedx", "--version"], + "cargo-cyclonedx", + &contract.tools.cargo_cyclonedx, + ) +} + +fn verify_tool(args: &[&str], name: &str, expected: &str) -> Result<(), String> { + let output = Command::new("cargo") + .args(args) + .output() + .map_err(|error| format!("failed to start {name}: {error}"))?; + if !output.status.success() { + return Err(format!("{name} {expected} is required")); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let installed = stdout + .split_whitespace() + .find_map(|value| semver::Version::parse(value).ok()) + .ok_or_else(|| format!("could not parse {name} version: {stdout}"))?; + let expected = semver::Version::parse(expected) + .map_err(|error| format!("invalid governed {name} version: {error}"))?; + if installed != expected { + return Err(format!("{name} {expected} is required, found {installed}")); + } + Ok(()) +} + +fn load_metadata(root: &Path) -> Result<Metadata, String> { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) + .current_dir(root) + .output() + .map_err(|error| format!("failed to start cargo metadata: {error}"))?; + if !output.status.success() { + return Err("locked cargo metadata failed".to_owned()); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("failed to parse cargo metadata: {error}")) +} + +fn qualify_dependencies(root: &Path, contract: &Contract) -> Result<(), String> { + let mut saw_governed_yank = false; + for package in &contract.package { + let manifest = root.join(&package.manifest_path); + let common = [ + "deny", + "-L", + "error", + "--manifest-path", + manifest + .to_str() + .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?, + "--all-features", + "--locked", + "check", + ]; + saw_governed_yank |= qualify_advisories(root, &manifest, &package.name)?; + run_command( + root, + "cargo", + common + .iter() + .copied() + .chain(["bans", "licenses", "sources"]) + .collect::<Vec<_>>(), + &format!("dependency policy failed for {}", package.name), + )?; + } + if !saw_governed_yank { + return Err("the governed spin 0.9.8 yank is stale and must be removed".to_owned()); + } + Ok(()) +} + +fn qualify_advisories(root: &Path, manifest: &Path, package: &str) -> Result<bool, String> { + let output = Command::new("cargo") + .args([ + "deny", + "--format", + "json", + "--log-level", + "warn", + "--manifest-path", + manifest + .to_str() + .ok_or_else(|| "non-UTF-8 package manifest path".to_owned())?, + "--all-features", + "--locked", + "check", + "--allow", + "advisory-not-detected", + "advisories", + ]) + .current_dir(root) + .output() + .map_err(|error| format!("failed to start cargo-deny: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + return Err(format!( + "advisory qualification failed for {package}: {stdout}{stderr}" + )); + } + + let mut saw_governed_yank = false; + for line in stdout.lines().chain(stderr.lines()) { + let Ok(message) = serde_json::from_str::<Value>(line) else { + continue; + }; + if message.get("type").and_then(Value::as_str) != Some("diagnostic") { + continue; + } + let fields = message + .get("fields") + .and_then(Value::as_object) + .ok_or_else(|| "cargo-deny emitted a malformed diagnostic".to_owned())?; + let code = fields + .get("code") + .and_then(Value::as_str) + .unwrap_or_default(); + let krate = fields + .get("graphs") + .and_then(Value::as_array) + .and_then(|graphs| graphs.first()) + .and_then(|graph| graph.get("Krate")); + let exact_governed_yank = code == "yanked" + && krate + .and_then(|krate| krate.get("name")) + .and_then(Value::as_str) + == Some("spin") + && krate + .and_then(|krate| krate.get("version")) + .and_then(Value::as_str) + == Some("0.9.8"); + if exact_governed_yank { + saw_governed_yank = true; + } else { + return Err(format!( + "unapproved cargo-deny diagnostic for {package}: {line}" + )); + } + } + Ok(saw_governed_yank) +} + +fn qualify_sboms( + root: &Path, + contract: &Contract, + metadata: &Metadata, +) -> Result<BTreeMap<String, String>, String> { + let mut paths = metadata + .packages + .iter() + .map(|package| { + package + .manifest_path + .parent() + .expect("manifest has parent") + .join(SBOM_FILENAME) + }) + .collect::<Vec<_>>(); + paths.sort(); + paths.dedup(); + if let Some(path) = paths.iter().find(|path| path.exists()) { + return Err(format!( + "refusing to overwrite pre-existing SBOM {}", + path.display() + )); + } + let _generated = GeneratedSboms(paths); + + let status = Command::new("cargo") + .args([ + "cyclonedx", + "--quiet", + "--manifest-path", + "Cargo.toml", + "--format", + &contract.sbom.format, + "--all-features", + "--target", + &contract.sbom.target, + "--spec-version", + &contract.sbom.spec_version, + "--license-strict", + "--license-accept-named", + "MIT/Apache-2.0", + "--license-accept-named", + "Apache-2.0/MIT", + "--license-accept-named", + "Apache-2.0 / MIT", + "--override-filename", + "radroots-release-v1-sbom", + ]) + .env( + "SOURCE_DATE_EPOCH", + contract.sbom.source_date_epoch.to_string(), + ) + .current_dir(root) + .status() + .map_err(|error| format!("failed to start cargo-cyclonedx: {error}"))?; + if !status.success() { + return Err("CycloneDX SBOM generation failed".to_owned()); + } + + let by_name = metadata + .packages + .iter() + .map(|package| (package.name.as_str(), package)) + .collect::<BTreeMap<_, _>>(); + let mut hashes = BTreeMap::new(); + for package in &contract.package { + let metadata_package = by_name + .get(package.name.as_str()) + .ok_or_else(|| format!("cargo metadata omitted {}", package.name))?; + if metadata_package.version != contract.package_version { + return Err(format!("{} metadata version drifted", package.name)); + } + let path = metadata_package + .manifest_path + .parent() + .expect("manifest has parent") + .join(SBOM_FILENAME); + let raw = fs::read(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut sbom: Value = serde_json::from_slice(&raw) + .map_err(|error| format!("invalid SBOM {}: {error}", path.display()))?; + validate_sbom(&sbom, &package.name, &contract.package_version)?; + normalize_sbom(&mut sbom, root); + hashes.insert( + package.name.clone(), + sha256(&serde_json::to_vec(&sbom).map_err(|error| error.to_string())?), + ); + } + Ok(hashes) +} + +fn validate_sbom(sbom: &Value, name: &str, version: &str) -> Result<(), String> { + let component = sbom + .pointer("/metadata/component") + .and_then(Value::as_object) + .ok_or_else(|| format!("{name} SBOM has no root component"))?; + if sbom.get("bomFormat").and_then(Value::as_str) != Some("CycloneDX") + || sbom.get("specVersion").and_then(Value::as_str) != Some("1.5") + || component.get("name").and_then(Value::as_str) != Some(name) + || component.get("version").and_then(Value::as_str) != Some(version) + || sbom + .get("components") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + || sbom + .get("dependencies") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + { + return Err(format!( + "{name} SBOM is incomplete or identifies the wrong package" + )); + } + Ok(()) +} + +fn normalize_sbom(value: &mut Value, root: &Path) { + normalize_sbom_value(value, root.to_string_lossy().as_ref()); +} + +fn normalize_sbom_value(value: &mut Value, root: &str) { + match value { + Value::Object(object) => { + object.remove("serialNumber"); + for value in object.values_mut() { + normalize_sbom_value(value, root); + } + } + Value::Array(values) => { + for value in values { + normalize_sbom_value(value, root); + } + } + Value::String(string) if string.contains(root) => { + *string = string.replace(root, "$REPOSITORY"); + } + _ => {} + } +} + +fn build_provenance( + root: &Path, + contract: &Contract, + sbom_hashes: &BTreeMap<String, String>, +) -> Result<Value, String> { + let revision = command_stdout(root, "git", &["rev-parse", "HEAD"])?; + if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Git provenance revision is not a full commit ID".to_owned()); + } + let lock = fs::read(root.join("Cargo.lock")) + .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; + let packages = contract + .package + .iter() + .map(|package| { + json!({ + "name": package.name, + "version": contract.package_version, + "manifestPath": package.manifest_path, + "sbomSha256": sbom_hashes.get(&package.name), + }) + }) + .collect::<Vec<_>>(); + Ok(json!({ + "schemaVersion": 1, + "specId": contract.spec_id, + "source": { + "gitCommit": revision, + "cargoLockSha256": sha256(&lock), + }, + "tools": { + "cargoDeny": contract.tools.cargo_deny, + "cargoCyclonedx": contract.tools.cargo_cyclonedx, + }, + "packages": packages, + "advisoryExceptions": contract.advisory_exception, + })) +} + +fn validate_provenance(provenance: &Value, contract: &Contract) -> Result<(), String> { + if provenance.get("schemaVersion").and_then(Value::as_u64) != Some(1) + || provenance.get("specId").and_then(Value::as_str) != Some(contract.spec_id.as_str()) + || provenance + .get("packages") + .and_then(Value::as_array) + .is_none_or(|packages| packages.len() != contract.package.len()) + || provenance + .pointer("/source/cargoLockSha256") + .and_then(Value::as_str) + .is_none_or(|hash| hash.len() != 64) + { + return Err("generated supply-chain provenance is incomplete".to_owned()); + } + Ok(()) +} + +fn run_command(root: &Path, program: &str, args: Vec<&str>, failure: &str) -> Result<(), String> { + let status = Command::new(program) + .args(args) + .current_dir(root) + .status() + .map_err(|error| format!("failed to start {program}: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(failure.to_owned()) + } +} + +fn command_stdout(root: &Path, program: &str, args: &[&str]) -> Result<String, String> { + let output = Command::new(program) + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("failed to start {program}: {error}"))?; + if !output.status.success() { + return Err(format!("{program} {} failed", args.join(" "))); + } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_owned()) + .map_err(|error| format!("{program} emitted non-UTF-8 output: {error}")) +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .to_path_buf() + } + + #[test] + fn current_contract_is_exact_and_exception_bound() { + let root = root(); + let contract = load(&root).expect("contract"); + validate(&root, &contract, 2).expect("valid contract"); + } + + #[test] + fn sbom_validation_rejects_wrong_identity() { + let sbom = json!({ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": {"component": {"name": "wrong", "version": "0.1.0-alpha"}}, + "components": [{}], + "dependencies": [{}], + }); + assert!(validate_sbom(&sbom, "radroots_core", "0.1.0-alpha").is_err()); + } + + #[test] + fn normalization_removes_random_and_absolute_identity() { + let mut sbom = json!({ + "serialNumber": "urn:uuid:random", + "path": "/workspace/crate", + }); + normalize_sbom(&mut sbom, Path::new("/workspace")); + assert!(sbom.get("serialNumber").is_none()); + assert_eq!( + sbom.get("path").and_then(Value::as_str), + Some("$REPOSITORY/crate") + ); + } +}